# -*- coding: utf-8 -*-
"""Location: ./mcpgateway/tools/builder/schema.py
Copyright 2026
SPDX-License-Identifier: Apache-2.0
Authors: Teryl Taylor

Pydantic schemas for MCP Stack configuration validation
"""

# Standard
import re
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse

# Third-Party
from pydantic import BaseModel, ConfigDict, Field, field_validator

_PLUGIN_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
_GIT_REF_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")
_SCP_REPO_PATTERN = re.compile(r"^git@[A-Za-z0-9._-]+:[A-Za-z0-9._/-]+(?:\.git)?$")


class OpenShiftConfig(BaseModel):
    """OpenShift-specific configuration.

    Routes are OpenShift's native way of exposing services externally (predates Kubernetes Ingress).
    They provide built-in TLS termination and are integrated with OpenShift's router/HAProxy infrastructure.

    Attributes:
        create_routes: Create OpenShift Route resources for external access (default: False)
        domain: OpenShift apps domain for route hostnames (default: auto-detected from cluster)
        tls_termination: TLS termination mode - edge, passthrough, or reencrypt (default: edge)

    Examples:
        >>> # Test with default values
        >>> config = OpenShiftConfig()
        >>> config.create_routes
        False
        >>> config.tls_termination
        'edge'

        >>> # Test with custom values
        >>> config = OpenShiftConfig(
        ...     create_routes=True,
        ...     domain="apps.example.com",
        ...     tls_termination="passthrough"
        ... )
        >>> config.create_routes
        True
        >>> config.domain
        'apps.example.com'
        >>> config.tls_termination
        'passthrough'

        >>> # Test valid TLS termination modes
        >>> for mode in ["edge", "passthrough", "reencrypt"]:
        ...     cfg = OpenShiftConfig(tls_termination=mode)
        ...     cfg.tls_termination == mode
        True
        True
        True
    """

    create_routes: bool = Field(False, description="Create OpenShift Route resources")
    domain: Optional[str] = Field(None, description="OpenShift apps domain (e.g., apps-crc.testing)")
    tls_termination: Literal["edge", "passthrough", "reencrypt"] = Field("edge", description="TLS termination mode")


class DeploymentConfig(BaseModel):
    """Deployment configuration

    Examples:
        >>> # Test compose deployment
        >>> config = DeploymentConfig(type="compose", project_name="test-project")
        >>> config.type
        'compose'
        >>> config.project_name
        'test-project'

        >>> # Test kubernetes deployment
        >>> config = DeploymentConfig(type="kubernetes", namespace="mcp-test")
        >>> config.type
        'kubernetes'
        >>> config.namespace
        'mcp-test'

        >>> # Test container engine options
        >>> config = DeploymentConfig(type="compose", container_engine="podman")
        >>> config.container_engine
        'podman'

        >>> # Test with OpenShift config
        >>> config = DeploymentConfig(
        ...     type="kubernetes",
        ...     namespace="test",
        ...     openshift=OpenShiftConfig(create_routes=True)
        ... )
        >>> config.openshift.create_routes
        True
    """

    type: Literal["kubernetes", "compose"] = Field(..., description="Deployment type")
    container_engine: Optional[str] = Field(default=None, description="Container engine: 'podman', 'docker', or full path (e.g., '/opt/podman/bin/podman')")
    project_name: Optional[str] = Field(None, description="Project name for compose")
    namespace: Optional[str] = Field(None, description="Namespace for Kubernetes")
    openshift: Optional[OpenShiftConfig] = Field(None, description="OpenShift-specific configuration")


