"""Validation pipeline service for CleverAgents.

Orchestrates execution of validation commands against resources,
enforces required vs informational semantics, and produces an
aggregated validation summary.

The pipeline:
- Sorts validations deterministically by (resource_name, mode, validation_name)
- Groups validations by resource for organised reporting
- Executes each validation with a configurable timeout
- Captures stdout/stderr for troubleshooting
- Normalises output to a consistent passed/message/data schema
- Builds a ValidationSummary with aggregated counts
- Integrates with the plan lifecycle for metadata persistence

Based on ``docs/specification.md`` and ADR-013 (Validation Abstraction).
"""

from __future__ import annotations

import io
import logging
import sys
import threading
import time
from collections import defaultdict
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, Any, Protocol

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.domain.models.core.tool import ValidationMode
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.infrastructure.events.protocol import EventBus

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Thread-safe stdout/stderr capture
# ---------------------------------------------------------------------------


class _ThreadLocalStream(io.TextIOBase):
    """Stream wrapper providing thread-local output capture.

    Non-capturing threads write through to the original stream.
    Threads that call ``start_capture()`` collect writes in a
    per-thread ``StringIO`` buffer until ``stop_capture()`` returns
    the captured text.
    """

    def __init__(self, original: Any) -> None:
        self._original = original
        self._local = threading.local()

    @property
    def encoding(self) -> str:  # type: ignore[override]
        return getattr(self._original, "encoding", "utf-8")

    def writable(self) -> bool:
        return True

    def readable(self) -> bool:
        return False

    def write(self, s: str) -> int:
        buf: io.StringIO | None = getattr(self._local, "buffer", None)
        if buf is not None:
            return buf.write(s)
        return self._original.write(s)

    def flush(self) -> None:
        buf: io.StringIO | None = getattr(self._local, "buffer", None)
        if buf is not None:
            buf.flush()
        self._original.flush()

    def isatty(self) -> bool:
        return self._original.isatty()

    def start_capture(self) -> None:
        """Begin capturing writes for the current thread."""
        self._local.buffer = io.StringIO()

    def stop_capture(self) -> str:
        """Stop capturing and return captured text for the current thread."""
        buf: io.StringIO | None = getattr(self._local, "buffer", None)
        self._local.buffer = None
        return buf.getvalue() if buf else ""


# ---------------------------------------------------------------------------
# Type alias for validation executor callables
# ---------------------------------------------------------------------------


class ValidationExecutor(Protocol):
    """Protocol for a callable that executes a single validation."""

    def __call__(
        self, validation_name: str, arguments: dict[str, Any]
    ) -> dict[str, Any]:
        """Execute a validation and return result dict.

        Must return a dict with at least:
        - ``passed`` (bool): whether the validation passed
        - ``message`` (str): human-readable result description

        May optionally include:
        - ``data`` (dict): additional structured data
        """
        ...  # pragma: no cover


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------


class ValidationCommand(BaseModel):
    """Command describing a single validation to execute.

    Attributes:
        validation_name: Name of the validation to run.
        resource_id: ULID of the resource being validated.
        resource_name: Human-readable resource name.
        mode: Whether failure blocks (required) or is informational.
        arguments: Arguments to pass to the validation executor.
        timeout_seconds: Maximum execution time in seconds.
    """

    validation_name: str = Field(
        ..., min_length=1, description="Name of the validation"
    )
    resource_id: str = Field(..., min_length=1, description="Resource ULID")
    resource_name: str = Field(
        ..., min_length=1, description="Human-readable resource name"
    )
    mode: ValidationMode = Field(..., description="Required or informational")
    arguments: dict[str, Any] = Field(
        default_factory=dict, description="Arguments for the validation"
    )
    timeout_seconds: float = Field(
        default=30.0, ge=0.1, description="Timeout in seconds"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
    )


