# Copyright (C) 2026 GoodData Corporation

"""Unified validate engine (incremental).

This module wires together:
- scope computation (handled by MCP tool entrypoint)
- change-domain classification (change_classifier)
- routing via `.aida/validation_policy.yaml`
- execution via registry command specs
- output normalization via processors
"""

from __future__ import annotations

import logging
import os
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING

from aida_mcp.output.policy import MAX_PROGRESS_MESSAGE_CHARS
from aida_mcp.tools.core.change_classifier import classify_paths
from aida_mcp.tools.core.validation_registry import ProcessorSpec, get_validation_registry
from aida_mcp.tools.validate.command_runner import resolve_registry_command, run_resolved_command
from aida_mcp.tools.validate.models import ResolvedCommandSpec, StepResult
from aida_mcp.tools.validate.policy_config import ValidationPolicyConfig, load_validation_policy
from aida_mcp.tools.validate.processors.runner import run_processor
from aida_mcp.utils.git import list_changed_files

if TYPE_CHECKING:
    from typing import Any

    from mcp.server.fastmcp import Context as MCPContext

    Context = MCPContext[Any, Any, Any]

logger = logging.getLogger(__name__)

# Public constants used by MCP tool wrappers.
NO_MATCHING_VALIDATION_ROUTES_TEXT = "No matching validation routes for changed paths."


def _truncate_progress_message(message: str) -> str:
    max_chars = MAX_PROGRESS_MESSAGE_CHARS
    if max_chars <= 0:
        return ""
    if len(message) <= max_chars:
        return message
    if max_chars <= 3:
        return message[:max_chars]
    return message[: max_chars - 3] + "..."


@dataclass(frozen=True, slots=True)
class PlannedStep:
    domain: str
    root: str
    pipeline_id: str
    command_id: str
    processor_id: str
    processor_config: dict[str, object]


@dataclass(frozen=True, slots=True)
class ResolvedPlannedStep:
    domain: str
    root: str
    pipeline_id: str
    command_id: str
    processor_id: str
    processor_config: dict[str, object]
    command: ResolvedCommandSpec


def _resolve_pipeline_for_domain(cfg: ValidationPolicyConfig, domain: str) -> str | None:
    for r in cfg.routes:
        if r.domain == domain:
            return r.pipeline
    return None


def _path_matches_any_glob(path: str, globs: list[str]) -> bool:
    p = PurePosixPath(path)
    return any(p.match(g) for g in globs)


def _build_codegen_plan(
    *, cfg: ValidationPolicyConfig, scope: str, changed_paths: list[str]
) -> tuple[list[str], list[PlannedStep]]:
    """Return (matched_trigger_ids, planned_steps)."""
    matched_trigger_ids: list[str] = []
    planned: list[PlannedStep] = []

    for t in cfg.codegen:
        if scope not in t.scopes:
            continue
        if not any(_path_matches_any_glob(p, t.match_globs) for p in changed_paths):
            continue
        matched_trigger_ids.append(t.id)
        pipeline = cfg.pipelines.get(t.pipeline)
        if pipeline is None:
            logger.warning("codegen trigger %s references missing pipeline %s", t.id, t.pipeline)
            continue
        for step in pipeline.steps:
            planned.append(
                PlannedStep(
                    domain=f"codegen:{t.id}",
                    root=".",
                    pipeline_id=pipeline.id,
                    command_id=step.command_id,
                    processor_id=step.processor_id,
                    processor_config=step.processor_config,
                )
            )
    return matched_trigger_ids, planned


def build_plan(
    *, workspace_root: Path, scope: str, changed_paths: list[str]
) -> tuple[ValidationPolicyConfig, list[PlannedStep]]:
    cfg = load_validation_policy(workspace_root)
    matches = classify_paths(changed_paths)

    planned: list[PlannedStep] = []
    for m in matches:
        pipeline_id = _resolve_pipeline_for_domain(cfg, m.domain)
        if pipeline_id is None:
            continue
        pipeline = cfg.pipelines.get(pipeline_id)
        if pipeline is None:
            continue
        for step in pipeline.steps:
            planned.append(
                PlannedStep(
                    domain=m.domain,
                    root=m.root,
                    pipeline_id=pipeline_id,
                    command_id=step.command_id,
                    processor_id=step.processor_id,
                    processor_config=step.processor_config,
                )
            )
    return cfg, planned