class RegistryConfig(BaseModel):
    """Container registry configuration.

    Optional configuration for pushing built images to a container registry.
    When enabled, images will be tagged with the full registry path and optionally pushed.

    Authentication:
        Users must authenticate to the registry before running the build:
        - Docker Hub: `docker login`
        - Quay.io: `podman login quay.io`
        - OpenShift internal: `podman login $(oc registry info) -u $(oc whoami) -p $(oc whoami -t)`
        - Private registry: `podman login your-registry.com -u username`

    Attributes:
        enabled: Enable registry integration (default: False)
        url: Registry URL (e.g., "docker.io", "quay.io", "default-route-openshift-image-registry.apps-crc.testing")
        namespace: Registry namespace/organization/project (e.g., "myorg", "mcp-gateway-test")
        push: Push image after build (default: True)
        image_pull_policy: Kubernetes imagePullPolicy (default: "IfNotPresent")

    Examples:
        >>> # Test with defaults (registry disabled)
        >>> config = RegistryConfig()
        >>> config.enabled
        False
        >>> config.push
        True
        >>> config.image_pull_policy
        'IfNotPresent'

        >>> # Test Docker Hub configuration
        >>> config = RegistryConfig(
        ...     enabled=True,
        ...     url="docker.io",
        ...     namespace="myusername"
        ... )
        >>> config.enabled
        True
        >>> config.url
        'docker.io'
        >>> config.namespace
        'myusername'

        >>> # Test with custom pull policy
        >>> config = RegistryConfig(
        ...     enabled=True,
        ...     url="quay.io",
        ...     namespace="myorg",
        ...     image_pull_policy="Always"
        ... )
        >>> config.image_pull_policy
        'Always'

        >>> # Test tag-only mode (no push)
        >>> config = RegistryConfig(
        ...     enabled=True,
        ...     url="registry.local",
        ...     namespace="test",
        ...     push=False
        ... )
        >>> config.push
        False
    """

    enabled: bool = Field(False, description="Enable registry push")
    url: Optional[str] = Field(None, description="Registry URL (e.g., docker.io, quay.io, or internal registry)")
    namespace: Optional[str] = Field(None, description="Registry namespace/organization/project")
    push: bool = Field(True, description="Push image after build")
    image_pull_policy: Optional[str] = Field("IfNotPresent", description="Kubernetes imagePullPolicy (IfNotPresent, Always, Never)")


