"""
Kiwi TCMS JSON-RPC API client.

Initialization methods::

    # 1. From environment variables (default values)
    rpc = KiwiTCMSApi()

    # 2. Explicit parameters in the constructor
    rpc = KiwiTCMSApi(url='https://tcms.example.com', username='admin', password='secret')

    # 3. Via configurator (without immediate connection)
    rpc = KiwiTCMSApi.configure(url='https://tcms.example.com', username='admin', password='secret')
    rpc.connect()

    # 4. Via configuration dictionary
    cfg = {'url': 'https://tcms.example.com', 'username': 'admin', 'password': 'secret'}
    rpc = KiwiTCMSApi.from_config(cfg)

Environment variables:

    KIWI_TCMS_API_URL         — server URL (default: http://localhost:8000)
    KIWI_TCMS_API_USERNAME    — username
    KIWI_TCMS_API_PASSWORD    — password
    KIWI_TCMS_API_VERIFY_SSL  — SSL certificate verification: true/false (default: true)
"""

import requests
import urllib3
import os
from bs4 import BeautifulSoup

__version__ = '0.0.2'
__all__ = ['KiwiTCMSApi']

# === CONNECTION CONFIGURATION (environment variables) ===
KIWI_TCMS_API_URL = os.environ.get('KIWI_TCMS_API_URL', 'http://localhost:8000')
KIWI_TCMS_API_USERNAME = os.environ.get('KIWI_TCMS_API_USERNAME', '')
KIWI_TCMS_API_PASSWORD = os.environ.get('KIWI_TCMS_API_PASSWORD', '')
KIWI_TCMS_API_VERIFY_SSL = os.environ.get('KIWI_TCMS_API_VERIFY_SSL', 'true').lower() not in ('false', '0', 'no')