def build_resolved_plan(
    *,
    workspace_root: Path,
    scope: str,
    changed_paths: list[str],
) -> tuple[list[str], list[ResolvedPlannedStep]]:
    """Build ordered validation steps and resolve concrete command specs."""
    os.environ["AIDA_WORKSPACE_ROOT"] = str(workspace_root)
    cfg, planned_validations = build_plan(workspace_root=workspace_root, scope=scope, changed_paths=changed_paths)
    matched_codegen_trigger_ids, planned_codegen = _build_codegen_plan(
        cfg=cfg,
        scope=scope,
        changed_paths=changed_paths,
    )
    planned = [*planned_codegen, *planned_validations]
    resolved: list[ResolvedPlannedStep] = []
    for step in planned:
        values = {
            "workspace_root": str(workspace_root),
            "domain": step.domain,
            "root": step.root,
            "target_root": step.root or ".",
        }
        command = resolve_registry_command(
            command_id=step.command_id,
            values=values,
            workspace_root=workspace_root,
        )
        resolved.append(
            ResolvedPlannedStep(
                domain=step.domain,
                root=step.root,
                pipeline_id=step.pipeline_id,
                command_id=step.command_id,
                processor_id=step.processor_id,
                processor_config=step.processor_config,
                command=command,
            )
        )
    return matched_codegen_trigger_ids, resolved


def build_resolved_plan_payload(
    *,
    workspace_root: Path,
    scope: str,
    changed_paths: list[str],
    include_explain: bool,
) -> dict[str, object]:
    matched_codegen_trigger_ids, resolved_steps = build_resolved_plan(
        workspace_root=workspace_root,
        scope=scope,
        changed_paths=changed_paths,
    )
    commands = [
        {
            "domain": step.domain,
            "root": step.root,
            "pipeline_id": step.pipeline_id,
            "command_id": step.command_id,
            "processor_id": step.processor_id,
            "command": step.command.to_dict(),
        }
        for step in resolved_steps
    ]
    payload: dict[str, object] = {
        "tool": "validate_command",
        "success": all(step.command.found for step in resolved_steps),
        "text": ("Planned validation commands." if resolved_steps else NO_MATCHING_VALIDATION_ROUTES_TEXT),
        "scope": scope,
        "changed_paths": changed_paths,
        "commands": commands,
    }
    if include_explain:
        payload["plan"] = [
            {
                "domain": step.domain,
                "root": step.root,
                "pipeline_id": step.pipeline_id,
                "command_id": step.command_id,
                "processor_id": step.processor_id,
            }
            for step in resolved_steps
        ]
        payload["matched_codegen_triggers"] = matched_codegen_trigger_ids
    return payload


def _resolve_processor_spec(processor_id: str) -> ProcessorSpec | None:
    # Convenience: allow policy to refer to builtin processors directly.
    if processor_id.startswith("builtin:"):
        builtin_id = processor_id.removeprefix("builtin:").strip()
        return ProcessorSpec(kind="builtin", builtin_id=builtin_id)
    if processor_id == "passthrough":
        return ProcessorSpec(kind="builtin", builtin_id="passthrough")
    # Also allow referencing builtin IDs without the "builtin:" prefix.
    # This keeps repo-owned policies concise.
    from aida_mcp.tools.validate.processors.builtin import BUILTIN_PROCESSORS

    if processor_id in BUILTIN_PROCESSORS:
        return ProcessorSpec(kind="builtin", builtin_id=processor_id)

    registry = get_validation_registry()
    spec = registry.processors.get(processor_id)
    return spec if isinstance(spec, ProcessorSpec) else None