class BuildableConfig(BaseModel):
    """Base class for components that can be built from source or use pre-built images.

    This base class provides common configuration for both gateway and plugins,
    supporting two build modes:
    1. Pre-built image: Specify only 'image' field
    2. Build from source: Specify 'repo' and optionally 'ref', 'context', 'containerfile', 'target'

    Attributes:
        image: Pre-built Docker image name (e.g., "mcpgateway/mcpgateway:latest")
        repo: Git repository URL to build from
        ref: Git branch/tag/commit to checkout (default: "main")
        context: Build context subdirectory within repo (default: ".")
        containerfile: Path to Containerfile/Dockerfile (default: "Containerfile")
        target: Target stage for multi-stage builds (optional)
        host_port: Host port mapping for direct access (optional)
        env_vars: Environment variables for container
        env_file: Path to environment file (.env)
        mtls_enabled: Enable mutual TLS authentication (default: True)
    """

    # Allow attribute assignment after model creation (needed for auto-detection of env_file)
    model_config = ConfigDict(validate_assignment=True)

    # Build configuration
    image: Optional[str] = Field(None, description="Pre-built Docker image")
    repo: Optional[str] = Field(None, description="Git repository URL")
    ref: Optional[str] = Field("main", description="Git branch/tag/commit")
    context: Optional[str] = Field(".", description="Build context subdirectory")
    containerfile: Optional[str] = Field("Containerfile", description="Containerfile path")
    target: Optional[str] = Field(None, description="Multi-stage build target")

    # Runtime configuration
    host_port: Optional[int] = Field(None, description="Host port mapping")
    env_vars: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Environment variables")
    env_file: Optional[str] = Field(None, description="Path to environment file (.env)")
    mtls_enabled: Optional[bool] = Field(True, description="Enable mTLS")

    # Registry configuration
    registry: Optional[RegistryConfig] = Field(None, description="Container registry configuration")

    @field_validator("repo")
    @classmethod
    def validate_repo(cls, v: Optional[str]) -> Optional[str]:
        """Validate repository URL format for git clone usage.

        Args:
            v: Repository URL value to validate.

        Returns:
            Optional[str]: Normalized repository URL.

        Raises:
            ValueError: If repository URL is malformed or uses unsafe characters.
        """
        if v is None:
            return v

        repo = v.strip()
        if not repo:
            raise ValueError("Repository URL cannot be empty")

        if any(ch.isspace() for ch in repo):
            raise ValueError("Repository URL cannot contain whitespace")

        if "\\" in repo:
            raise ValueError("Repository URL cannot contain backslashes")

        if any(token in repo for token in ("`", "$", "|", ";", "&", "<", ">")):
            raise ValueError("Repository URL contains unsupported characters")

        if _SCP_REPO_PATTERN.match(repo):
            return repo

        parsed = urlparse(repo)
        if parsed.scheme not in {"https", "http", "ssh", "git"}:
            raise ValueError("Repository URL must use one of: https, http, ssh, git, or git@host:path syntax")
        if not parsed.netloc:
            raise ValueError("Repository URL must include a host")
        if not parsed.path or parsed.path == "/":
            raise ValueError("Repository URL must include a repository path")

        return repo

    @field_validator("ref")
    @classmethod
    def validate_ref(cls, v: Optional[str]) -> Optional[str]:
        """Validate git ref format used by git fetch/clone commands.

        Args:
            v: Git reference value to validate.

        Returns:
            Optional[str]: Normalized git reference.

        Raises:
            ValueError: If ref contains unsafe patterns.
        """
        if v is None:
            return v

        ref = v.strip()
        if not ref:
            raise ValueError("Git ref cannot be empty")
        if ref.startswith("-"):
            raise ValueError("Git ref cannot start with '-'")
        if ".." in ref or "@{" in ref or "\\" in ref or "//" in ref:
            raise ValueError("Git ref contains unsupported sequence")
        if not _GIT_REF_PATTERN.match(ref):
            raise ValueError("Git ref must contain only letters, numbers, dot, underscore, dash, and slash")

        return ref

    def model_post_init(self, _: Any) -> None:
        """Validate that either image or repo is specified

        Raises:
            ValueError: If neither image nor repo is specified

        Examples:
            >>> # Test that error is raised when neither image nor repo specified
            >>> try:
            ...     # BuildableConfig can't be instantiated directly, use GatewayConfig
            ...     from mcpgateway.tools.builder.schema import GatewayConfig
            ...     GatewayConfig()
            ... except ValueError as e:
            ...     "must specify either 'image' or 'repo'" in str(e)
            True

            >>> # Test valid config with image
            >>> from mcpgateway.tools.builder.schema import GatewayConfig
            >>> config = GatewayConfig(image="mcpgateway:latest")
            >>> config.image
            'mcpgateway:latest'

            >>> # Test valid config with repo
            >>> from mcpgateway.tools.builder.schema import GatewayConfig
            >>> config = GatewayConfig(repo="https://github.com/example/repo")
            >>> config.repo
            'https://github.com/example/repo'
        """
        if not self.image and not self.repo:
            component_type = self.__class__.__name__.replace("Config", "")
            raise ValueError(f"{component_type} must specify either 'image' or 'repo'")