if not KIWI_TCMS_API_VERIFY_SSL:
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class KiwiTCMSApi:
    def __init__(self, url=None, username=None, password=None, verify_ssl=None, _autoconnect=True):
        """Initialize the client.

        Args:
            url (str, optional): Server URL. Defaults to KIWI_TCMS_API_URL.
            username (str, optional): Username. Defaults to KIWI_TCMS_API_USERNAME.
            password (str, optional): Password. Defaults to KIWI_TCMS_API_PASSWORD.
            verify_ssl (bool, optional): Verify SSL. Defaults to KIWI_TCMS_API_VERIFY_SSL.
            _autoconnect (bool): Perform login when the object is created (internal flag).
        """
        self.url = (url or KIWI_TCMS_API_URL).rstrip('/')
        self.username = username or KIWI_TCMS_API_USERNAME
        self.password = password or KIWI_TCMS_API_PASSWORD
        self.verify_ssl = verify_ssl if verify_ssl is not None else KIWI_TCMS_API_VERIFY_SSL
        self.session = requests.Session()
        self.rpc_id = 1
        if _autoconnect:
            self._login()

    @classmethod
    def configure(cls, url=None, username=None, password=None, verify_ssl=None):
        """Create an instance without an immediate connection.

        Allows parameters to be configured in advance and connect() to be called later.

        Args:
            url (str, optional): Server URL.
            username (str, optional): Username.
            password (str, optional): Password.
            verify_ssl (bool, optional): Verify SSL.

        Returns:
            KiwiTCMSApi: unconfigured (not connected) instance.

        Example:
            rpc = KiwiTCMSApi.configure(url='https://tcms.example.com', username='admin', password='secret')
            rpc.connect()
        """
        return cls(url=url, username=username, password=password, verify_ssl=verify_ssl, _autoconnect=False)

    @classmethod
    def from_config(cls, config):
        """Create an instance from a configuration dictionary.

        Args:
            config (dict): Dictionary with keys url, username, password, verify_ssl (all optional).

        Returns:
            KiwiTCMSApi: connected instance.

        Example:
            cfg = {
                'url': 'https://tcms.example.com',
                'username': 'admin',
                'password': 'secret',
                'verify_ssl': False,
            }
            rpc = KiwiTCMSApi.from_config(cfg)
        """
        return cls(
            url=config.get('url'),
            username=config.get('username'),
            password=config.get('password'),
            verify_ssl=config.get('verify_ssl'),
        )

    def connect(self):
        """Perform login (used after configure()).

        Returns:
            KiwiTCMSApi: self (for method chaining).

        Example:
            rpc = KiwiTCMSApi.configure(url='https://tcms.example.com', username='admin', password='secret')
            rpc.connect()
        """
        self._login()
        return self

    def _login(self):
        """Login and obtain CSRF token + Cookies"""
        login_url = f"{self.url}/accounts/login/"

        resp = self.session.get(login_url, verify=self.verify_ssl)
        soup = BeautifulSoup(resp.text, 'html.parser')
        csrf = soup.find('input', {'name': 'csrfmiddlewaretoken'})
        if not csrf:
            raise Exception("Failed to obtain CSRF token")

        data = {
            'username': self.username,
            'password': self.password,
            'csrfmiddlewaretoken': csrf['value'],
            'next': '/'
        }
        headers = {'Referer': login_url, 'Origin': self.url}
        resp = self.session.post(login_url, data=data, headers=headers, verify=self.verify_ssl)

        if "accounts/login" in resp.url:
            raise Exception("Invalid username or password")

        print(f"✅ Login successful ({self.username})")

    def exec(self, method, params=None):
        """Call a JSON-RPC API method.

        Args:
            method (str): Method name, e.g. 'Product.filter'
            params (list): List of positional parameters

        Returns:
            Result of the method call
        """
        if params is None:
            params = []

        payload = {
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
            "id": self.rpc_id
        }
        self.rpc_id += 1

        resp = self.session.post(
            f"{self.url}/json-rpc/",
            json=payload,
            headers={"Content-Type": "application/json"},
            verify=self.verify_ssl
        )
        result = resp.json()

        if "error" in result:
            raise Exception(f"API Error: {result['error']}")

        return result.get("result")

    def discover_methods(self, verbose=True):
        """Check the availability of known methods.

        Args:
            verbose (bool): Print detailed information

        Returns:
            dict: {'available': [...], 'unavailable': [...], 'requires_params': [...]}
        """
        available = []
        unavailable = []
        requires_params = []

        if verbose:
            print("🔍 Checking available methods...")

        for method in self.KNOWN_METHODS:
            try:
                result = self.exec(method, [{}])
                available.append(method)
                if verbose: print(f"  ✅ {method}")
            except Exception as e:
                if "Method not found" not in str(e):
                    available.append(method)
                    requires_params.append(method)
                    if verbose: print(f"  ✅ {method} (requires parameters)")
                else:
                    unavailable.append(method)
                    if verbose: print(f"  ❌ {method}")

        if verbose:
            print(f"\n✅ Available: {len(available)} | ❌ Unavailable: {len(unavailable)}")

        return {'available': available, 'unavailable': unavailable, 'requires_params': requires_params}

    def __getattr__(self, name):
        """Allows calling methods as rpc.Product.filter()"""
        class APIMethod:
            def __init__(self, api, module):
                self.api = api
                self.module = module
            def __getattr__(self, method):
                def caller(*args):
                    full_method = f"{self.module}.{method}"
                    return self.api.exec(full_method, list(args) if args else [{}])
                return caller
        return APIMethod(self, name)

    # =========================================================================
    # STATIC METHODS WITH EXPLICIT PARAMETERS
    # Source: https://kiwitcms.readthedocs.io/en/latest/modules/tcms.rpc.api.html
    # =========================================================================

    # === Attachment ===

    def Attachment_remove_attachment(self, attachment_id):
        """Delete an attachment by its ID.

        Args:
            attachment_id (int): Attachment PK

        Returns:
            None

        Raises:
            Exception: if the attachment is not found or deletion failed

        Example:
            rpc.Attachment_remove_attachment(42)
        """
        return self.exec('Attachment.remove_attachment', [attachment_id])

    # === Auth ===

    def Auth_login(self, username, password):
        """Log in and obtain the session ID.

        Args:
            username (str): Username
            password (str): Password

        Returns:
            str: Session ID

        Raises:
            PermissionDenied: if the credentials are invalid

        Example:
            session_id = rpc.Auth_login('admin', 'secret')
        """
        return self.exec('Auth.login', [username, password])

    def Auth_logout(self):
        """Terminate the current session.

        Returns:
            None

        Example:
            rpc.Auth_logout()
        """
        return self.exec('Auth.logout', [])

    # === Bug ===

    def Bug_details(self, url):
        """Get bug details by URL in the bug tracker.

        Args:
            url (str): Bug URL in the external tracker

        Returns:
            dict: Bug details (structure depends on the tracker)

        Example:
            details = rpc.Bug_details('https://bugzilla.example.com/show_bug.cgi?id=123')
        """
        return self.exec('Bug.details', [url])

    def Bug_report(self, execution_id, tracker_id):
        """Create a bug report in the tracker based on the execution result.

        Args:
            execution_id (int): TestExecution PK
            tracker_id (int): BugSystem PK (bug tracker)

        Returns:
            dict: {'success': True, 'url': '...'} or {'success': False, 'message': '...'}

        Example:
            result = rpc.Bug_report(execution_id=101, tracker_id=1)
            print(result['url'])
        """
        return self.exec('Bug.report', [execution_id, tracker_id])

    # === BugTracker ===

    def BugTracker_create(self, values):
        """Create a new bug tracker (BugSystem).

        Args:
            values (dict): BugSystem model fields, e.g.:
                {
                    'name': 'Jira',
                    'tracker_type': 'tcms_github_integration.issues.API',
                    'base_url': 'https://jira.example.com'
                }

        Returns:
            dict: Serialized BugSystem object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no testcases.add_bugsystem permission

        Example:
            bt = rpc.BugTracker_create({'name': 'GitHub', 'base_url': 'https://github.com/org/repo'})
        """
        return self.exec('BugTracker.create', [values])

    def BugTracker_filter(self, query=None):
        """Find bug trackers by filter.

        Args:
            query (dict, optional): BugSystem field filter conditions.
                Empty dict or None — return all.

        Returns:
            list[dict]: List of BugSystem objects

        Raises:
            PermissionDenied: no testcases.view_bugsystem permission

        Example:
            trackers = rpc.BugTracker_filter({'name__icontains': 'jira'})
        """
        return self.exec('BugTracker.filter', [query or {}])

    # === Build ===

    def Build_create(self, values):
        """Create a new build (Build).

        Args:
            values (dict): Build model fields, e.g.:
                {'name': '1.0-beta', 'version': 10, 'is_active': True}

        Returns:
            dict: Serialized Build object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no management.add_build permission

        Example:
            build = rpc.Build_create({'name': '2.0.0', 'version': 5})
        """
        return self.exec('Build.create', [values])

    def Build_filter(self, query=None):
        """Find builds by filter.

        Args:
            query (dict, optional): Build field filter conditions.

        Returns:
            list[dict]: List of Build objects

        Example:
            builds = rpc.Build_filter({'version': 10, 'is_active': True})
            all_builds = rpc.Build_filter()
        """
        return self.exec('Build.filter', [query or {}])

    def Build_update(self, build_id, values):
        """Update build fields.

        Args:
            build_id (int): Build PK
            values (dict): New field values, e.g. {'is_active': False}

        Returns:
            dict: Serialized updated Build object

        Raises:
            Build.DoesNotExist: build not found
            PermissionDenied: no management.change_build permission
            ValueError: invalid data

        Example:
            rpc.Build_update(12, {'name': '2.0.1-rc1', 'is_active': True})
        """
        return self.exec('Build.update', [build_id, values])

    # === Category ===

    def Category_create(self, values):
        """Create a new test case category.

        Args:
            values (dict): Category model fields, e.g.:
                {'name': 'Regression', 'product': 1}

        Returns:
            dict: Serialized Category object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no testcases.add_category permission

        Example:
            cat = rpc.Category_create({'name': 'Smoke', 'product': 1})
        """
        return self.exec('Category.create', [values])

    def Category_filter(self, query=None):
        """Find categories by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Category objects

        Example:
            cats = rpc.Category_filter({'product': 1})
        """
        return self.exec('Category.filter', [query or {}])

    # === Classification ===

    def Classification_create(self, values):
        """Create a new product classification.

        Args:
            values (dict): Classification model fields, e.g.:
                {'name': 'Enterprise'}

        Returns:
            dict: Serialized Classification object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no management.add_classification permission

        Example:
            cls = rpc.Classification_create({'name': 'Cloud'})
        """
        return self.exec('Classification.create', [values])

    def Classification_filter(self, query=None):
        """Find classifications by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Classification objects

        Example:
            classifications = rpc.Classification_filter({'name__icontains': 'cloud'})
        """
        return self.exec('Classification.filter', [query or {}])

    # === Component ===

    def Component_create(self, values):
        """Create a new product component.

        Args:
            values (dict): Component model fields, e.g.:
                {'name': 'Backend', 'product': 1, 'initial_owner': 'admin'}
                If initial_owner_id/initial_qa_owner_id are not specified,
                the current user is used.

        Returns:
            dict: Serialized Component object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no management.add_component permission

        Example:
            comp = rpc.Component_create({'name': 'API', 'product': 1})
        """
        return self.exec('Component.create', [values])

    def Component_filter(self, query=None):
        """Find components by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Component objects

        Example:
            comps = rpc.Component_filter({'product': 1, 'name__icontains': 'api'})
        """
        return self.exec('Component.filter', [query or {}])

    def Component_update(self, component_id, values):
        """Update component fields.

        Args:
            component_id (int): Component PK
            values (dict): New field values

        Returns:
            dict: Serialized updated Component object

        Raises:
            ValueError: invalid data
            PermissionDenied: no management.change_component permission

        Example:
            rpc.Component_update(5, {'name': 'REST API', 'initial_owner': 'dev_user'})
        """
        return self.exec('Component.update', [component_id, values])

    # === Environment ===

    def Environment_add_property(self, environment_id, name, value):
        """Add a property to an environment.

        Duplicates are silently ignored.

        Args:
            environment_id (int): Environment PK
            name (str): Property name
            value (str): Property value

        Returns:
            dict: Serialized EnvironmentProperty object

        Raises:
            PermissionDenied: no testruns.add_environmentproperty permission

        Example:
            rpc.Environment_add_property(3, 'os', 'Ubuntu 22.04')
        """
        return self.exec('Environment.add_property', [environment_id, name, value])

    def Environment_create(self, values):
        """Create a new environment.

        Args:
            values (dict): Environment model fields, e.g.:
                {'name': 'Production'}

        Returns:
            dict: Serialized Environment object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no testruns.add_environment permission

        Example:
            env = rpc.Environment_create({'name': 'Staging', 'description': 'Pre-prod env'})
        """
        return self.exec('Environment.create', [values])

    def Environment_filter(self, query=None):
        """Find environments by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Environment objects

        Raises:
            PermissionDenied: no testruns.view_environment permission

        Example:
            envs = rpc.Environment_filter({'name__icontains': 'prod'})
        """
        return self.exec('Environment.filter', [query or {}])

    def Environment_properties(self, query=None):
        """Get environment properties.

        Args:
            query (dict, optional): EnvironmentProperty field filter conditions,
                e.g. {'environment': 3} or {'name': 'os'}

        Returns:
            list[dict]: List of EnvironmentProperty objects

        Raises:
            PermissionDenied: no testruns.view_environmentproperty permission

        Example:
            props = rpc.Environment_properties({'environment': 3})
        """
        return self.exec('Environment.properties', [query or {}])

    def Environment_remove_property(self, query):
        """Delete environment properties by filter.

        Args:
            query (dict): EnvironmentProperty field filter conditions,
                e.g. {'environment': 3, 'name': 'os'}

        Returns:
            None

        Raises:
            PermissionDenied: no testruns.delete_environmentproperty permission

        Example:
            rpc.Environment_remove_property({'environment': 3, 'name': 'browser'})
        """
        return self.exec('Environment.remove_property', [query])

    # === Group ===

    def Group_filter(self, query=None):
        """Find user groups by filter.

        Added in v15.3.

        Args:
            query (dict, optional): Group field filter conditions.

        Returns:
            list[dict]: List of Group objects

        Raises:
            PermissionDenied: no auth.view_group permission

        Example:
            groups = rpc.Group_filter({'name': 'Testers'})
        """
        return self.exec('Group.filter', [query or {}])

    def Group_permissions(self, group_id):
        """Get the list of permissions for a group.

        Args:
            group_id (int): Group PK

        Returns:
            list[str]: List of permission labels, e.g. ['auth.view_user', ...]

        Raises:
            PermissionDenied: no auth.view_group permission
            DoesNotExist: group not found

        Example:
            perms = rpc.Group_permissions(2)
        """
        return self.exec('Group.permissions', [group_id])

    def Group_users(self, group_id):
        """Get users in a group.

        Args:
            group_id (int): Group PK

        Returns:
            list[dict]: List of User objects (without the password field)

        Raises:
            PermissionDenied: no auth.view_group or auth.view_user permission
            DoesNotExist: group not found

        Example:
            users = rpc.Group_users(2)
        """
        return self.exec('Group.users', [group_id])

    # === Kiwitcms ===

    def Kiwitcms_version(self):
        """Get the current Kiwi TCMS server version.

        Returns:
            str: Version string, e.g. '15.3'

        Example:
            ver = rpc.Kiwitcms_version()
            print(f'Version: {ver}')
        """
        return self.exec('Kiwitcms.version', [])

    # === Markdown ===

    def Markdown_render(self, text):
        """Convert Markdown to HTML.

        Args:
            text (str): Text in Markdown format

        Returns:
            str: HTML markup

        Example:
            html = rpc.Markdown_render('# Hello\\n\\nWorld **bold**')
        """
        return self.exec('Markdown.render', [text])

    # === PlanType ===

    def PlanType_create(self, values):
        """Create a new test plan type.

        Args:
            values (dict): PlanType model fields, e.g.:
                {'name': 'Regression', 'description': 'Regression testing'}

        Returns:
            dict: Serialized PlanType object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no testplans.add_plantype permission

        Example:
            pt = rpc.PlanType_create({'name': 'Smoke'})
        """
        return self.exec('PlanType.create', [values])

    def PlanType_filter(self, query=None):
        """Find test plan types by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of PlanType objects

        Example:
            pts = rpc.PlanType_filter({'name__icontains': 'smoke'})
        """
        return self.exec('PlanType.filter', [query or {}])

    # === Priority ===

    def Priority_create(self, values):
        """Create a new test case priority.

        Added in v15.2.

        Args:
            values (dict): Priority model fields, e.g.:
                {'value': 'P1', 'is_active': True}

        Returns:
            dict: Serialized Priority object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no management.add_priority permission

        Example:
            p = rpc.Priority_create({'value': 'Critical', 'is_active': True})
        """
        return self.exec('Priority.create', [values])

    def Priority_filter(self, query=None):
        """Find priorities by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Priority objects

        Example:
            priorities = rpc.Priority_filter({'is_active': True})
        """
        return self.exec('Priority.filter', [query or {}])

    # === Product ===

    def Product_create(self, values):
        """Create a new product.

        Args:
            values (dict): Product model fields, e.g.:
                {'name': 'MyApp', 'classification': 1, 'description': '...'}

        Returns:
            dict: Serialized Product object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no management.add_product permission

        Example:
            prod = rpc.Product_create({'name': 'Backend Service', 'classification': 1})
        """
        return self.exec('Product.create', [values])

    def Product_filter(self, query=None):
        """Find products by filter.

        Args:
            query (dict, optional): Filter conditions, e.g.:
                {'name': 'Red Hat Enterprise Linux 5'}

        Returns:
            list[dict]: List of Product objects

        Example:
            products = rpc.Product_filter({'name__icontains': 'backend'})
            all_products = rpc.Product_filter()
        """
        return self.exec('Product.filter', [query or {}])

    # === Tag ===

    def Tag_create(self, values):
        """Create a new tag.

        Added in v15.2.

        Args:
            values (dict): Tag model fields, e.g.: {'name': 'regression'}

        Returns:
            dict: Serialized Tag object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no management.add_tag permission

        Example:
            tag = rpc.Tag_create({'name': 'smoke-test'})
        """
        return self.exec('Tag.create', [values])

    def Tag_filter(self, query=None):
        """Find tags by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Tag objects

        Example:
            tags = rpc.Tag_filter({'name__startswith': 'smoke'})
        """
        return self.exec('Tag.filter', [query or {}])

    # === Template ===

    def Template_create(self, values):
        """Create a new test case template.

        Args:
            values (dict): Template model fields, e.g.:
                {'name': 'Login Template', 'template': '## Steps\n1. Open...'}

        Returns:
            dict: Serialized Template object

        Raises:
            ValueError: invalid input data
            PermissionDenied: no testcases.add_template permission

        Example:
            tmpl = rpc.Template_create({'name': 'API Test', 'template': '## Setup\n## Steps'})
        """
        return self.exec('Template.create', [values])

    def Template_filter(self, query=None):
        """Find templates by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of Template objects

        Raises:
            PermissionDenied: no testcases.view_template permission

        Example:
            templates = rpc.Template_filter({'name__icontains': 'api'})
        """
        return self.exec('Template.filter', [query or {}])

    # === TestCase ===

    def TestCase_add_attachment(self, case_id, filename, b64content):
        """Add an attachment to a test case.

        Args:
            case_id (int): Test case PK
            filename (str): File name, e.g. 'screenshot.png'
            b64content (str): File content encoded in Base64

        Returns:
            None

        Example:
            import base64
            with open('screenshot.png', 'rb') as f:
                content = base64.b64encode(f.read()).decode()
            rpc.TestCase_add_attachment(101, 'screenshot.png', content)
        """
        return self.exec('TestCase.add_attachment', [case_id, filename, b64content])

    def TestCase_add_comment(self, case_id, comment, user_id=None, submit_date=None):
        """Add a comment to a test case.

        Comments are only displayed during test case review in the web interface.
        A superuser can override the author and date.

        Args:
            case_id (int): Test case PK
            comment (str): Comment text
            user_id (int, optional): User PK (superusers only)
            submit_date (str, optional): Date in ISO format (superusers only)

        Returns:
            dict: Serialized Comment object

        Raises:
            PermissionDenied: insufficient permissions
            TestCase.DoesNotExist: test case not found

        Example:
            rpc.TestCase_add_comment(101, 'Verify on version 2.0')
            rpc.TestCase_add_comment(101, 'Historical comment', user_id=5, submit_date='2024-01-01T10:00:00')
        """
        params = [case_id, comment]
        if user_id is not None:
            params.append(user_id)
        if submit_date is not None:
            params.append(submit_date)
        return self.exec('TestCase.add_comment', params)

    def TestCase_add_component(self, case_id, component):
        """Link a component to a test case by name.

        Args:
            case_id (int): Test case PK
            component (str): Component name

        Returns:
            dict: Serialized Component object

        Raises:
            PermissionDenied: insufficient permissions
            DoesNotExist: test case or component not found

        Example:
            rpc.TestCase_add_component(101, 'Backend API')
        """
        return self.exec('TestCase.add_component', [case_id, component])

    def TestCase_add_notification_cc(self, case_id, cc_list):
        """Add email addresses to the test case notification list.

        Args:
            case_id (int): Test case PK
            cc_list (list[str]): List of email addresses

        Returns:
            None

        Raises:
            TypeError: wrong argument type
            ValidationError: invalid email
            PermissionDenied: insufficient permissions
            TestCase.DoesNotExist: test case not found

        Example:
            rpc.TestCase_add_notification_cc(101, ['qa@example.com', 'lead@example.com'])
        """
        return self.exec('TestCase.add_notification_cc', [case_id, cc_list])

    def TestCase_add_property(self, case_id, name, value):
        """Add an arbitrary property to a test case.

        Duplicates are silently ignored.

        Args:
            case_id (int): Test case PK
            name (str): Property name
            value (str): Property value

        Returns:
            dict: Serialized Property object

        Raises:
            PermissionDenied: insufficient permissions

        Example:
            rpc.TestCase_add_property(101, 'jira_ticket', 'PROJ-123')
        """
        return self.exec('TestCase.add_property', [case_id, name, value])

    def TestCase_add_tag(self, case_id, tag):
        """Add a tag to a test case.

        Args:
            case_id (int): Test case PK
            tag (str): Tag name

        Returns:
            None

        Raises:
            PermissionDenied: insufficient permissions
            TestCase.DoesNotExist: test case not found
            Tag.DoesNotExist: tag not found

        Example:
            rpc.TestCase_add_tag(101, 'regression')
        """
        return self.exec('TestCase.add_tag', [case_id, tag])

    def TestCase_comments(self, case_id):
        """Get all comments of a test case.

        Args:
            case_id (int): Test case PK

        Returns:
            list[dict]: List of Comment objects

        Raises:
            PermissionDenied: insufficient permissions
            TestCase.DoesNotExist: test case not found

        Example:
            comments = rpc.TestCase_comments(101)
            for c in comments:
                print(c['comment'])
        """
        return self.exec('TestCase.comments', [case_id])

    def TestCase_create(self, values):
        """Create a new test case.

        Args:
            values (dict): Test case fields. Required fields:
                - category (int): Category PK
                - product (int): Product PK
                - summary (str): Brief description
                - priority (int): Priority PK
                Optional: is_automated, text, notes, status, etc.

        Returns:
            dict: Serialized TestCase object

        Raises:
            ValueError: invalid data
            PermissionDenied: insufficient permissions

        Example:
            tc = rpc.TestCase_create({
                'product': 1,
                'category': 2,
                'priority': 1,
                'summary': 'Verify authorization',
                'text': '## Steps\\n1. Open the login page...',
                'is_automated': False
            })
        """
        return self.exec('TestCase.create', [values])

    def TestCase_filter(self, query=None):
        """Find test cases by filter.

        Args:
            query (dict, optional): Filter conditions. Examples:
                {'product': 1} — test cases for a product
                {'summary__icontains': 'login'} — search by title
                {'plan': 5} — test cases in a plan

        Returns:
            list[dict]: List of TestCase objects with the latest text

        Example:
            cases = rpc.TestCase_filter({'product': 1, 'is_automated': False})
        """
        return self.exec('TestCase.filter', [query or {}])

    def TestCase_get_notification_cc(self, case_id):
        """Get the list of email addresses for test case notifications.

        Args:
            case_id (int): Test case PK

        Returns:
            list[str]: List of email addresses

        Raises:
            TestCase.DoesNotExist: test case not found

        Example:
            cc = rpc.TestCase_get_notification_cc(101)
        """
        return self.exec('TestCase.get_notification_cc', [case_id])

    def TestCase_history(self, case_id, query=None):
        """Get the change history of a test case.

        Args:
            case_id (int): Test case PK
            query (dict, optional): Additional filter on history fields

        Returns:
            list[dict]: List of HistoricalTestCase objects

        Example:
            history = rpc.TestCase_history(101)
            history = rpc.TestCase_history(101, {'history_date__date': '2024-01-15'})
        """
        params = [case_id]
        if query is not None:
            params.append(query)
        return self.exec('TestCase.history', params)

    def TestCase_list_attachments(self, case_id):
        """Get the list of attachments for a test case.

        Args:
            case_id (int): Test case PK

        Returns:
            list: Attachment metadata with download URLs

        Raises:
            TestCase.DoesNotExist: test case not found

        Example:
            attachments = rpc.TestCase_list_attachments(101)
            for a in attachments:
                print(a['url'], a['filename'])
        """
        return self.exec('TestCase.list_attachments', [case_id])

    def TestCase_properties(self, query=None):
        """Get test case properties by filter.

        Args:
            query (dict, optional): Property filter conditions,
                e.g. {'case': 101} or {'name': 'jira_ticket'}

        Returns:
            list[dict]: List of Property objects

        Raises:
            PermissionDenied: insufficient permissions

        Example:
            props = rpc.TestCase_properties({'case': 101})
        """
        return self.exec('TestCase.properties', [query or {}])

    def TestCase_remove(self, query):
        """Delete test cases by filter.

        Args:
            query (dict): Filter conditions. Warning: deletes all matches!

        Returns:
            tuple: (number of deleted records, breakdown dict by type)

        Raises:
            PermissionDenied: insufficient permissions

        Example:
            count, details = rpc.TestCase_remove({'id': 101})
            count, details = rpc.TestCase_remove({'summary__icontains': 'obsolete'})
        """
        return self.exec('TestCase.remove', [query])

    def TestCase_remove_comment(self, case_id, comment_id=None):
        """Delete a comment (or all comments) from a test case.

        Args:
            case_id (int): Test case PK
            comment_id (int, optional): Comment PK.
                If not specified — all comments for the case are deleted.

        Returns:
            None

        Raises:
            PermissionDenied: insufficient permissions
            TestCase.DoesNotExist: test case not found

        Example:
            rpc.TestCase_remove_comment(101, 55)   # delete a specific one
            rpc.TestCase_remove_comment(101)        # delete all
        """
        params = [case_id]
        if comment_id is not None:
            params.append(comment_id)
        return self.exec('TestCase.remove_comment', params)

    def TestCase_remove_component(self, case_id, component_id):
        """Unlink a component from a test case.

        Args:
            case_id (int): Test case PK
            component_id (int): Component PK

        Returns:
            None

        Raises:
            PermissionDenied: insufficient permissions
            DoesNotExist: test case or component not found

        Example:
            rpc.TestCase_remove_component(101, 3)
        """
        return self.exec('TestCase.remove_component', [case_id, component_id])

    def TestCase_remove_notification_cc(self, case_id, cc_list):
        """Remove email addresses from the test case notification list.

        Args:
            case_id (int): Test case PK
            cc_list (list[str]): List of email addresses to remove

        Returns:
            None

        Raises:
            TypeError: wrong argument type
            ValidationError: invalid email
            PermissionDenied: insufficient permissions
            TestCase.DoesNotExist: test case not found

        Example:
            rpc.TestCase_remove_notification_cc(101, ['old@example.com'])
        """
        return self.exec('TestCase.remove_notification_cc', [case_id, cc_list])

    def TestCase_remove_property(self, query):
        """Delete test case properties by filter.

        Args:
            query (dict): Property filter conditions,
                e.g. {'case': 101, 'name': 'jira_ticket'}

        Returns:
            None

        Raises:
            PermissionDenied: insufficient permissions

        Example:
            rpc.TestCase_remove_property({'case': 101, 'name': 'jira_ticket'})
        """
        return self.exec('TestCase.remove_property', [query])

    def TestCase_remove_tag(self, case_id, tag):
        """Remove a tag from a test case.

        Args:
            case_id (int): Test case PK
            tag (str): Tag name

        Returns:
            None

        Raises:
            PermissionDenied: insufficient permissions
            DoesNotExist: test case or tag not found

        Example:
            rpc.TestCase_remove_tag(101, 'regression')
        """
        return self.exec('TestCase.remove_tag', [case_id, tag])

    def TestCase_sortkeys(self, query=None):
        """Get sort keys for test cases in plans.

        Args:
            query (dict, optional): Filter conditions

        Returns:
            dict: Dictionary {case_id: sortkey}

        Example:
            keys = rpc.TestCase_sortkeys({'plan': 10})
        """
        return self.exec('TestCase.sortkeys', [query or {}])

    def TestCase_update(self, case_id, values):
        """Update test case fields.

        Args:
            case_id (int): Test case PK
            values (dict): New field values, e.g.:
                {'summary': 'New description', 'is_automated': True}

        Returns:
            dict: Serialized updated TestCase object

        Raises:
            ValueError: invalid data
            TestCase.DoesNotExist: test case not found
            PermissionDenied: insufficient permissions

        Example:
            rpc.TestCase_update(101, {'summary': 'Updated title', 'priority': 2})
        """
        return self.exec('TestCase.update', [case_id, values])

    # === TestCaseStatus ===

    def TestCaseStatus_create(self, values):
        """Create a new test case status.

        Added in v15.2.

        Args:
            values (dict): TestCaseStatus model fields, e.g.:
                {'name': 'DRAFT', 'is_confirmed': False}

        Returns:
            dict: Serialized TestCaseStatus object

        Raises:
            ValueError: invalid data
            PermissionDenied: no testcases.add_testcasestatus permission

        Example:
            status = rpc.TestCaseStatus_create({'name': 'REVIEW', 'is_confirmed': False})
        """
        return self.exec('TestCaseStatus.create', [values])

    def TestCaseStatus_filter(self, query=None):
        """Find test case statuses by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of TestCaseStatus objects

        Example:
            statuses = rpc.TestCaseStatus_filter({'is_confirmed': True})
        """
        return self.exec('TestCaseStatus.filter', [query or {}])

    # === TestExecution ===

    def TestExecution_add_attachment(self, execution_id, filename, b64content):
        """Add an attachment to a test execution result.

        Args:
            execution_id (int): TestExecution PK
            filename (str): File name
            b64content (str): File content in Base64

        Returns:
            dict: Attachment object

        Example:
            import base64
            with open('result.log', 'rb') as f:
                content = base64.b64encode(f.read()).decode()
            rpc.TestExecution_add_attachment(200, 'result.log', content)
        """
        return self.exec('TestExecution.add_attachment', [execution_id, filename, b64content])

    def TestExecution_add_comment(self, execution_id, comment, user_id=None, submit_date=None):
        """Add a comment to a test execution result.

        A superuser can override the author and date.

        Args:
            execution_id (int): TestExecution PK
            comment (str): Comment text
            user_id (int, optional): User PK (superuser only)
            submit_date (str, optional): ISO date (superuser only)

        Returns:
            dict: Serialized Comment object

        Raises:
            PermissionDenied: no django_comments.add_comment permission

        Example:
            rpc.TestExecution_add_comment(200, 'Test failed at step 3')
        """
        params = [execution_id, comment]
        if user_id is not None:
            params.append(user_id)
        if submit_date is not None:
            params.append(submit_date)
        return self.exec('TestExecution.add_comment', params)

    def TestExecution_add_link(self, values, update_tracker=False):
        """Link a URL (bug) to an execution result.

        Args:
            values (dict): LinkReference fields, e.g.:
                {'execution': 200, 'url': 'https://jira.example.com/browse/BUG-1', 'is_defect': True}
            update_tracker (bool): If True — add a back-link in the external tracker

        Returns:
            dict: Serialized LinkReference object

        Raises:
            RuntimeError: operation failed
            ValueError: validation error

        Example:
            link = rpc.TestExecution_add_link(
                {'execution': 200, 'url': 'https://github.com/org/repo/issues/42', 'is_defect': True},
                update_tracker=True
            )
        """
        return self.exec('TestExecution.add_link', [values, update_tracker])

    def TestExecution_add_property(self, execution_id, name, value):
        """Add a property to a test execution result.

        Duplicates are silently ignored.

        Args:
            execution_id (int): TestExecution PK
            name (str): Property name
            value (str): Property value

        Returns:
            dict: Serialized TestExecutionProperty object

        Raises:
            PermissionDenied: no testruns.add_testexecutionproperty permission

        Example:
            rpc.TestExecution_add_property(200, 'build_number', '20240115.1')
        """
        return self.exec('TestExecution.add_property', [execution_id, name, value])

    def TestExecution_create(self, values):
        """Create a new test execution result.

        Args:
            values (dict): TestExecution fields, e.g.:
                {'run': 50, 'case': 101, 'status': 1}

        Returns:
            dict: Serialized TestExecution object

        Raises:
            PermissionDenied: no testruns.add_testexecution permission
            ValueError: invalid data

        Example:
            te = rpc.TestExecution_create({'run': 50, 'case': 101})
        """
        return self.exec('TestExecution.create', [values])

    def TestExecution_filter(self, query=None):
        """Find test execution results by filter.

        Args:
            query (dict, optional): Filter conditions, e.g.:
                {'run': 50} — all executions in a run
                {'status__name': 'FAILED'} — only failed ones

        Returns:
            list[dict]: List of TestExecution objects

        Example:
            executions = rpc.TestExecution_filter({'run': 50})
            failed = rpc.TestExecution_filter({'run': 50, 'status__name': 'FAILED'})
        """
        return self.exec('TestExecution.filter', [query or {}])

    def TestExecution_get_comments(self, execution_id):
        """Get all comments for an execution result.

        Args:
            execution_id (int): TestExecution PK

        Returns:
            list[dict]: List of Comment objects

        Raises:
            PermissionDenied: no django_comments.view_comment permission

        Example:
            comments = rpc.TestExecution_get_comments(200)
        """
        return self.exec('TestExecution.get_comments', [execution_id])

    def TestExecution_get_links(self, query):
        """Get links (bugs) attached to executions.

        Args:
            query (dict): Filter conditions, e.g.:
                {'execution': 200} or {'is_defect': True}

        Returns:
            list[dict]: List of LinkReference objects

        Example:
            links = rpc.TestExecution_get_links({'execution': 200})
        """
        return self.exec('TestExecution.get_links', [query])

    def TestExecution_history(self, execution_id):
        """Get the change history of an execution result.

        Args:
            execution_id (int): TestExecution PK

        Returns:
            list[dict]: List of KiwiHistoricalRecords objects

        Raises:
            PermissionDenied: no testruns.view_testexecution permission

        Example:
            history = rpc.TestExecution_history(200)
        """
        return self.exec('TestExecution.history', [execution_id])

    def TestExecution_list_attachments(self, execution_id):
        """Get the list of attachments for an execution result.

        Args:
            execution_id (int): TestExecution PK

        Returns:
            list: Attachment metadata with download URLs

        Raises:
            TestExecution.DoesNotExist: execution not found

        Example:
            attachments = rpc.TestExecution_list_attachments(200)
        """
        return self.exec('TestExecution.list_attachments', [execution_id])

    def TestExecution_properties(self, query=None):
        """Get execution result properties by filter.

        Args:
            query (dict, optional): Filter conditions,
                e.g. {'execution': 200} or {'name': 'build_number'}

        Returns:
            list[dict]: List of TestExecutionProperty objects

        Example:
            props = rpc.TestExecution_properties({'execution': 200})
        """
        return self.exec('TestExecution.properties', [query or {}])

    def TestExecution_remove(self, query):
        """Delete test execution results by filter.

        Args:
            query (dict): Filter conditions. Caution: deletes all matches!

        Returns:
            None

        Raises:
            PermissionDenied: no testruns.delete_testexecution permission

        Example:
            rpc.TestExecution_remove({'id': 200})
        """
        return self.exec('TestExecution.remove', [query])

    def TestExecution_remove_comment(self, execution_id, comment_id=None):
        """Delete comment(s) from an execution result.

        Args:
            execution_id (int): TestExecution PK
            comment_id (int, optional): Comment PK.
                If not specified — all comments are deleted.

        Returns:
            None

        Raises:
            PermissionDenied: no django_comments.delete_comment permission

        Example:
            rpc.TestExecution_remove_comment(200, 77)  # specific
            rpc.TestExecution_remove_comment(200)       # all
        """
        params = [execution_id]
        if comment_id is not None:
            params.append(comment_id)
        return self.exec('TestExecution.remove_comment', params)

    def TestExecution_remove_link(self, query):
        """Remove links (bugs) from execution results by filter.

        Args:
            query (dict): LinkReference filter conditions,
                e.g. {'execution': 200, 'url__icontains': 'github'}

        Returns:
            None

        Example:
            rpc.TestExecution_remove_link({'execution': 200, 'is_defect': True})
        """
        return self.exec('TestExecution.remove_link', [query])

    def TestExecution_update(self, execution_id, values):
        """Update test execution result fields.

        Args:
            execution_id (int): TestExecution PK
            values (dict): New values, e.g.:
                {'status': 4} — change status (status ID)
                {'tested_by': 2, 'close_date': '2024-01-15T12:00:00'}

        Returns:
            dict: Serialized updated TestExecution object

        Raises:
            ValueError: invalid data
            PermissionDenied: no testruns.change_testexecution permission

        Example:
            # Set status PASSED (ID depends on configuration)
            passed_status = rpc.TestExecutionStatus_filter({'name': 'PASSED'})[0]
            rpc.TestExecution_update(200, {'status': passed_status['id']})
        """
        return self.exec('TestExecution.update', [execution_id, values])

    # === TestExecutionStatus ===

    def TestExecutionStatus_create(self, values):
        """Create a new test execution status.

        Added in v15.2.

        Args:
            values (dict): TestExecutionStatus model fields, e.g.:
                {'name': 'SKIPPED', 'weight': 30, 'icon': 'fas fa-forward', 'color': '#aaaaaa'}

        Returns:
            dict: Serialized TestExecutionStatus object

        Raises:
            ValueError: invalid data
            PermissionDenied: no testruns.add_testexecutionstatus permission

        Example:
            status = rpc.TestExecutionStatus_create({
                'name': 'BLOCKED',
                'weight': 25,
                'icon': 'fas fa-ban',
                'color': '#ff6600'
            })
        """
        return self.exec('TestExecutionStatus.create', [values])

    def TestExecutionStatus_filter(self, query=None):
        """Find test execution statuses by filter.

        Args:
            query (dict, optional): Filter conditions.

        Returns:
            list[dict]: List of TestExecutionStatus objects

        Example:
            statuses = rpc.TestExecutionStatus_filter()
            passed = rpc.TestExecutionStatus_filter({'name': 'PASSED'})
        """
        return self.exec('TestExecutionStatus.filter', [query or {}])

    # === TestPlan ===

    def TestPlan_add_attachment(self, plan_id, filename, b64content):
        """Add an attachment to a test plan.

        Args:
            plan_id (int): Plan PK
            filename (str): File name
            b64content (str): Content in Base64

        Returns:
            None

        Example:
            import base64
            with open('plan.pdf', 'rb') as f:
                content = base64.b64encode(f.read()).decode()
            rpc.TestPlan_add_attachment(10, 'plan.pdf', content)
        """
        return self.exec('TestPlan.add_attachment', [plan_id, filename, b64content])

    def TestPlan_add_case(self, plan_id, case_id):
        """Add a test case to a test plan.

        Args:
            plan_id (int): Plan PK
            case_id (int): Test case PK

        Returns:
            dict: Serialized TestCase object with sortkey field

        Raises:
            TestPlan.DoesNotExist: plan not found
            TestCase.DoesNotExist: test case not found
            PermissionDenied: no testcases.add_testcaseplan permission

        Example:
            rpc.TestPlan_add_case(10, 101)
        """
        return self.exec('TestPlan.add_case', [plan_id, case_id])

    def TestPlan_add_tag(self, plan_id, tag_name):
        """Add a tag to a test plan.

        Args:
            plan_id (int): Plan PK
            tag_name (str): Tag name

        Returns:
            None

        Raises:
            PermissionDenied: no testplans.add_testplantag permission
            TestPlan.DoesNotExist: plan not found
            Tag.DoesNotExist: tag not found

        Example:
            rpc.TestPlan_add_tag(10, 'sprint-42')
        """
        return self.exec('TestPlan.add_tag', [plan_id, tag_name])

    def TestPlan_create(self, values):
        """Create a new test plan.

        Args:
            values (dict): Plan fields. Required fields:
                - product (int): Product PK
                - product_version (int): Product version PK
                - name (str): Plan name
                - type (int): Plan type PK
                Optional: parent (int), text (str), is_active (bool)

        Returns:
            dict: Serialized TestPlan object

        Raises:
            ValueError: invalid data
            PermissionDenied: no testplans.add_testplan permission

        Example:
            plan = rpc.TestPlan_create({
                'product': 1,
                'product_version': 5,
                'name': 'Release Plan v2.0',
                'type': 1,
                'text': '## Goal\\nVerify main functionality...'
            })
        """
        return self.exec('TestPlan.create', [values])

    def TestPlan_filter(self, query=None):
        """Find test plans by filter.

        Args:
            query (dict, optional): Filter conditions, e.g.:
                {'product': 1} — product plans
                {'is_active': True} — active plans
                {'name__icontains': 'release'} — search by name

        Returns:
            list[dict]: List of TestPlan objects

        Example:
            plans = rpc.TestPlan_filter({'product': 1, 'is_active': True})
        """
        return self.exec('TestPlan.filter', [query or {}])

    def TestPlan_list_attachments(self, plan_id):
        """Get list of test plan attachments.

        Args:
            plan_id (int): Plan PK

        Returns:
            list: Attachment metadata with download URLs

        Raises:
            TestPlan.DoesNotExist: plan not found

        Example:
            attachments = rpc.TestPlan_list_attachments(10)
        """
        return self.exec('TestPlan.list_attachments', [plan_id])

    def TestPlan_remove_case(self, plan_id, case_id):
        """Remove a test case from a test plan.

        Args:
            plan_id (int): Plan PK
            case_id (int): Test case PK

        Returns:
            None

        Raises:
            PermissionDenied: no testcases.delete_testcaseplan permission

        Example:
            rpc.TestPlan_remove_case(10, 101)
        """
        return self.exec('TestPlan.remove_case', [plan_id, case_id])

    def TestPlan_remove_tag(self, plan_id, tag_name):
        """Remove a tag from a test plan.

        Args:
            plan_id (int): Plan PK
            tag_name (str): Tag name

        Returns:
            None

        Raises:
            PermissionDenied: no testplans.delete_testplantag permission
            DoesNotExist: plan or tag not found

        Example:
            rpc.TestPlan_remove_tag(10, 'sprint-42')
        """
        return self.exec('TestPlan.remove_tag', [plan_id, tag_name])

    def TestPlan_tree(self, plan_id):
        """Get test plan ancestor tree (depth-first traversal).

        Args:
            plan_id (int): Plan PK

        Returns:
            list: List of TestPlan objects from root to current plan

        Raises:
            TestPlan.DoesNotExist: plan not found

        Example:
            tree = rpc.TestPlan_tree(10)
            for node in tree:
                print(node['name'])
        """
        return self.exec('TestPlan.tree', [plan_id])

    def TestPlan_update(self, plan_id, values):
        """Update test plan fields.

        Args:
            plan_id (int): Plan PK
            values (dict): New field values, e.g.:
                {'name': 'New name', 'is_active': False}

        Returns:
            dict: Serialized updated TestPlan object

        Raises:
            TestPlan.DoesNotExist: plan not found
            PermissionDenied: no testplans.change_testplan permission
            ValueError: invalid data

        Example:
            rpc.TestPlan_update(10, {'name': 'Sprint 43 Plan', 'is_active': True})
        """
        return self.exec('TestPlan.update', [plan_id, values])

    def TestPlan_update_case_order(self, plan_id, case_id, sortkey):
        """Update test case order in a plan.

        Args:
            plan_id (int): Plan PK
            case_id (int): Test case PK
            sortkey (int): Sort key (recommended multiples of 10, e.g.: 10, 20, 30)

        Returns:
            None

        Raises:
            PermissionDenied: no testcases.delete_testcaseplan permission

        Example:
            rpc.TestPlan_update_case_order(10, 101, 10)
            rpc.TestPlan_update_case_order(10, 102, 20)
            rpc.TestPlan_update_case_order(10, 103, 30)
        """
        return self.exec('TestPlan.update_case_order', [plan_id, case_id, sortkey])

    # === TestRun ===

    def TestRun_add_attachment(self, run_id, filename, b64content):
        """Add an attachment to a test run.

        Args:
            run_id (int): Run PK
            filename (str): File name
            b64content (str): Content in Base64

        Returns:
            None

        Example:
            import base64
            with open('report.html', 'rb') as f:
                content = base64.b64encode(f.read()).decode()
            rpc.TestRun_add_attachment(50, 'report.html', content)
        """
        return self.exec('TestRun.add_attachment', [run_id, filename, b64content])

    def TestRun_add_case(self, run_id, case_id):
        """Add a test case to a test run.

        The test case must have CONFIRMED status.

        Args:
            run_id (int): Run PK
            case_id (int): Test case PK

        Returns:
            list[dict]: List of created TestExecution objects

        Raises:
            DoesNotExist: run or case not found
            PermissionDenied: no permission
            RuntimeError: case status is not CONFIRMED

        Example:
            executions = rpc.TestRun_add_case(50, 101)
        """
        return self.exec('TestRun.add_case', [run_id, case_id])

    def TestRun_add_cc(self, run_id, username):
        """Add a user to test run CC list.

        Args:
            run_id (int): Run PK
            username (str): User PK, email or username

        Returns:
            None

        Raises:
            DoesNotExist: run or user not found
            PermissionDenied: no permission
            ValueError: invalid user identifier

        Example:
            rpc.TestRun_add_cc(50, 'qa_engineer')
        """
        return self.exec('TestRun.add_cc', [run_id, username])

    def TestRun_add_property(self, run_id, name, value):
        """Add a property to a test run.

        Duplicates are silently ignored.

        Args:
            run_id (int): Run PK
            name (str): Property name
            value (str): Property value

        Returns:
            dict: Serialized Property object

        Raises:
            PermissionDenied: no testruns.add_property permission

        Example:
            rpc.TestRun_add_property(50, 'environment', 'staging')
            rpc.TestRun_add_property(50, 'build', '20240115.1')
        """
        return self.exec('TestRun.add_property', [run_id, name, value])

    def TestRun_add_tag(self, run_id, tag_name):
        """Add a tag to a test run.

        Args:
            run_id (int): Run PK
            tag_name (str): Tag name

        Returns:
            list[dict]: List of Tag objects

        Raises:
            PermissionDenied: no permission
            DoesNotExist: run or tag not found

        Example:
            rpc.TestRun_add_tag(50, 'nightly')
        """
        return self.exec('TestRun.add_tag', [run_id, tag_name])

    def TestRun_annotate_executions_with_properties(self, run_id, property_names):
        """Annotate execution results with run properties.

        Args:
            run_id (int): Run PK
            property_names (list[str]): List of property names to annotate with

        Returns:
            list[dict]: Execution results with added properties

        Example:
            results = rpc.TestRun_annotate_executions_with_properties(50, ['environment', 'build'])
        """
        return self.exec('TestRun.annotate_executions_with_properties', [run_id, property_names])

    def TestRun_create(self, values):
        """Create a new test run.

        Args:
            values (dict): Run fields. Required fields:
                - build (int): Build PK
                - manager (str/int): Manager PK, email or username
                - plan (int): Test plan PK
                - summary (str): Run summary/name
                Optional: start_date, stop_date, notes, default_tester, etc.

        Returns:
            dict: Serialized TestRun object

        Raises:
            PermissionDenied: no testruns.add_testrun permission

        Example:
            run = rpc.TestRun_create({
                'plan': 10,
                'build': 3,
                'manager': 'admin',
                'summary': 'Regression Run v2.0'
            })
        """
        return self.exec('TestRun.create', [values])

    def TestRun_filter(self, query=None):
        """Find test runs by filter.

        Args:
            query (dict, optional): Filter conditions, e.g.:
                {'plan': 10} — runs for a plan
                {'manager__username': 'admin'} — runs for a manager
                {'summary__icontains': 'regression'} — search by name

        Returns:
            list[dict]: List of TestRun objects

        Example:
            runs = rpc.TestRun_filter({'plan': 10})
            all_runs = rpc.TestRun_filter()
        """
        return self.exec('TestRun.filter', [query or {}])

    def TestRun_get_cases(self, run_id):
        """Get test cases for a run with their execution status.

        Args:
            run_id (int): Run PK

        Returns:
            list[dict]: List of TestCase objects with execution_id and status fields

        Example:
            cases = rpc.TestRun_get_cases(50)
            for case in cases:
                print(case['summary'], '→', case['status'])
        """
        return self.exec('TestRun.get_cases', [run_id])

    def TestRun_get_cc(self, run_id):
        """Get CC list for a test run.

        Args:
            run_id (int): Run PK

        Returns:
            list[str]: List of email addresses

        Raises:
            TestRun.DoesNotExist: run not found

        Example:
            cc = rpc.TestRun_get_cc(50)
        """
        return self.exec('TestRun.get_cc', [run_id])

    def TestRun_list_attachments(self, run_id):
        """Get list of test run attachments.

        Args:
            run_id (int): Run PK

        Returns:
            list: Attachment metadata with download URLs

        Raises:
            TestRun.DoesNotExist: run not found

        Example:
            attachments = rpc.TestRun_list_attachments(50)
        """
        return self.exec('TestRun.list_attachments', [run_id])

    def TestRun_properties(self, query=None):
        """Get test run properties.

        Args:
            query (dict, optional): Filter conditions,
                e.g. {'run': 50} or {'name': 'environment'}

        Returns:
            list[dict]: List of Property objects

        Raises:
            PermissionDenied: no testruns.view_property permission

        Example:
            props = rpc.TestRun_properties({'run': 50})
        """
        return self.exec('TestRun.properties', [query or {}])

    def TestRun_remove(self, query):
        """Delete test runs by filter.

        Args:
            query (dict): Filter conditions. Caution: deletes all matches!

        Returns:
            tuple: (count of deleted, breakdown dictionary by type)

        Raises:
            PermissionDenied: no testruns.delete_testrun permission

        Example:
            rpc.TestRun_remove({'id': 50})
        """
        return self.exec('TestRun.remove', [query])

    def TestRun_remove_case(self, run_id, case_id):
        """Remove a test case from a run (deprecated method).

        .. deprecated::
            Use :meth:`TestExecution_remove` instead of this method.

        Args:
            run_id (int): Run PK
            case_id (int): Test case PK

        Returns:
            None

        Example:
            rpc.TestRun_remove_case(50, 101)
        """
        return self.exec('TestRun.remove_case', [run_id, case_id])

    def TestRun_remove_cc(self, run_id, username):
        """Remove a user from test run CC list.

        Args:
            run_id (int): Run PK
            username (str): User PK, email or username

        Returns:
            None

        Raises:
            DoesNotExist: run or user not found
            PermissionDenied: no permission
            ValueError: invalid user identifier

        Example:
            rpc.TestRun_remove_cc(50, 'qa_engineer')
        """
        return self.exec('TestRun.remove_cc', [run_id, username])

    def TestRun_remove_tag(self, run_id, tag_name):
        """Remove a tag from a test run.

        Args:
            run_id (int): Run PK
            tag_name (str): Tag name

        Returns:
            list[dict]: List of remaining Tag objects

        Raises:
            PermissionDenied: no testruns.delete_testruntag permission

        Example:
            rpc.TestRun_remove_tag(50, 'nightly')
        """
        return self.exec('TestRun.remove_tag', [run_id, tag_name])

    def TestRun_update(self, run_id, values):
        """Update test run fields.

        Args:
            run_id (int): Run PK
            values (dict): New field values, e.g.:
                {'summary': 'Updated run', 'stop_date': '2024-01-20T18:00:00'}

        Returns:
            dict: Serialized updated TestRun object

        Raises:
            PermissionDenied: no testruns.change_testrun permission
            ValueError: invalid data

        Example:
            rpc.TestRun_update(50, {'summary': 'Sprint 43 regression', 'default_tester': 'qa_user'})
        """
        return self.exec('TestRun.update', [run_id, values])

    # === User ===

    def User_add_attachment(self, filename, b64content):
        """Attach a file to the current user.

        Used for SimpleMDE and subsequent processing (TestPlan, TestCase).
        File is only accessible via direct URL — not displayed in UI.

        Args:
            filename (str): File name, e.g. 'logs.txt'
            b64content (str): Content in Base64

        Returns:
            dict: Attachment info with URL

        Example:
            import base64
            content = base64.b64encode(b'log data').decode()
            rpc.User_add_attachment('debug.log', content)
        """
        return self.exec('User.add_attachment', [filename, b64content])

    def User_deactivate(self, query):
        """Deactivate users by filter (disable login).

        Args:
            query (dict): Filter conditions, e.g.:
                {'username': 'old_user'}
                {'email__icontains': '@contractor.com'}
                {'id__in': [5, 6, 7]}

        Returns:
            list[dict]: List of deactivated users

        Raises:
            PermissionDenied: no auth.change_user permission or password mismatch

        Example:
            rpc.User_deactivate({'username': 'dismissed_employee'})
        """
        return self.exec('User.deactivate', [query])

    def User_filter(self, query=None):
        """Find users by filter.

        Args:
            query (dict, optional): Filter conditions.
                If not specified or empty — returns current user.

        Returns:
            list[dict]: List of users (without password field)

        Raises:
            PermissionDenied: no auth.view_user permission

        Example:
            current = rpc.User_filter()                            # current user
            admins = rpc.User_filter({'is_superuser': True})
            user = rpc.User_filter({'username': 'qa_engineer'})
        """
        return self.exec('User.filter', [query or {}])

    def User_join_group(self, username, groupname):
        """Add a user to a group.

        Group must exist beforehand.

        Args:
            username (str): Username
            groupname (str): Group name

        Returns:
            None

        Raises:
            PermissionDenied: no auth.change_user permission

        Example:
            rpc.User_join_group('new_tester', 'Testers')
        """
        return self.exec('User.join_group', [username, groupname])

    def User_update(self, user_id, values):
        """Update user data.

        Cannot change another user's password via RPC.

        Args:
            user_id (int): User PK. None — current user.
            values (dict): New field values, e.g.:
                {'email': 'new@example.com', 'first_name': 'John'}
                {'password': 'new_password'} — only for yourself

        Returns:
            dict: Updated user object

        Raises:
            PermissionDenied: no auth.change_user permission

        Example:
            rpc.User_update(None, {'email': 'my_new@example.com'})   # update self
            rpc.User_update(5, {'is_active': True})                   # update another
        """
        return self.exec('User.update', [user_id, values])

    # === Utils ===

    def Utils_tracker_from_url(self, url):
        """Find bug tracker object by defect URL.

        Args:
            url (str): URL of existing defect in external tracker

        Returns:
            IssueTrackerType: tracker object whose base_url is contained in url

        Example:
            tracker = rpc.Utils_tracker_from_url('https://github.com/org/repo/issues/42')
        """
        return self.exec('Utils.tracker_from_url', [url])

    # === Version ===

    def Version_create(self, values):
        """Create a new product version.

        Args:
            values (dict): Version model fields, e.g.:
                {'value': 'devel', 'product': 272}

        Returns:
            dict: Serialized Version object, e.g.:
                {'id': '1106', 'value': 'devel', 'product': 272}

        Raises:
            ValueError: invalid data
            PermissionDenied: no management.add_version permission

        Example:
            ver = rpc.Version_create({'value': '2.0.0', 'product': 1})
            print(ver['id'])
        """
        return self.exec('Version.create', [values])

    def Version_filter(self, query=None):
        """Find product versions by filter.

        Args:
            query (dict, optional): Filter conditions, e.g.:
                {'product': 1} — product versions
                {'value': 'devel'} — versions with name 'devel'

        Returns:
            list[dict]: List of Version objects

        Example:
            versions = rpc.Version_filter({'product': 1})
        """
        return self.exec('Version.filter', [query or {}])

    # =========================================================================
    # COMPLETE LIST OF METHODS FROM OFFICIAL DOCUMENTATION
    # Source: https://kiwitcms.readthedocs.io/en/latest/modules/tcms.rpc.api.html
    # =========================================================================
    KNOWN_METHODS = [
        # === Attachment ===
        'Attachment.remove_attachment',

        # === Auth ===
        'Auth.login',
        'Auth.logout',

        # === Bug ===
        'Bug.details',
        'Bug.report',

        # === BugTracker ===
        'BugTracker.create',
        'BugTracker.filter',

        # === Build ===
        'Build.create',
        'Build.filter',
        'Build.update',

        # === Category ===
        'Category.create',
        'Category.filter',

        # === Classification ===
        'Classification.create',
        'Classification.filter',

        # === Component ===
        'Component.create',
        'Component.filter',
        'Component.update',

        # === Environment ===
        'Environment.add_property',
        'Environment.create',
        'Environment.filter',
        'Environment.properties',
        'Environment.remove_property',

        # === Group (added in v15.3) ===
        'Group.filter',
        'Group.permissions',
        'Group.users',

        # === Kiwitcms ===
        'Kiwitcms.version',

        # === Markdown ===
        'Markdown.render',

        # === PlanType ===
        'PlanType.create',
        'PlanType.filter',

        # === Priority (added in v15.2) ===
        'Priority.create',
        'Priority.filter',

        # === Product ===
        'Product.create',
        'Product.filter',

        # === Tag (added in v15.2) ===
        'Tag.create',
        'Tag.filter',

        # === Template ===
        'Template.create',
        'Template.filter',

        # === TestCase ===
        'TestCase.add_attachment',
        'TestCase.add_comment',
        'TestCase.add_component',
        'TestCase.add_notification_cc',
        'TestCase.add_property',
        'TestCase.add_tag',
        'TestCase.comments',
        'TestCase.create',
        'TestCase.filter',
        'TestCase.get_notification_cc',
        'TestCase.history',
        'TestCase.list_attachments',
        'TestCase.properties',
        'TestCase.remove',
        'TestCase.remove_comment',
        'TestCase.remove_component',
        'TestCase.remove_notification_cc',
        'TestCase.remove_property',
        'TestCase.remove_tag',
        'TestCase.sortkeys',
        'TestCase.update',

        # === TestCaseStatus (added in v15.2) ===
        'TestCaseStatus.create',
        'TestCaseStatus.filter',

        # === TestExecution ===
        'TestExecution.add_attachment',
        'TestExecution.add_comment',
        'TestExecution.add_link',
        'TestExecution.add_property',
        'TestExecution.create',
        'TestExecution.filter',
        'TestExecution.get_comments',
        'TestExecution.get_links',
        'TestExecution.history',
        'TestExecution.list_attachments',
        'TestExecution.properties',
        'TestExecution.remove',
        'TestExecution.remove_comment',
        'TestExecution.remove_link',
        'TestExecution.update',

        # === TestExecutionStatus (added in v15.2) ===
        'TestExecutionStatus.create',
        'TestExecutionStatus.filter',

        # === TestPlan ===
        'TestPlan.add_attachment',
        'TestPlan.add_case',
        'TestPlan.add_tag',
        'TestPlan.create',
        'TestPlan.filter',
        'TestPlan.list_attachments',
        'TestPlan.remove_case',
        'TestPlan.remove_tag',
        'TestPlan.tree',
        'TestPlan.update',
        'TestPlan.update_case_order',

        # === TestRun ===
        'TestRun.add_attachment',
        'TestRun.add_case',
        'TestRun.add_cc',
        'TestRun.add_property',
        'TestRun.add_tag',
        'TestRun.annotate_executions_with_properties',
        'TestRun.create',
        'TestRun.filter',
        'TestRun.get_cases',
        'TestRun.get_cc',
        'TestRun.list_attachments',
        'TestRun.properties',
        'TestRun.remove',
        'TestRun.remove_case',
        'TestRun.remove_cc',
        'TestRun.remove_tag',
        'TestRun.update',

        # === User ===
        'User.add_attachment',
        'User.deactivate',
        'User.filter',
        'User.join_group',
        'User.update',

        # === Utils ===
        'Utils.tracker_from_url',

        # === Version ===
        'Version.create',
        'Version.filter',
    ]