class ValidationResult(BaseModel):
    """Result of executing a single validation.

    Attributes:
        validation_name: Name of the executed validation.
        resource_id: ULID of the validated resource.
        resource_name: Human-readable resource name.
        mode: Whether the validation was required or informational.
        passed: Whether the validation passed.
        message: Human-readable result description.
        data: Optional structured result data.
        duration_ms: Execution duration in milliseconds.
        error: Error message if the validation raised an exception.
        timed_out: Whether the validation exceeded its timeout.
    """

    validation_name: str = Field(..., description="Name of the validation")
    resource_id: str = Field(..., description="Resource ULID")
    resource_name: str = Field(..., description="Human-readable resource name")
    mode: ValidationMode = Field(..., description="Required or informational")
    passed: bool = Field(..., description="Whether the validation passed")
    message: str = Field(..., description="Human-readable result description")
    data: dict[str, Any] | None = Field(
        default=None, description="Optional structured data"
    )
    duration_ms: float = Field(..., ge=0.0, description="Duration in milliseconds")
    error: str | None = Field(
        default=None, description="Error message if validation raised"
    )
    timed_out: bool = Field(
        default=False, description="Whether the validation timed out"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
    )


class ValidationSummary(BaseModel):
    """Aggregated summary of all validation results.

    Attributes:
        total: Total number of validations executed.
        required_passed: Count of required validations that passed.
        required_failed: Count of required validations that failed.
        informational_passed: Count of informational validations that passed.
        informational_failed: Count of informational validations that failed.
        results: Individual validation results.
    """

    total: int = Field(..., ge=0, description="Total validations executed")
    required_passed: int = Field(..., ge=0, description="Required validations passed")
    required_failed: int = Field(..., ge=0, description="Required validations failed")
    informational_passed: int = Field(
        ..., ge=0, description="Informational validations passed"
    )
    informational_failed: int = Field(
        ..., ge=0, description="Informational validations failed"
    )
    results: list[ValidationResult] = Field(
        default_factory=list, description="Individual results"
    )

    @property
    def all_required_passed(self) -> bool:
        """Return True if all required validations passed."""
        return self.required_failed == 0

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
    )


# ---------------------------------------------------------------------------
# Pipeline
# ---------------------------------------------------------------------------


def _normalise_executor_output(raw: Any) -> dict[str, Any]:
    """Normalise raw executor output to passed/message/data schema.

    Handles various error conditions:
    - Non-dict output is coerced to a failed result
    - Missing ``passed`` defaults to False
    - Missing ``message`` gets a default string
    """
    if not isinstance(raw, dict):
        return {
            "passed": False,
            "message": f"Validation returned non-dict output: {raw!r}",
            "data": None,
        }
    passed = raw.get("passed", False)
    if not isinstance(passed, bool):
        passed = False
    message = raw.get("message", "No message provided")
    if not isinstance(message, str):
        message = str(message)
    data = raw.get("data")
    if data is not None and not isinstance(data, dict):
        data = {"raw": data}
    return {"passed": passed, "message": message, "data": data}