class GatewayConfig(BuildableConfig):
    """Gateway configuration.

    Extends BuildableConfig to support either pre-built gateway images or
    building the gateway from source repository.

    Attributes:
        port: Gateway internal port (default: 4444)

    Examples:
        >>> # Test with pre-built image
        >>> config = GatewayConfig(image="mcpgateway:latest")
        >>> config.image
        'mcpgateway:latest'
        >>> config.port
        4444

        >>> # Test with custom port
        >>> config = GatewayConfig(image="mcpgateway:latest", port=8080)
        >>> config.port
        8080

        >>> # Test with source repository
        >>> config = GatewayConfig(
        ...     repo="https://github.com/example/gateway",
        ...     ref="v1.0.0"
        ... )
        >>> config.repo
        'https://github.com/example/gateway'
        >>> config.ref
        'v1.0.0'

        >>> # Test with environment variables
        >>> config = GatewayConfig(
        ...     image="mcpgateway:latest",
        ...     env_vars={"LOG_LEVEL": "DEBUG", "PORT": "4444"}
        ... )
        >>> config.env_vars['LOG_LEVEL']
        'DEBUG'

        >>> # Test with mTLS enabled
        >>> config = GatewayConfig(image="mcpgateway:latest", mtls_enabled=True)
        >>> config.mtls_enabled
        True
    """

    port: Optional[int] = Field(4444, description="Gateway port")


class PluginConfig(BuildableConfig):
    """Plugin configuration.

    Extends BuildableConfig to support plugin-specific configuration while
    inheriting common build and runtime capabilities.

    Attributes:
        name: Unique plugin identifier
        port: Plugin internal port (default: 8000)
        expose_port: Whether to expose plugin port on host (default: False)
        plugin_overrides: Plugin-specific override configuration
    """

    name: str = Field(..., description="Plugin name")
    port: Optional[int] = Field(8000, description="Plugin port")
    expose_port: Optional[bool] = Field(False, description="Expose port on host")
    plugin_overrides: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Plugin overrides")

    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str) -> str:
        """Validate plugin name format.

        Args:
            v: Plugin name value to validate

        Returns:
            Validated plugin name

        Raises:
            ValueError: If plugin name is empty/whitespace or contains unsupported characters.

        Examples:
            >>> # Test valid plugin names
            >>> PluginConfig.validate_name("my-plugin")
            'my-plugin'
            >>> PluginConfig.validate_name("plugin_123")
            'plugin_123'
            >>> PluginConfig.validate_name("TestPlugin")
            'TestPlugin'

            >>> # Test empty name raises error
            >>> try:
            ...     PluginConfig.validate_name("")
            ... except ValueError as e:
            ...     "cannot be empty" in str(e)
            True

            >>> # Test whitespace-only name raises error
            >>> try:
            ...     PluginConfig.validate_name("   ")
            ... except ValueError as e:
            ...     "cannot be empty" in str(e)
            True
        """
        if not v or not v.strip():
            raise ValueError("Plugin name cannot be empty")
        name = v.strip()
        if not _PLUGIN_NAME_PATTERN.match(name):
            raise ValueError("Plugin name must contain only letters, numbers, underscore, and hyphen")
        return name


class CertificatesConfig(BaseModel):
    """Certificate configuration.

    Supports two modes:
    1. Local certificate generation (use_cert_manager=false, default):
       - Certificates generated locally using OpenSSL (via Makefile)
       - Deployed to Kubernetes as secrets via kubectl
       - Manual rotation required before expiry

    2. cert-manager integration (use_cert_manager=true, Kubernetes only):
       - Certificates managed by cert-manager controller
       - Automatic renewal before expiry (default: at 2/3 of lifetime)
       - Native Kubernetes Certificate resources
       - Requires cert-manager to be installed in cluster

    Attributes:
        validity_days: Certificate validity period in days (default: 825 ≈ 2.25 years)
        auto_generate: Auto-generate certificates locally (default: True)
        use_cert_manager: Use cert-manager for certificate management (default: False, Kubernetes only)
        cert_manager_issuer: Name of cert-manager Issuer/ClusterIssuer (default: "mcp-ca-issuer")
        cert_manager_kind: Type of issuer - Issuer or ClusterIssuer (default: "Issuer")
        ca_path: Path to CA certificates for local generation (default: "./certs/mcp/ca")
        gateway_path: Path to gateway certificates for local generation (default: "./certs/mcp/gateway")
        plugins_path: Path to plugin certificates for local generation (default: "./certs/mcp/plugins")
    """

    validity_days: Optional[int] = Field(825, description="Certificate validity in days")
    auto_generate: Optional[bool] = Field(True, description="Auto-generate certificates locally")

    # cert-manager integration (Kubernetes only)
    use_cert_manager: Optional[bool] = Field(False, description="Use cert-manager for certificate management (Kubernetes only)")
    cert_manager_issuer: Optional[str] = Field("mcp-ca-issuer", description="cert-manager Issuer/ClusterIssuer name")
    cert_manager_kind: Optional[Literal["Issuer", "ClusterIssuer"]] = Field("Issuer", description="cert-manager issuer kind")

    ca_path: Optional[str] = Field("./certs/mcp/ca", description="CA certificate path")
    gateway_path: Optional[str] = Field("./certs/mcp/gateway", description="Gateway cert path")
    plugins_path: Optional[str] = Field("./certs/mcp/plugins", description="Plugins cert path")