# === USAGE EXAMPLE ===
if __name__ == "__main__":
    # Method 1: from environment variables
    rpc = KiwiTCMSApi()

    # Method 2: explicit parameters in constructor
    # rpc = KiwiTCMSApi(url='https://tcms.example.com', username='admin', password='secret')

    # Method 3: configurator — setup and deferred connection
    # rpc = KiwiTCMSApi.configure(url='https://tcms.example.com', username='admin', password='secret')
    # rpc.connect()

    # Method 4: from dictionary
    # cfg = {'url': 'https://tcms.example.com', 'username': 'admin', 'password': 'secret'}
    # rpc = KiwiTCMSApi.from_config(cfg)

    # Check server version
    try:
        version = rpc.Kiwitcms_version()
        print(f"Server version: {version}")
    except Exception as e:
        print(f"Failed to get version: {e}")

    # Check method availability
    rpc.discover_methods(verbose=True)

    # Basic data
    products = rpc.Product_filter()
    print(f"\nFound products: {len(products)}")

    plans = rpc.TestPlan_filter()
    print(f"Found plans: {len(plans)}")

    runs = rpc.TestRun_filter()
    print(f"Found runs: {len(runs)}")

    # 5. Update execution status
    # statuses = rpc.TestExecutionStatus_filter({'name': 'PASSED'})
    # rpc.TestExecution_update(200, {'status': statuses[0]['id']})
