"""Contract Drift Detection - Detect API contract violations from replays.

Analyzes recorded HTTP calls against OpenAPI/Swagger specs to detect:
- Schema violations (missing fields, wrong types)
- Status code mismatches
- Response structure drift
- Breaking changes between versions

This helps catch flaky tests caused by API contract drift.
"""

from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse

logger = logging.getLogger(__name__)


class DriftSeverity(str, Enum):
    """Severity of contract drift."""

    BREAKING = "breaking"  # Will break clients
    WARNING = "warning"  # May cause issues
    INFO = "info"  # Minor drift


class DriftType(str, Enum):
    """Types of contract drift."""

    MISSING_FIELD = "missing_field"
    EXTRA_FIELD = "extra_field"
    TYPE_MISMATCH = "type_mismatch"
    STATUS_MISMATCH = "status_mismatch"
    SCHEMA_VIOLATION = "schema_violation"
    UNDOCUMENTED_ENDPOINT = "undocumented_endpoint"
    DEPRECATED_USAGE = "deprecated_usage"


@dataclass
class ContractDrift:
    """A detected contract drift."""

    drift_type: DriftType
    severity: DriftSeverity
    endpoint: str
    method: str
    message: str
    expected: Optional[str] = None
    actual: Optional[str] = None
    path: Optional[str] = None  # JSON path to the field


@dataclass
class ContractDriftResult:
    """Result of contract drift analysis."""

    drifts: List[ContractDrift]
    endpoints_checked: int
    endpoints_with_issues: int
    breaking_count: int
    warning_count: int
    spec_version: Optional[str] = None