async def execute_plan(
    *,
    workspace_root: Path,
    scope: str,
    changed_paths: list[str],
    dry_run: bool,
    explain: bool,
    ctx: Context | None,
) -> dict[str, object]:
    # Ensure repo-owned config loaders (which are intentionally context-free) can resolve
    # the correct workspace root for this request.
    os.environ["AIDA_WORKSPACE_ROOT"] = str(workspace_root)

    matched_codegen_trigger_ids, resolved_steps = build_resolved_plan(
        workspace_root=workspace_root,
        scope=scope,
        changed_paths=changed_paths,
    )
    plan_payload: list[dict[str, object]] = [
        {
            "domain": p.domain,
            "root": p.root,
            "pipeline_id": p.pipeline_id,
            "command_id": p.command_id,
            "processor_id": p.processor_id,
        }
        for p in resolved_steps
    ]

    if dry_run:
        return {
            "tool": "validate",
            "success": True,
            "text": "Dry-run: planned validation steps only.",
            "scope": scope,
            "changed_paths": changed_paths,
            "plan": plan_payload,
            "matched_codegen_triggers": matched_codegen_trigger_ids if explain else [],
        }

    if not resolved_steps:
        return {
            "tool": "validate",
            "success": True,
            "text": NO_MATCHING_VALIDATION_ROUTES_TEXT,
            "scope": scope,
            "changed_paths": changed_paths,
            "plan": plan_payload if explain else [],
            "matched_codegen_triggers": matched_codegen_trigger_ids if explain else [],
        }

    steps_out: list[dict[str, object]] = []
    ok = True
    did_autofix = False
    modified_files: set[str] = set()

    total_steps = len(resolved_steps)
    for step_idx, p in enumerate(resolved_steps, start=1):
        if ctx:
            with_msg = _truncate_progress_message(f"validate: {p.domain} {p.root} -> {p.command_id}")
            await ctx.info(f"▶️ {with_msg}")
            # Step-level progress is always reported by the engine, even if the command emits no events.
            with suppress(Exception):
                await ctx.report_progress(step_idx, total_steps, with_msg)

        before = set(list_changed_files(workspace_root, include_untracked=True))
        execution = await run_resolved_command(
            resolved=p.command,
            ctx=ctx,
            current_step=step_idx,
            total_steps=total_steps,
        )
        after = set(list_changed_files(workspace_root, include_untracked=True))
        step_modified = sorted(after - before)

        spec = _resolve_processor_spec(p.processor_id)
        if spec is None:
            result = StepResult(success=False, text=f"Unknown processor_id: {p.processor_id}", error_count=1)
        else:
            result = await run_processor(spec, execution=execution, processor_config=p.processor_config)
        if step_modified:
            did_autofix = True
            modified_files.update(step_modified)
            result = StepResult(
                success=result.success,
                text=result.text,
                error_count=result.error_count,
                log_paths=result.log_paths,
                meta=result.meta,
                did_autofix=True,
                modified_files=step_modified,
            )

        ok = ok and result.success
        steps_out.append(
            {
                "domain": p.domain,
                "root": p.root,
                "pipeline_id": p.pipeline_id,
                "command_id": p.command_id,
                "processor_id": p.processor_id,
                "execution": execution.to_dict(),
                "result": result.to_dict(),
            }
        )
        # If codegen fails, stop early to avoid validating against stale/generated sources.
        if p.domain.startswith("codegen:") and not result.success:
            break

    text = "✅ validate succeeded" if ok else "❌ validate failed"
    payload: dict[str, object] = {
        "tool": "validate",
        "success": ok,
        "text": text,
        "scope": scope,
        "changed_paths": changed_paths,
        "steps": steps_out,
        "did_autofix": did_autofix,
        "modified_files": sorted(modified_files),
    }
    if explain:
        payload["plan"] = plan_payload
        payload["matched_codegen_triggers"] = matched_codegen_trigger_ids
    return payload