class ValidationPipeline:
    """Orchestrates validation execution with deterministic ordering.

    The pipeline sorts validations by ``(resource_name, mode, validation_name)``
    for deterministic execution, groups them by resource for reporting, and
    enforces required vs informational semantics.

    Args:
        commands: List of validation commands to execute.
        executor: Callable that runs a single validation.
        max_workers: Maximum concurrent validation threads.
        read_only_resources: Set of resource IDs that are read-only.
    """

    def __init__(
        self,
        commands: list[ValidationCommand],
        executor: Callable[[str, dict[str, Any]], dict[str, Any]],
        max_workers: int = 4,
        read_only_resources: set[str] | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        self._commands = self._sort_commands(commands)
        self._executor = executor
        self._max_workers = max_workers
        self._read_only_resources: set[str] = read_only_resources or set()
        self._event_bus = event_bus

    @staticmethod
    def _sort_commands(
        commands: list[ValidationCommand],
    ) -> list[ValidationCommand]:
        """Sort commands by (resource_name, mode, validation_name)."""
        return sorted(
            commands,
            key=lambda c: (c.resource_name, c.mode.value, c.validation_name),
        )

    @staticmethod
    def group_by_resource(
        results: list[ValidationResult],
    ) -> dict[str, list[ValidationResult]]:
        """Group validation results by resource_name."""
        groups: dict[str, list[ValidationResult]] = defaultdict(list)
        for result in results:
            groups[result.resource_name].append(result)
        return dict(groups)

    def _execute_single(self, cmd: ValidationCommand) -> ValidationResult:
        """Execute a single validation command with timeout and output capture.

        Uses a daemon thread for timeout enforcement so that timed-out
        tasks are abandoned immediately without blocking the caller.
        Stdout/stderr capture is thread-safe via ``_ThreadLocalStream``.
        """
        # Check read-only resource guard
        if cmd.resource_id in self._read_only_resources:
            logger.warning(
                "Skipping validation '%s' on read-only resource '%s' (%s)",
                cmd.validation_name,
                cmd.resource_name,
                cmd.resource_id,
            )
            return ValidationResult(
                validation_name=cmd.validation_name,
                resource_id=cmd.resource_id,
                resource_name=cmd.resource_name,
                mode=cmd.mode,
                passed=True,
                message=(f"Skipped: resource '{cmd.resource_name}' is read-only"),
                data=None,
                duration_ms=0.0,
                error=None,
                timed_out=False,
            )

        start = time.monotonic()

        # Cross-thread communication holders
        result_holder: list[Any] = []
        error_holder: list[Exception] = []
        stdout_holder: list[str] = [""]
        stderr_holder: list[str] = [""]

        def _run_in_thread() -> None:
            # Activate per-thread capture if stream wrappers are installed
            tl_out = sys.stdout
            tl_err = sys.stderr
            capture_out = isinstance(tl_out, _ThreadLocalStream)
            capture_err = isinstance(tl_err, _ThreadLocalStream)
            if capture_out:
                tl_out.start_capture()
            if capture_err:
                tl_err.start_capture()
            try:
                raw = self._executor(cmd.validation_name, cmd.arguments)
                result_holder.append(raw)
            except Exception as exc:
                error_holder.append(exc)
            finally:
                if capture_out:
                    stdout_holder[0] = tl_out.stop_capture()
                if capture_err:
                    stderr_holder[0] = tl_err.stop_capture()

        thread = threading.Thread(target=_run_in_thread, daemon=True)
        thread.start()
        thread.join(timeout=cmd.timeout_seconds)
        elapsed_ms = (time.monotonic() - start) * 1000

        if thread.is_alive():
            # Timeout: daemon thread is abandoned (cleaned up at exit)
            return ValidationResult(
                validation_name=cmd.validation_name,
                resource_id=cmd.resource_id,
                resource_name=cmd.resource_name,
                mode=cmd.mode,
                passed=False,
                message=(f"Validation timed out after {cmd.timeout_seconds}s"),
                data=None,
                duration_ms=elapsed_ms,
                error=f"TimeoutError: exceeded {cmd.timeout_seconds}s",
                timed_out=True,
            )

        if error_holder:
            exc = error_holder[0]
            return ValidationResult(
                validation_name=cmd.validation_name,
                resource_id=cmd.resource_id,
                resource_name=cmd.resource_name,
                mode=cmd.mode,
                passed=False,
                message=f"Validation raised {type(exc).__name__}: {exc}",
                data=None,
                duration_ms=elapsed_ms,
                error=f"{type(exc).__name__}: {exc}",
                timed_out=False,
            )

        raw_result = result_holder[0] if result_holder else None
        normalised = _normalise_executor_output(raw_result)

        # Merge captured output into data
        stdout_text = stdout_holder[0]
        stderr_text = stderr_holder[0]
        result_data = normalised["data"]
        if stdout_text or stderr_text:
            if result_data is None:
                result_data = {}
            if stdout_text:
                result_data["_captured_stdout"] = stdout_text
            if stderr_text:
                result_data["_captured_stderr"] = stderr_text

        return ValidationResult(
            validation_name=cmd.validation_name,
            resource_id=cmd.resource_id,
            resource_name=cmd.resource_name,
            mode=cmd.mode,
            passed=normalised["passed"],
            message=normalised["message"],
            data=result_data if result_data else None,
            duration_ms=elapsed_ms,
            error=None,
            timed_out=False,
        )

    def _build_summary(self, results: list[ValidationResult]) -> ValidationSummary:
        """Build an aggregated summary from individual results."""
        required_passed = 0
        required_failed = 0
        informational_passed = 0
        informational_failed = 0

        for r in results:
            if r.mode == ValidationMode.REQUIRED:
                if r.passed:
                    required_passed += 1
                else:
                    required_failed += 1
            else:
                if r.passed:
                    informational_passed += 1
                else:
                    informational_failed += 1

        return ValidationSummary(
            total=len(results),
            required_passed=required_passed,
            required_failed=required_failed,
            informational_passed=informational_passed,
            informational_failed=informational_failed,
            results=results,
        )

    def run(self) -> ValidationSummary:
        """Execute all validations and return a summary.

        Validations are executed concurrently up to ``max_workers``.
        Required failures cause ``all_required_passed`` to be False.
        Informational failures are logged but do not block.

        Returns:
            ValidationSummary with aggregated counts and results.
        """
        if not self._commands:
            return ValidationSummary(
                total=0,
                required_passed=0,
                required_failed=0,
                informational_passed=0,
                informational_failed=0,
                results=[],
            )

        # Emit VALIDATION_STARTED event
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.VALIDATION_STARTED,
                        details={
                            "validation_count": len(self._commands),
                        },
                    )
                )
            except Exception:
                logger.warning("event_bus_emit_failed: VALIDATION_STARTED")

        results: list[ValidationResult] = []

        # Install thread-local stream wrappers so _execute_single can
        # capture per-thread stdout/stderr without global races.
        orig_stdout = sys.stdout
        orig_stderr = sys.stderr
        sys.stdout = _ThreadLocalStream(orig_stdout)
        sys.stderr = _ThreadLocalStream(orig_stderr)
        try:
            with ThreadPoolExecutor(max_workers=self._max_workers) as pool:
                futures_map = {
                    pool.submit(self._execute_single, cmd): cmd
                    for cmd in self._commands
                }
                for future in futures_map:
                    result = future.result()
                    results.append(result)
        finally:
            sys.stdout = orig_stdout
            sys.stderr = orig_stderr

        # Re-sort results to match command ordering (deterministic)
        results.sort(
            key=lambda r: (
                r.resource_name,
                r.mode.value,
                r.validation_name,
            )
        )

        # Log informational failures
        for r in results:
            if r.mode == ValidationMode.INFORMATIONAL and not r.passed:
                logger.info(
                    "Informational validation '%s' on resource '%s' failed: %s",
                    r.validation_name,
                    r.resource_name,
                    r.message,
                )

        summary = self._build_summary(results)

        if not summary.all_required_passed:
            logger.warning(
                "Validation pipeline: %d required validation(s) failed",
                summary.required_failed,
            )

        # Emit VALIDATION_PASSED or VALIDATION_FAILED event
        if self._event_bus is not None:
            event_type = (
                EventType.VALIDATION_PASSED
                if summary.all_required_passed
                else EventType.VALIDATION_FAILED
            )
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=event_type,
                        details={
                            "total": summary.total,
                            "required_passed": summary.required_passed,
                            "required_failed": summary.required_failed,
                            "all_required_passed": summary.all_required_passed,
                        },
                    )
                )
            except Exception:
                logger.warning("event_bus_emit_failed: %s", event_type.value)

        return summary

    def run_for_plan(
        self,
        plan_metadata: dict[str, Any] | None = None,
    ) -> ValidationSummary:
        """Execute validations and persist summary to plan metadata.

        This integrates the validation pipeline with the plan lifecycle
        by storing the validation summary in the plan's metadata dict.

        Args:
            plan_metadata: Mutable dict for plan metadata. If provided,
                ``validation_summary`` key will be updated with summary data.

        Returns:
            ValidationSummary with aggregated counts and results.
        """
        summary = self.run()

        if plan_metadata is not None:
            plan_metadata["validation_summary"] = {
                "total": summary.total,
                "required_passed": summary.required_passed,
                "required_failed": summary.required_failed,
                "informational_passed": summary.informational_passed,
                "informational_failed": summary.informational_failed,
                "all_required_passed": summary.all_required_passed,
                "results": [
                    {
                        "validation_name": r.validation_name,
                        "resource_id": r.resource_id,
                        "resource_name": r.resource_name,
                        "mode": r.mode.value,
                        "passed": r.passed,
                        "message": r.message,
                        "data": r.data,
                        "duration_ms": r.duration_ms,
                        "error": r.error,
                        "timed_out": r.timed_out,
                    }
                    for r in summary.results
                ],
            }

        return summary