class ContractDriftDetector:
    """Detect API contract drift from replay transcripts.

    Usage:
        detector = ContractDriftDetector()
        detector.load_spec("openapi.yaml")

        result = detector.analyze_transcript(transcript)
        for drift in result.drifts:
            print(f"{drift.severity}: {drift.message}")
    """

    def __init__(self, max_array_items: int = 100):
        """Initialize the detector."""
        self._spec: Optional[Dict[str, Any]] = None
        self._spec_version: Optional[str] = None
        self._paths: Dict[str, Dict[str, Any]] = {}
        self._max_array_items = max(1, max_array_items)

    def load_spec(self, spec_path: str) -> bool:
        """Load an OpenAPI/Swagger specification.

        Args:
            spec_path: Path to the spec file (YAML or JSON)

        Returns:
            True if loaded successfully
        """
        path = Path(spec_path)
        if not path.exists():
            logger.warning(f"Spec file not found: {spec_path}")
            return False

        try:
            content = path.read_text()

            if path.suffix in [".yaml", ".yml"]:
                try:
                    import yaml

                    self._spec = yaml.safe_load(content)
                except ImportError:
                    logger.warning("PyYAML not installed, trying JSON parse")
                    return False
            else:
                self._spec = json.loads(content)

            # Extract version and paths
            self._spec_version = self._spec.get("info", {}).get("version")
            self._paths = self._spec.get("paths", {})

            logger.info(
                f"Loaded spec version {self._spec_version} with {len(self._paths)} paths"
            )
            return True

        except Exception as e:
            logger.error(f"Failed to load spec: {e}")
            return False

    def load_spec_from_dict(self, spec: Dict[str, Any]) -> None:
        """Load spec from a dictionary.

        Args:
            spec: OpenAPI spec as dictionary
        """
        self._spec = spec
        self._spec_version = spec.get("info", {}).get("version")
        self._paths = spec.get("paths", {})

    def analyze_transcript(
        self,
        transcript: Dict[str, Any],
        strict: bool = False,
    ) -> ContractDriftResult:
        """Analyze a replay transcript for contract drift.

        Args:
            transcript: I/O transcript with http calls
            strict: If True, treat warnings as breaking

        Returns:
            ContractDriftResult with all detected drifts
        """
        drifts: List[ContractDrift] = []
        endpoints_checked: Set[str] = set()
        endpoints_with_issues: Set[str] = set()

        http_calls = transcript.get("http", [])

        for call in http_calls:
            method = call.get("method", "GET").lower()
            url = call.get("url", "")
            status = call.get("status", 200)
            request_body = call.get("request_body")
            response_body = call.get("response_body")

            # Parse URL to get path
            parsed = urlparse(url)
            path = parsed.path

            endpoint_key = f"{method.upper()} {path}"
            endpoints_checked.add(endpoint_key)

            # Find matching spec path
            spec_path, path_params = self._match_path(path)

            if not spec_path:
                if self._spec:  # Only report if spec is loaded
                    drifts.append(
                        ContractDrift(
                            drift_type=DriftType.UNDOCUMENTED_ENDPOINT,
                            severity=DriftSeverity.WARNING,
                            endpoint=path,
                            method=method.upper(),
                            message=f"Endpoint not found in spec: {endpoint_key}",
                        )
                    )
                    endpoints_with_issues.add(endpoint_key)
                continue

            # Get operation spec
            operation = self._paths.get(spec_path, {}).get(method)
            if not operation:
                drifts.append(
                    ContractDrift(
                        drift_type=DriftType.UNDOCUMENTED_ENDPOINT,
                        severity=DriftSeverity.WARNING,
                        endpoint=path,
                        method=method.upper(),
                        message=f"Method {method.upper()} not documented for {spec_path}",
                    )
                )
                endpoints_with_issues.add(endpoint_key)
                continue

            # Check if deprecated
            if operation.get("deprecated"):
                drifts.append(
                    ContractDrift(
                        drift_type=DriftType.DEPRECATED_USAGE,
                        severity=DriftSeverity.WARNING,
                        endpoint=path,
                        method=method.upper(),
                        message=f"Using deprecated endpoint: {endpoint_key}",
                    )
                )

            # Check request body
            if request_body and operation.get("requestBody"):
                req_drifts = self._check_request_body(
                    request_body, operation["requestBody"], path, method
                )
                drifts.extend(req_drifts)
                if req_drifts:
                    endpoints_with_issues.add(endpoint_key)

            # Check response
            responses = operation.get("responses", {})
            resp_drifts = self._check_response(
                status, response_body, responses, path, method
            )
            drifts.extend(resp_drifts)
            if resp_drifts:
                endpoints_with_issues.add(endpoint_key)

        # Calculate counts
        breaking_count = sum(1 for d in drifts if d.severity == DriftSeverity.BREAKING)
        warning_count = sum(1 for d in drifts if d.severity == DriftSeverity.WARNING)

        return ContractDriftResult(
            drifts=drifts,
            endpoints_checked=len(endpoints_checked),
            endpoints_with_issues=len(endpoints_with_issues),
            breaking_count=breaking_count,
            warning_count=warning_count,
            spec_version=self._spec_version,
        )

    def _match_path(self, actual_path: str) -> Tuple[Optional[str], Dict[str, str]]:
        """Match an actual path to a spec path template.

        Args:
            actual_path: The actual URL path

        Returns:
            Tuple of (spec_path, path_params) or (None, {})
        """
        if not self._paths:
            return None, {}

        # Try exact match first
        if actual_path in self._paths:
            return actual_path, {}

        # Try template matching
        for spec_path in self._paths:
            pattern, param_names = self._path_to_regex(spec_path)
            match = re.match(pattern, actual_path)
            if match:
                params = dict(zip(param_names, match.groups()))
                return spec_path, params

        return None, {}

    def _path_to_regex(self, spec_path: str) -> Tuple[str, List[str]]:
        """Convert OpenAPI path template to regex.

        Args:
            spec_path: Path like "/users/{id}/posts/{postId}"

        Returns:
            Tuple of (regex_pattern, param_names)
        """
        param_names = []
        pattern = spec_path

        # Find all {param} patterns
        for match in re.finditer(r"\{(\w+)\}", spec_path):
            param_names.append(match.group(1))
            # Replace with regex group
            pattern = pattern.replace(match.group(0), r"([^/]+)")

        return f"^{pattern}$", param_names

    def _check_request_body(
        self,
        actual: Any,
        spec: Dict[str, Any],
        path: str,
        method: str,
    ) -> List[ContractDrift]:
        """Check request body against spec."""
        drifts = []

        # Get expected schema
        content = spec.get("content", {})
        json_content = content.get("application/json", {})
        schema = json_content.get("schema", {})

        if not schema:
            return drifts

        # Check against schema
        schema_drifts = self._check_schema(actual, schema, "request", path, method)
        drifts.extend(schema_drifts)

        return drifts

    def _check_response(
        self,
        status: int,
        actual: Any,
        responses: Dict[str, Any],
        path: str,
        method: str,
    ) -> List[ContractDrift]:
        """Check response against spec."""
        drifts = []

        # Find matching response spec
        status_str = str(status)
        # Try exact match, then range (2XX, 4XX), then default
        response_spec = responses.get(status_str)
        if not response_spec:
            range_key = f"{status_str[0]}XX"
            response_spec = responses.get(range_key) or responses.get("default")

        if not response_spec:
            # Check if this status is documented at all
            documented_statuses = set(responses.keys()) - {"default"}
            if documented_statuses:
                drifts.append(
                    ContractDrift(
                        drift_type=DriftType.STATUS_MISMATCH,
                        severity=DriftSeverity.WARNING,
                        endpoint=path,
                        method=method.upper(),
                        message=f"Undocumented status code: {status}",
                        expected=", ".join(sorted(documented_statuses)),
                        actual=status_str,
                    )
                )
            return drifts

        # Get expected schema
        content = response_spec.get("content", {})
        json_content = content.get("application/json", {})
        schema = json_content.get("schema", {})

        if schema and actual is not None:
            schema_drifts = self._check_schema(actual, schema, "response", path, method)
            drifts.extend(schema_drifts)

        return drifts

    def _check_schema(
        self,
        actual: Any,
        schema: Dict[str, Any],
        context: str,
        path: str,
        method: str,
        json_path: str = "$",
    ) -> List[ContractDrift]:
        """Check actual value against JSON schema."""
        drifts = []

        # Resolve $ref if present
        if "$ref" in schema:
            schema = self._resolve_ref(schema["$ref"])
            if not schema:
                return drifts

        expected_type = schema.get("type")

        # Handle nullable types
        is_nullable = schema.get("nullable", False)
        if isinstance(expected_type, list):
            # OpenAPI 3.1 style: type: [string, null]
            if actual is None and "null" in expected_type:
                return drifts
            expected_type = next((t for t in expected_type if t != "null"), expected_type[0] if expected_type else None)
            is_nullable = True

        # Check type
        if expected_type:
            actual_type = self._get_json_type(actual)
            if actual_type == "null" and is_nullable:
                return drifts  # Nullable field with null value is fine
            if not self._types_match(actual_type, expected_type):
                drifts.append(
                    ContractDrift(
                        drift_type=DriftType.TYPE_MISMATCH,
                        severity=DriftSeverity.BREAKING,
                        endpoint=path,
                        method=method.upper(),
                        message=f"Type mismatch in {context} at {json_path}",
                        expected=expected_type,
                        actual=actual_type,
                        path=json_path,
                    )
                )
                return drifts  # Don't check further if type is wrong

        # Check object properties
        if expected_type == "object" and isinstance(actual, dict):
            properties = schema.get("properties", {})
            required = set(schema.get("required", []))

            # Check for missing required fields
            for prop_name in required:
                if prop_name not in actual:
                    drifts.append(
                        ContractDrift(
                            drift_type=DriftType.MISSING_FIELD,
                            severity=DriftSeverity.BREAKING,
                            endpoint=path,
                            method=method.upper(),
                            message=f"Missing required field in {context}: {prop_name}",
                            expected=prop_name,
                            path=f"{json_path}.{prop_name}",
                        )
                    )

            # Check for extra fields (if additionalProperties is false)
            if schema.get("additionalProperties") is False:
                for prop_name in actual:
                    if prop_name not in properties:
                        drifts.append(
                            ContractDrift(
                                drift_type=DriftType.EXTRA_FIELD,
                                severity=DriftSeverity.WARNING,
                                endpoint=path,
                                method=method.upper(),
                                message=f"Extra field in {context}: {prop_name}",
                                actual=prop_name,
                                path=f"{json_path}.{prop_name}",
                            )
                        )

            # Recursively check nested properties
            for prop_name, prop_schema in properties.items():
                if prop_name in actual:
                    nested_drifts = self._check_schema(
                        actual[prop_name],
                        prop_schema,
                        context,
                        path,
                        method,
                        f"{json_path}.{prop_name}",
                    )
                    drifts.extend(nested_drifts)

        # Check array items
        if expected_type == "array" and isinstance(actual, list):
            items_schema = schema.get("items", {})
            for i, item in enumerate(actual[: self._max_array_items]):
                item_drifts = self._check_schema(
                    item, items_schema, context, path, method, f"{json_path}[{i}]"
                )
                drifts.extend(item_drifts)

        return drifts

    def _resolve_ref(self, ref: str) -> Optional[Dict[str, Any]]:
        """Resolve a $ref to its schema."""
        if not self._spec:
            return None
        if not ref.startswith("#/"):
            logger.warning(f"External $ref not supported: {ref}")
            return None

        parts = ref[2:].split("/")
        current = self._spec

        for part in parts:
            if isinstance(current, dict) and part in current:
                current = current[part]
            else:
                return None

        return current if isinstance(current, dict) else None

    def _get_json_type(self, value: Any) -> str:
        """Get JSON type name for a Python value."""
        if value is None:
            return "null"
        if isinstance(value, bool):
            return "boolean"
        if isinstance(value, int):
            return "integer"
        if isinstance(value, float):
            return "number"
        if isinstance(value, str):
            return "string"
        if isinstance(value, list):
            return "array"
        if isinstance(value, dict):
            return "object"
        return "unknown"

    def _types_match(self, actual: str, expected: str) -> bool:
        """Check if types match (with some flexibility)."""
        if actual == expected:
            return True
        # integer is a subset of number (both directions)
        if expected == "number" and actual == "integer":
            return True
        if expected == "integer" and actual == "number":
            return True
        return False


