"""
Utility functions for NetOrca SDK v2.

Provides helper functions for repository loading, error formatting, and more.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from beautifultable import BeautifulTable
from ruamel.yaml import YAML
from ruamel.yaml.constructor import DuplicateKeyError
from ruamel.yaml.error import YAMLError

from netorca_sdk.v2.exceptions import NetorcaException

if TYPE_CHECKING:
    from netorca_sdk.v2.client import NetOrcaClient


DEFAULT_LLM_MODEL_ID = 1


@dataclass
class PackPrompt:
    """Represents a pack prompt loaded from the pack directory.

    Pack folder structure:
        config.[txt/md]     # Config prompt text
        config.json    # Config response schema (optional)
        verify.[txt/md]     # Verify prompt text
        verify.json    # Verify response schema (optional)
        execute.[txt/md]    # Execute prompt text
        execute.json   # Execute response schema (optional)
    """
    prompt_type: str  # e.g., "config", "verify", "execute"
    prompt: str  # Content of the prompt .[txt/md] file
    schema: Optional[Dict[str, Any]] = None  # Content of the schema .json file


@dataclass
class ServiceSubmission:
    """Represents a service submission with all its components."""
    name: str
    service: Dict[str, Any]  # The service JSON schema
    readme: Optional[str] = None  # Path to README/service.md
    readme_content: Optional[str] = None  # Content of the README
    pack_prompts: List[PackPrompt] = field(default_factory=list)  # Pack prompts
    context_documents: Dict[str, str] = field(default_factory=dict)  # {filename: content}
    is_folder_structure: bool = False  # True if using new folder structure


class RepositoryLoader:
    """
    Helper class for loading NetOrca submissions from repository structures.

    This class provides static methods to load service definitions and
    consumer submissions from structured repository formats.

    Examples:
        Load service owner repository:

        >>> from netorca_sdk.v2 import RepositoryLoader
        >>> data = RepositoryLoader.load_serviceowner_repository(
        ...     repository_path="/path/to/repo",
        ...     netorca_directory=".netorca"
        ... )
        >>> print(data["config"]["netorca_global"])
        >>> for service_name, service_data in data["submissions"].items():
        ...     print(f"Service: {service_name}")
        ...     print(f"README: {service_data.get('readme', 'N/A')}")

        Load consumer repository:

        >>> data = RepositoryLoader.load_consumer_repository(
        ...     repository_path="/path/to/consumer/repo"
        ... )
        >>> for app_name, app_data in data["submissions"].items():
        ...     print(f"Application: {app_name}")
        ...     print(f"Services: {list(app_data['services'].keys())}")
    """

    @staticmethod
    def load_serviceowner_repository(
        repository_path: str,
        netorca_directory: str = ".netorca"
    ) -> Dict[str, Any]:
        """Load service owner submission from repository.

        Supports two structures:

        Legacy (flat file) structure::

            repository_path/
                .netorca/
                    config.json          # Required: NetOrca configuration
                    service1.json        # Service definition
                    service1.md          # Optional: Service README
                    service2.json
                    service2.md

        New (per-service folder) structure::

            repository_path/
                .netorca/
                    config.json          # Required: NetOrca configuration
                    service1/            # Service folder
                        service.json     # Service definition
                        service.md       # Optional: Service README
                        pack/            # Optional: Pack prompts
                            config.[txt/md]     # Config prompt text
                            config.json         # Config response schema (optional)
                            verify.[txt/md]     # Verify prompt text
                            verify.json         # Verify response schema (optional)
                            execute.[txt/md]    # Execute prompt text
                            execute.json        # Execute response schema (optional)
                        context/         # Optional: Context documents
                            doc1.md
                            doc2.md

        Args:
            repository_path (str): Path to service owner repository
            netorca_directory (str, optional): NetOrca directory name. Defaults to ".netorca"

        Returns:
            dict: Dictionary with keys:
                - config (dict): NetOrca configuration
                - submissions (dict): Dict of {service_name: ServiceSubmission}

        Raises:
            NetorcaException: If repository structure is invalid

        Example:
            >>> data = RepositoryLoader.load_serviceowner_repository("/path/to/repo")
            >>> config = data["config"]
            >>> base_url = config["netorca_global"]["base_url"]
            >>> for service_name, submission in data["submissions"].items():
            ...     print(f"Service: {service_name}")
            ...     print(f"Is folder structure: {submission.is_folder_structure}")
            ...     print(f"Pack prompts: {len(submission.pack_prompts)}")
            ...     print(f"Context docs: {len(submission.context_documents)}")
        """
        if not os.path.isdir(repository_path):
            raise NetorcaException(f"{repository_path} directory does not exist.")

        netorca_path = os.path.join(repository_path, netorca_directory)
        if not os.path.isdir(netorca_path):
            raise NetorcaException(f"{netorca_directory} directory does not exist.")

        config_path = os.path.join(netorca_path, "config.json")
        config = RepositoryLoader._read_json_file(config_path)

        netorca_global = config.get("netorca_global", {})
        if not netorca_global:
            raise NetorcaException("No netorca_global.base_url provided in config.json.")

        submissions: Dict[str, ServiceSubmission] = {}

        # Scan for both legacy flat files and new folder structures
        for entry in os.listdir(netorca_path):
            if entry == "config.json":
                continue

            entry_path = os.path.join(netorca_path, entry)

            # New folder structure: entry is a directory
            if os.path.isdir(entry_path):
                submission = RepositoryLoader._load_folder_structure(entry, entry_path)
                if submission:
                    submissions[entry] = submission

            # Legacy flat file: entry is a .json file
            elif entry.endswith(".json") and os.path.isfile(entry_path):
                service_name = os.path.splitext(entry)[0]
                if service_name not in submissions:
                    json_data = RepositoryLoader._read_json_file(entry_path)
                    submission = ServiceSubmission(
                        name=service_name,
                        service=json_data,
                        is_folder_structure=False
                    )
                    submissions[service_name] = submission

        # Load README files for legacy flat file structure
        for entry in os.listdir(netorca_path):
            if not entry.endswith(".md"):
                continue

            entry_path = os.path.join(netorca_path, entry)
            if not os.path.isfile(entry_path):
                continue

            service_name = os.path.splitext(entry)[0]

            if service_name in submissions and not submissions[service_name].is_folder_structure:
                submissions[service_name].readme = entry_path
                submissions[service_name].readme_content = RepositoryLoader._read_text_file(entry_path)

        return {
            "config": config,
            "submissions": submissions
        }

    @staticmethod
    def _load_folder_structure(service_name: str, service_path: str) -> Optional[ServiceSubmission]:
        """Load a service from new folder structure.

        Args:
            service_name: Name of the service (folder name)
            service_path: Full path to the service folder

        Returns:
            ServiceSubmission if valid, None otherwise
        """
        service_json_path = os.path.join(service_path, "service.json")

        # Must have service.json to be a valid service folder
        if not os.path.isfile(service_json_path):
            return None

        service_data = RepositoryLoader._read_json_file(service_json_path)

        submission = ServiceSubmission(
            name=service_name,
            service=service_data,
            is_folder_structure=True
        )

        # Load service.md if present
        service_md_path = os.path.join(service_path, "service.md")
        if os.path.isfile(service_md_path):
            submission.readme = service_md_path
            submission.readme_content = RepositoryLoader._read_text_file(service_md_path)

        # Load pack prompts
        pack_path = os.path.join(service_path, "pack")
        if os.path.isdir(pack_path):
            submission.pack_prompts = RepositoryLoader._load_pack_prompts(pack_path)

        # Load context documents
        context_path = os.path.join(service_path, "context")
        if os.path.isdir(context_path):
            submission.context_documents = RepositoryLoader._load_context_documents(context_path)

        return submission

    @staticmethod
    def _load_pack_prompts(pack_path: str) -> List[PackPrompt]:
        """Load pack prompts from pack directory.

        Scans for ``{type}.[txt/md]`` files and optionally loads
        a matching ``{type}.json``. Supported types:
        config, verify, execute, execution, executioner, optimizer.

        Args:
            pack_path: Path to the pack directory

        Returns:
            List of PackPrompt objects
        """
        prompts = []
        processor_types = {"config", "verify", "execute", "execution", "executioner", "optimizer"}

        for entry in os.listdir(pack_path):
            if entry.endswith(".txt"):
                prompt_type = entry[: -len(".txt")]
            elif entry.endswith(".md"):
                prompt_type = entry[: -len(".md")]
            else:
                continue

            if prompt_type not in processor_types:
                continue

            prompt_content = RepositoryLoader._read_text_file(os.path.join(pack_path, entry))

            schema = None
            schema_path = os.path.join(pack_path, f"{prompt_type}.json")
            if os.path.isfile(schema_path):
                try:
                    schema = RepositoryLoader._read_json_file(schema_path)
                except Exception:
                    pass

            prompts.append(PackPrompt(
                prompt_type=prompt_type,
                prompt=prompt_content,
                schema=schema,
            ))

        return prompts

    @staticmethod
    def _load_context_documents(context_path: str) -> Dict[str, str]:
        """Load context documents from context directory.

        Args:
            context_path: Path to the context directory

        Returns:
            Dict of {filename: content}
        """
        documents = {}

        for entry in os.listdir(context_path):
            entry_path = os.path.join(context_path, entry)
            if os.path.isfile(entry_path):
                documents[entry] = RepositoryLoader._read_text_file(entry_path)

        return documents

    @staticmethod
    def _read_text_file(file_path: str) -> str:
        """Read a text file and return its contents.

        Args:
            file_path: Path to the text file

        Returns:
            File contents as string

        Raises:
            NetorcaException: If file cannot be read
        """
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                return f.read()
        except FileNotFoundError as exc:
            raise NetorcaException(f"File not found: {file_path}. Exception: {exc}")
        except PermissionError as exc:
            raise NetorcaException(f"Permission error while reading file: {file_path}. Exception: {exc}")

    @staticmethod
    def load_consumer_repository(
        repository_path: str,
        netorca_directory: str = ".netorca"
    ) -> Dict[str, Any]:
        """Load consumer submission from repository.

        Expected structure::

            repository_path/
                .netorca/
                    config.yaml or config.yml  # Required: NetOrca configuration
                    app1.yaml                  # Application definitions
                    app2.yml

        Args:
            repository_path (str): Path to consumer repository
            netorca_directory (str, optional): NetOrca directory name. Defaults to ".netorca"

        Returns:
            dict: Dictionary with keys:
                - config (dict): NetOrca configuration
                - submissions (dict): Dict of application submissions

        Raises:
            NetorcaException: If repository structure is invalid
        """
        if not os.path.isdir(repository_path):
            raise NetorcaException(f"{repository_path} directory does not exist.")

        netorca_path = os.path.join(repository_path, netorca_directory)
        if not os.path.isdir(netorca_path):
            raise NetorcaException(f"{netorca_directory} directory does not exist.")

        # Check for config file
        config_path_yaml = os.path.join(netorca_path, "config.yaml")
        config_path_yml = os.path.join(netorca_path, "config.yml")

        if os.path.exists(config_path_yml):
            config_path = config_path_yml
        elif os.path.exists(config_path_yaml):
            config_path = config_path_yaml
        else:
            raise NetorcaException("No config.yaml or config.yml file found in repository.")

        config = RepositoryLoader._load_yaml_file(config_path)
        netorca_global = config.get("netorca_global", {})

        if not netorca_global:
            raise NetorcaException("No netorca_global.base_url provided in config.")

        submissions = {}

        # Load all YAML application definitions
        for filename in os.listdir(netorca_path):
            if filename in ["config.yaml", "config.yml"]:
                continue

            file_path = os.path.join(netorca_path, filename)

            if not (os.path.isfile(file_path) and (file_path.endswith(".yaml") or file_path.endswith(".yml"))):
                continue

            app_data = RepositoryLoader._load_yaml_file(file_path)

            if not isinstance(app_data, dict):
                raise NetorcaException(f"Invalid format in file: {filename}. Expected dictionary.")

            for app_name, content in app_data.items():
                if app_name in submissions:
                    raise NetorcaException(f"Application '{app_name}' already exists in different YAML file.")

                submissions[app_name] = {
                    "metadata": content.get("metadata", {}),
                    "services": content.get("services", {})
                }

        return {
            "config": config,
            "submissions": submissions
        }

    @staticmethod
    def _read_json_file(file_path: str) -> Dict[str, Any]:
        """Read and parse JSON file.

        Args:
            file_path (str): Path to JSON file to read

        Returns:
            dict: Parsed JSON data

        Raises:
            NetorcaException: If file not found, permission denied, or JSON parsing fails
        """
        try:
            with open(file_path, "r") as stream:
                return json.load(stream)
        except FileNotFoundError as exc:
            raise NetorcaException(f"File not found: {file_path}. Exception: {exc}")
        except PermissionError as exc:
            raise NetorcaException(f"Permission error while reading file: {file_path}. Exception: {exc}")
        except json.JSONDecodeError as exc:
            raise NetorcaException(f"Error while parsing file: {file_path}. Exception: {exc.msg}")

    @staticmethod
    def _load_yaml_file(file_path: str) -> Dict[str, Any]:
        """Read and parse YAML file.

        Args:
            file_path (str): Path to YAML file to read

        Returns:
            dict: Parsed YAML data (empty dict if file is empty)

        Raises:
            NetorcaException: If YAML parsing fails, duplicate keys found, or other errors
        """
        file_name = os.path.basename(file_path)
        yaml_parser = YAML()
        yaml_parser.allow_duplicate_keys = False

        try:
            with open(file_path, "r") as stream:
                loaded_yaml = yaml_parser.load(stream)
                return loaded_yaml if loaded_yaml is not None else {}
        except DuplicateKeyError as exc:
            raise NetorcaException(f"Error parsing '{file_name}': Duplicate key found. {exc}")
        except YAMLError as exc:
            raise NetorcaException(f"Error parsing '{file_name}': {exc}")
        except Exception as exc:
            raise NetorcaException(f"Error parsing '{file_name}': {str(exc)}")


class ErrorFormatter:
    """
    Helper class for formatting validation errors.

    Provides methods to format validation errors from NetOrca API
    into readable tables using beautifultable.

    Examples:
        Format service owner validation errors:

        >>> from netorca_sdk.v2 import ErrorFormatter
        >>> errors = {
        ...     "schema": {"required": ["title is required"]},
        ...     "name": "Name must be lowercase"
        ... }
        >>> formatted = ErrorFormatter.format_serviceowner_errors(
        ...     service_name="my_service",
        ...     errors=errors
        ... )
        >>> print(formatted)

        Format consumer validation errors:

        >>> errors = {
        ...     "team1": {
        ...         "app1": {
        ...             "metadata": {"name": "Invalid name format"}
        ...         }
        ...     }
        ... }
        >>> formatted = ErrorFormatter.format_consumer_errors(errors)
        >>> print(formatted)
    """

    @staticmethod
    def format_serviceowner_errors(service_name: str, errors: Dict[str, Any]) -> str:
        """Format service owner validation errors into a readable table.

        Args:
            service_name (str): Name of the service being validated
            errors (dict): Validation errors dictionary from API response

        Returns:
            str: Formatted error string with table layout

        Example:
            >>> errors = {"title": "Required field", "schema": {"type": "Invalid"}}
            >>> output = ErrorFormatter.format_serviceowner_errors("my_service", errors)
            >>> print(output)
            --------------------...
            Schema: my_service validation errors
            --------------------...
            ╒════════╤══════════╤════════╕
            │ Schema │ Property │ Reason │
            ╘════════╧══════════╧════════╛
        """
        def append_to_table(service_name: str, item: str, value: Any, table: BeautifulTable) -> None:
            if isinstance(value, (str, list)):
                table.rows.append([service_name, item, value])
            elif isinstance(value, dict):
                for key, val in value.items():
                    append_to_table(service_name, key, val, table)

        table = BeautifulTable(maxwidth=100)
        table.set_style(BeautifulTable.STYLE_SEPARATED)
        table.columns.header = ["Schema", "Property", "Reason"]

        for item, value in errors.items():
            append_to_table(service_name, item, value, table)

        if table.rows:
            output = [
                "-" * 100,
                f"Schema: {service_name} validation errors",
                "-" * 100,
                str(table),
                ""
            ]
            return "\n".join(output)

        return ""

    @staticmethod
    def format_consumer_errors(errors: Dict[str, Any]) -> str:
        """Format consumer validation errors.

        Args:
            errors (dict): Validation errors dictionary

        Returns:
            str: Formatted error string with table layout
        """
        output_lines = []

        # Format team-level errors
        table = BeautifulTable(maxwidth=100)
        table.set_style(BeautifulTable.STYLE_SEPARATED)
        table.columns.header = ["Team", "Field", "Reason"]

        for item1, value1 in errors.items():
            if isinstance(value1, (str, list)):
                table.rows.append([item1, "", value1])
            elif isinstance(value1, dict):
                for item2, value2 in value1.items():
                    if isinstance(value2, (str, list)):
                        table.rows.append([item1, item2, value2])
                        if table.rows:
                            output_lines.append("-" * 100)
                            output_lines.append(f"Team: {item1} validation errors")
                            output_lines.append("-" * 100)
                            output_lines.append(str(table))
                            output_lines.append("")
                        break

        # Format application-level errors
        for item1, value1 in errors.items():
            if isinstance(value1, dict):
                for item2, value2 in value1.items():
                    table = BeautifulTable(maxwidth=100)
                    table.set_style(BeautifulTable.STYLE_SEPARATED)
                    table.columns.header = ["Application", "Service", "ServiceItem", "Field", "Reason"]

                    if isinstance(value2, dict):
                        for item3, value3 in value2.items():
                            if isinstance(value3, str):
                                table.rows.append([item2, "", "", item3, value3])
                            elif isinstance(value3, list):
                                for err in value3:
                                    table.rows.append([item2, "", "", item3, err])
                            elif isinstance(value3, dict):
                                for item4, value4 in value3.items():
                                    if isinstance(value4, (str, list)):
                                        table.rows.append([item2, item3, "", item4, value4])
                                    elif isinstance(value4, dict):
                                        for item5, value5 in value4.items():
                                            if isinstance(value5, (str, list)):
                                                table.rows.append([item2, item3, item4, item5, value5])

                        if table.rows:
                            output_lines.append("-" * 100)
                            output_lines.append(f"Application: {item2} validation errors")
                            output_lines.append("-" * 100)
                            output_lines.append(str(table))
                            output_lines.append("")

        return "\n".join(output_lines)


class ServiceOwnerSubmission:
    """
    Helper class for validating and submitting service owner services.

    Handles the full workflow of validating services, submitting them to NetOrca,
    and optionally uploading pack prompts and context documents.

    Examples:
        Basic validation and submission:

        >>> from netorca_sdk.v2 import NetOrcaClient, ServiceOwnerSubmission
        >>> client = NetOrcaClient(base_url="https://api.netorca.io", token="...")
        >>> submission = ServiceOwnerSubmission(client, repository_path="/path/to/repo")
        >>> is_valid, errors = submission.validate()
        >>> if is_valid:
        ...     results = submission.submit()
        ...     for service_name, result in results.items():
        ...         print(f"{service_name}: {result['status']}")

        Validate only without submitting:

        >>> is_valid, errors = submission.validate()
        >>> if not is_valid:
        ...     print(ErrorFormatter.format_serviceowner_errors("my_service", errors))
    """

    def __init__(
        self,
        client: "NetOrcaClient",
        repository_path: str,
        netorca_directory: str = ".netorca"
    ):
        """Initialize ServiceOwnerSubmission.

        Args:
            client: NetOrcaClient instance
            repository_path: Path to the repository containing .netorca folder
            netorca_directory: Name of the NetOrca directory (default: ".netorca")
        """
        self.client = client
        self.repository_path = repository_path
        self.netorca_directory = netorca_directory
        self._loaded = False
        self._config: Dict[str, Any] = {}
        self._submissions: Dict[str, ServiceSubmission] = {}

    def _load(self) -> None:
        """Load repository data if not already loaded."""
        if self._loaded:
            return

        data = RepositoryLoader.load_serviceowner_repository(
            self.repository_path,
            self.netorca_directory
        )
        self._config = data["config"]
        self._submissions = data["submissions"]
        self._loaded = True

    @property
    def config(self) -> Dict[str, Any]:
        """Get the NetOrca configuration."""
        self._load()
        return self._config

    @property
    def submissions(self) -> Dict[str, ServiceSubmission]:
        """Get the service submissions."""
        self._load()
        return self._submissions

    def validate(self) -> tuple:
        """Validate all services in the repository.

        Returns:
            Tuple of (is_valid: bool, errors: dict)
            - is_valid: True if all services pass validation
            - errors: Dict of {service_name: error_details} for any failures

        Example:
            >>> is_valid, errors = submission.validate()
            >>> if not is_valid:
            ...     for service_name, err in errors.items():
            ...         print(f"Service {service_name} failed: {err}")
        """
        self._load()
        all_errors = {}
        is_valid = True

        for service_name, submission in self._submissions.items():
            try:
                response = self.client.services.validate(submission.service)
                # If we get here without exception, validation passed
            except Exception as exc:
                is_valid = False
                all_errors[service_name] = str(exc)

        return is_valid, all_errors

    def submit(
        self,
        upload_pack: bool = True,
        upload_context: bool = True,
        skip_duplicate_documents: bool = True,
        require_existing_processors: bool = False,
        dry_run: bool = False
    ) -> Dict[str, Dict[str, Any]]:
        """Submit all services to NetOrca.

        Args:
            upload_pack: Whether to upload pack prompts (default: True)
            upload_context: Whether to upload context documents (default: True)
            skip_duplicate_documents: If True, skip context documents that already
                exist for the service (matched by filename). If False, update them.
                Default: True
            require_existing_processors: If True, raise an error instead of creating
                a new AI processor when one does not already exist. Default: False
            dry_run: If True, validate only without submitting (default: False)

        Returns:
            Dict of {service_name: result} where result contains:
            - status: "success", "error", or "skipped"
            - service_id: ID of created/updated service (if success)
            - error: Error message (if error)
            - pack_uploaded: Whether pack prompts were uploaded
            - context_uploaded: Whether context docs were uploaded

        Example:
            >>> results = submission.submit(upload_pack=True, upload_context=True)
            >>> for name, result in results.items():
            ...     if result["status"] == "success":
            ...         print(f"{name}: Service ID {result['service_id']}")
        """
        self._load()
        results = {}

        for service_name, submission in self._submissions.items():
            result = {
                "status": "pending",
                "service_id": None,
                "error": None,
                "pack_uploaded": False,
                "context_uploaded": False
            }

            try:
                if dry_run:
                    # Just validate
                    self.client.services.validate(submission.service)
                    result["status"] = "validated"
                else:
                    # Submit service (creates or updates based on title)
                    # API expects the schema directly, not wrapped
                    service = self.client.services.submit(submission.service)
                    result["service_id"] = service.id
                    result["status"] = "submitted"

                    # Upload pack prompts as AI Processors if available and requested
                    if upload_pack and submission.pack_prompts and result["service_id"]:
                        pack_result = self._upload_pack_prompts(
                            service_id=result["service_id"],
                            service_name=submission.service.get("title", service_name),
                            prompts=submission.pack_prompts,
                            require_existing=require_existing_processors,
                        )
                        result["pack"] = pack_result

                    # Upload context documents if available and requested
                    if upload_context and submission.context_documents and result["service_id"]:
                        ctx_result = self._upload_context_documents(
                            result["service_id"], submission.context_documents,
                            skip_duplicates=skip_duplicate_documents
                        )
                        result["context"] = ctx_result

            except Exception as exc:
                result["status"] = "error"
                result["error"] = str(exc)

            results[service_name] = result

        return results

    def _upload_pack_prompts(
        self,
        service_id: int,
        service_name: str,
        prompts: List[PackPrompt],
        require_existing: bool = False,
    ) -> Dict[str, Any]:
        """Upload pack prompts as AI Processors for a service.

        Processor name is always ``{service_name}_{type}``.
        LLM model defaults to ``DEFAULT_LLM_MODEL_ID`` on create;
        it is not changed when updating an existing processor.

        Args:
            service_id: ID of the service
            service_name: Name of the service (used to build processor name)
            prompts: List of PackPrompt objects to upload
            require_existing: If True, raise an error instead of creating a new
                processor when one does not already exist. Default: False

        Returns:
            Dict with keys: created, updated, skipped, errors
        """
        result: Dict[str, Any] = {
            "created": [],
            "updated": [],
            "skipped": [],
            "errors": []
        }

        # Map file type names to API action_type values
        action_type_map = {
            "config": "config",
            "verify": "verify",
            "execute": "execution",
            "execution": "execution",
            "executioner": "execution",
            "optimizer": "optimizer",
        }

        # Build lookup by action_type for this service
        existing_by_action_type: Dict[str, int] = {}
        try:
            for processor in self.client.ai_processors.list():
                if str(processor.service_id) == str(service_id) and processor.action_type:
                    existing_by_action_type[processor.action_type] = processor.id
        except Exception:
            pass

        for prompt in prompts:
            action_type = action_type_map.get(prompt.prompt_type, prompt.prompt_type)
            processor_name = f"{service_name}_{prompt.prompt_type}"
            existing_id = existing_by_action_type.get(action_type)

            try:
                if existing_id:
                    update_data: Dict[str, Any] = {
                        "name": processor_name,
                        "prompt": prompt.prompt,
                        "active": True,
                    }
                    if prompt.schema:
                        update_data["response_schema"] = prompt.schema
                    self.client.ai_processors.update(existing_id, update_data)
                    result["updated"].append(processor_name)
                else:
                    if require_existing:
                        result["errors"].append(
                            f"AI Processor '{processor_name}' does not exist (action_type='{action_type}'). "
                            f"Remove --require-existing-processors to allow creating new processors."
                        )
                        continue
                    create_data: Dict[str, Any] = {
                        "name": processor_name,
                        "service": service_id,
                        "llm_model": DEFAULT_LLM_MODEL_ID,
                        "action_type": action_type,
                        "prompt": prompt.prompt,
                        "active": True,
                    }
                    if prompt.schema:
                        create_data["response_schema"] = prompt.schema
                    self.client.ai_processors.create(create_data)
                    result["created"].append(processor_name)
            except Exception as exc:
                # Fallback: if create hit unique constraint, find and update
                if "already exists" in str(exc):
                    try:
                        found_id = None
                        for p in self.client.ai_processors.list():
                            if str(p.service_id) == str(service_id) and p.action_type == action_type:
                                found_id = p.id
                                break
                        if found_id:
                            update_data = {
                                "name": processor_name,
                                "prompt": prompt.prompt,
                                "active": True,
                            }
                            if prompt.schema:
                                update_data["response_schema"] = prompt.schema
                            self.client.ai_processors.update(found_id, update_data)
                            result["updated"].append(processor_name)
                        else:
                            result["errors"].append(f"AI Processor '{processor_name}': {exc}")
                    except Exception as retry_exc:
                        result["errors"].append(f"AI Processor '{processor_name}': {retry_exc}")
                else:
                    result["errors"].append(f"AI Processor '{processor_name}': {exc}")

        return result

    def _upload_context_documents(
        self,
        service_id: int,
        documents: Dict[str, str],
        skip_duplicates: bool = True,
    ) -> Dict[str, Any]:
        """Upload context documents for a service.

        Args:
            service_id: ID of the service
            documents: Dict of {filename: content}
            skip_duplicates: If True, skip files that already exist for this service
                (matched by filename). If False, update them. Default: True

        Returns:
            Dict with keys: created, updated, skipped, errors
        """
        result: Dict[str, Any] = {
            "created": [],
            "updated": [],
            "skipped": [],
            "errors": []
        }

        # Fetch existing documents for this service (keyed by filename)
        existing_docs: Dict[str, int] = {}
        try:
            for doc in self.client.ai_documents.by_service(service_id):
                filename = doc.data.get("filename")
                if filename:
                    existing_docs[filename] = doc.id
        except Exception:
            pass

        for filename, content in documents.items():
            if filename in existing_docs:
                if skip_duplicates:
                    result["skipped"].append(filename)
                    continue
                try:
                    self.client.ai_documents.update(
                        existing_docs[filename],
                        {"filename": filename, "raw_content": content}
                    )
                    result["updated"].append(filename)
                except Exception as exc:
                    result["errors"].append(f"Context doc '{filename}': {exc}")
            else:
                try:
                    self.client.ai_documents.create_for_service(
                        service_id,
                        {"filename": filename, "raw_content": content}
                    )
                    result["created"].append(filename)
                except Exception as exc:
                    result["errors"].append(f"Context doc '{filename}': {exc}")

        return result

    def get_summary(self) -> str:
        """Get a summary of the repository contents.

        Returns:
            Formatted string summarizing services, pack prompts, and context docs
        """
        self._load()
        lines = []
        lines.append(f"Repository: {self.repository_path}")
        lines.append(f"Services found: {len(self._submissions)}")
        lines.append("")

        for service_name, submission in self._submissions.items():
            lines.append(f"  {service_name}:")
            lines.append(f"    Structure: {'folder' if submission.is_folder_structure else 'legacy'}")
            lines.append(f"    Has README: {'yes' if submission.readme_content else 'no'}")
            if submission.pack_prompts:
                prompt_types = [p.prompt_type for p in submission.pack_prompts]
                lines.append(f"    Pack prompts: {', '.join(prompt_types)}")
            if submission.context_documents:
                lines.append(f"    Context docs: {', '.join(submission.context_documents.keys())}")
            lines.append("")

        return "\n".join(lines)