class PostgresConfig(BaseModel):
    """PostgreSQL database configuration"""

    enabled: Optional[bool] = Field(True, description="Enable PostgreSQL deployment")
    image: Optional[str] = Field("quay.io/sclorg/postgresql-15-c9s:latest", description="PostgreSQL image (default is OpenShift-compatible)")
    database: Optional[str] = Field("mcp", description="Database name")
    user: Optional[str] = Field("postgres", description="Database user")
    password: Optional[str] = Field("mysecretpassword", description="Database password")
    storage_size: Optional[str] = Field("10Gi", description="Persistent volume size (Kubernetes only)")
    storage_class: Optional[str] = Field(None, description="Storage class name (Kubernetes only)")


class RedisConfig(BaseModel):
    """Redis cache configuration"""

    enabled: Optional[bool] = Field(True, description="Enable Redis deployment")
    image: Optional[str] = Field("redis:latest", description="Redis image")


class InfrastructureConfig(BaseModel):
    """Infrastructure services configuration"""

    postgres: Optional[PostgresConfig] = Field(default_factory=PostgresConfig)
    redis: Optional[RedisConfig] = Field(default_factory=RedisConfig)


class MCPStackConfig(BaseModel):
    """Complete MCP Stack configuration"""

    deployment: DeploymentConfig
    gateway: GatewayConfig
    plugins: List[PluginConfig] = Field(default_factory=list)
    certificates: Optional[CertificatesConfig] = Field(default_factory=CertificatesConfig)
    infrastructure: Optional[InfrastructureConfig] = Field(default_factory=InfrastructureConfig)

    @field_validator("plugins")
    @classmethod
    def validate_plugin_names_unique(cls, v: List[PluginConfig]) -> List[PluginConfig]:
        """Ensure plugin names are unique

        Args:
            v: List of plugin configurations to validate

        Returns:
            Validated list of plugin configurations

        Raises:
            ValueError: If duplicate plugin names are found

        Examples:
            >>> from mcpgateway.tools.builder.schema import PluginConfig
            >>> # Test with unique names (valid)
            >>> plugins = [
            ...     PluginConfig(name="plugin1", image="img1:latest"),
            ...     PluginConfig(name="plugin2", image="img2:latest")
            ... ]
            >>> result = MCPStackConfig.validate_plugin_names_unique(plugins)
            >>> len(result) == 2
            True

            >>> # Test with duplicate names (invalid)
            >>> try:
            ...     duplicates = [
            ...         PluginConfig(name="duplicate", image="img1:latest"),
            ...         PluginConfig(name="duplicate", image="img2:latest")
            ...     ]
            ...     MCPStackConfig.validate_plugin_names_unique(duplicates)
            ... except ValueError as e:
            ...     "Duplicate plugin names found" in str(e)
            True

            >>> # Test with empty list (valid)
            >>> empty = MCPStackConfig.validate_plugin_names_unique([])
            >>> len(empty) == 0
            True
        """
        names = [p.name for p in v]
        if len(names) != len(set(names)):
            duplicates = [name for name in names if names.count(name) > 1]
            raise ValueError(f"Duplicate plugin names found: {duplicates}")
        return v