def analyze_contract_drift(
    transcript: Dict[str, Any],
    spec_path: Optional[str] = None,
    spec_dict: Optional[Dict[str, Any]] = None,
) -> ContractDriftResult:
    """Convenience function to analyze contract drift.

    Args:
        transcript: I/O transcript with http calls
        spec_path: Path to OpenAPI spec file
        spec_dict: OpenAPI spec as dictionary (alternative to spec_path)

    Returns:
        ContractDriftResult
    """
    detector = ContractDriftDetector()

    if spec_path:
        detector.load_spec(spec_path)
    elif spec_dict:
        detector.load_spec_from_dict(spec_dict)

    return detector.analyze_transcript(transcript)


def infer_contract_from_transcript(
    transcript: Dict[str, Any],
) -> Dict[str, Any]:
    """Infer an OpenAPI spec from a transcript.

    Useful when no spec exists - creates a baseline from observed behavior.

    Args:
        transcript: I/O transcript with http calls

    Returns:
        OpenAPI spec dictionary
    """
    paths: Dict[str, Dict[str, Any]] = {}

    http_calls = transcript.get("http", [])

    for call in http_calls:
        method = call.get("method", "GET").lower()
        url = call.get("url", "")
        status = call.get("status", 200)
        request_body = call.get("request_body")
        response_body = call.get("response_body")

        # Parse URL
        parsed = urlparse(url)
        path = parsed.path

        # Initialize path if needed
        if path not in paths:
            paths[path] = {}

        # Initialize operation if needed
        if method not in paths[path]:
            paths[path][method] = {
                "responses": {},
            }

        operation = paths[path][method]

        # Add request body schema
        if request_body:
            operation["requestBody"] = {
                "content": {
                    "application/json": {
                        "schema": _infer_schema(request_body),
                    }
                }
            }

        # Add response schema
        status_str = str(status)
        if status_str not in operation["responses"]:
            operation["responses"][status_str] = {
                "description": f"Response {status}",
            }

        if response_body:
            operation["responses"][status_str]["content"] = {
                "application/json": {
                    "schema": _infer_schema(response_body),
                }
            }

    return {
        "openapi": "3.0.0",
        "info": {
            "title": "Inferred API",
            "version": "1.0.0-inferred",
        },
        "paths": paths,
    }


def _infer_schema(value: Any) -> Dict[str, Any]:
    """Infer a JSON schema from a value."""
    if value is None:
        return {"type": "null"}
    if isinstance(value, bool):
        return {"type": "boolean"}
    if isinstance(value, int):
        return {"type": "integer"}
    if isinstance(value, float):
        return {"type": "number"}
    if isinstance(value, str):
        return {"type": "string"}
    if isinstance(value, list):
        if not value:
            return {"type": "array", "items": {}}
        # Infer from first item
        return {"type": "array", "items": _infer_schema(value[0])}
    if isinstance(value, dict):
        properties = {}
        required = []
        for k, v in value.items():
            properties[k] = _infer_schema(v)
            required.append(k)
        return {
            "type": "object",
            "properties": properties,
            "required": required,
        }
    return {}
