# Copyright (C) 2026 GoodData Corporation

"""CLI handlers for local validation streaming experiments."""

from __future__ import annotations

import argparse
import asyncio
import os
import re
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, cast

import orjson

from aida_mcp.output.pytest_stream import PytestStreamFilter, StreamDecision
from aida_mcp.server.validation_scope import resolve_validation_scope
from aida_mcp.tools.validate.engine import (
    NO_MATCHING_VALIDATION_ROUTES_TEXT,
    ResolvedPlannedStep,
    build_resolved_plan,
)
from aida_mcp.utils.artifacts import get_artifacts_root
from aida_mcp.utils.command_runner_logs import write_stream_log_line
from aida_mcp.utils.stream_reader import read_stream_chunked
from aida_mcp.utils.workspace import ENV_WORKSPACE_ROOT

StdoutMode = Literal["important", "errors"]
PytestOutputMode = Literal["plain", "default"]
ProcessorId = Literal["auto", "pytest", "external_json", "gradle", "typescript_rush_tail", "passthrough"]
_TAIL_CAPTURE_LINES = 120
_TAIL_CAPTURE_CHARS = 8_000
_STREAM_LINE_MAX_BYTES = 20_000
_GENERIC_ERROR_RE = re.compile(r"\b(failed|error|exception|traceback)\b", re.IGNORECASE)
_GRADLE_TASK_RE = re.compile(r"^> Task (?P<task>\S+)")
_RUSH_STEP_RE = re.compile(r"^(Starting|Invoking|Executing|Testing|Building)\b", re.IGNORECASE)


@dataclass(frozen=True, slots=True)
class OutputFiles:
    full_path: Path
    important_path: Path


@dataclass(frozen=True, slots=True)
class ExternalJsonSummary:
    success: bool
    text: str


@dataclass(slots=True)
class GradleStreamFilter:
    def classify(self, line: str) -> StreamDecision:
        stripped = line.strip()
        if not stripped:
            return StreamDecision(important=False, error=False)
        low = stripped.lower()
        if _GRADLE_TASK_RE.match(stripped):
            return StreamDecision(important=True, error=("failed" in low))
        if "build successful" in low or "build failed" in low:
            return StreamDecision(important=True, error=("failed" in low))
        if " test " in f" {low} " and ("passed" in low or "failed" in low):
            return StreamDecision(important=True, error=("failed" in low))
        if _GENERIC_ERROR_RE.search(stripped):
            return StreamDecision(important=True, error=True)
        return StreamDecision(important=False, error=False)


@dataclass(slots=True)
class TypeScriptRushStreamFilter:
    def classify(self, line: str) -> StreamDecision:
        stripped = line.strip()
        if not stripped:
            return StreamDecision(important=False, error=False)
        low = stripped.lower()
        if _RUSH_STEP_RE.match(stripped):
            return StreamDecision(important=True, error=False)
        if low.startswith(("==[", "[rush]")):
            return StreamDecision(important=True, error=("failed" in low or "error" in low))
        if "project(s) succeeded" in low or "project(s) failed" in low:
            return StreamDecision(important=True, error=("failed" in low))
        if _GENERIC_ERROR_RE.search(stripped):
            return StreamDecision(important=True, error=True)
        return StreamDecision(important=False, error=False)


@dataclass(slots=True)
class PassthroughStreamFilter:
    def classify(self, line: str) -> StreamDecision:
        stripped = line.strip()
        if not stripped:
            return StreamDecision(important=False, error=False)
        return StreamDecision(important=True, error=(bool(_GENERIC_ERROR_RE.search(stripped))))


@dataclass(slots=True)
class FilteredOutputWriter:
    mode: StdoutMode
    processor_id: ProcessorId
    command_id: str
    argv: list[str]
    stdout_max_lines: int
    stdout_max_chars: int
    files: OutputFiles
    important_handle: Any
    stdout_lines: int = 0
    stdout_chars: int = 0
    stdout_capped: bool = False
    important_lines: int = 0
    emitted_lines: set[str] = field(default_factory=set)
    raw_filter: (
        PytestStreamFilter | GradleStreamFilter | TypeScriptRushStreamFilter | PassthroughStreamFilter | None
    ) = field(init=False, default=None)

    def __post_init__(self) -> None:
        self.raw_filter = _make_stream_filter(self.processor_id, command_id=self.command_id, argv=self.argv)

    async def on_stdout_line(self, line: str) -> None:
        await self._handle_line(line=line, stream_name="stdout")

    async def on_stderr_line(self, line: str) -> None:
        await self._handle_line(line=line, stream_name="stderr")

    async def _handle_line(self, *, line: str, stream_name: str) -> None:
        if _extract_external_json_summary_from_line(line) is not None:
            return
        if stream_name == "stderr":
            self._emit_filtered_line(line, error=True, stream_name=stream_name)
            return
        if self.raw_filter is None:
            return
        decision = self.raw_filter.classify(line)
        if not decision.important:
            return
        self._emit_filtered_line(line, error=decision.error, stream_name=stream_name)

    def emit_summary(self, summary: ExternalJsonSummary) -> None:
        self._emit_filtered_line(summary.text, error=(not summary.success), stream_name="summary")

    def _write_important(self, *, stream_name: str, line: str) -> None:
        write_stream_log_line(self.important_handle, stream_name, line)
        self.important_lines += 1

    def _should_emit_to_stdout(self, decision: StreamDecision) -> bool:
        return decision.error if self.mode == "errors" else decision.important

    def _emit_filtered_line(self, line: str, *, error: bool, stream_name: str = "filtered") -> None:
        cleaned = line.strip()
        if not cleaned:
            return
        self._write_important(stream_name=stream_name, line=cleaned)
        if self._should_emit_to_stdout(StreamDecision(important=True, error=error)):
            self._emit_stdout(cleaned)

    def _emit_stdout(self, line: str) -> None:
        if self.stdout_capped:
            return
        next_lines = self.stdout_lines + 1
        next_chars = self.stdout_chars + len(line) + 1
        if next_lines > self.stdout_max_lines or next_chars > self.stdout_max_chars:
            self.stdout_capped = True
            return
        print(line, flush=True)
        self.stdout_lines = next_lines
        self.stdout_chars = next_chars
        self.emitted_lines.add(line)

    def emit_fallback_excerpt(self, lines: list[str]) -> None:
        for line in lines:
            cleaned = line.strip()
            if not cleaned or cleaned in self.emitted_lines:
                continue
            self._write_important(stream_name="fallback", line=cleaned)
            self._emit_stdout(cleaned)

    def print_final_status(self, *, exit_code: int) -> None:
        if self.stdout_capped and exit_code != 0:
            self._print_overflow_notice()
        status = "succeeded" if exit_code == 0 else f"failed (exit code {exit_code})"
        print(f"[aida-mcp][validate] Validation {status}.", flush=True)

    def _print_overflow_notice(self) -> None:
        print(
            (
                "[aida-mcp][validate] Filtered output exceeded the configured stdout cap. "
                "Streaming has been reduced to file links only.\n"
                f"Analyze this filtered log first: {self.files.important_path.as_uri()}\n"
                f"If needed, inspect the full raw log: {self.files.full_path.as_uri()}\n"
                "Start with the filtered log. Only open the full raw log if the filtered log does not reveal the root cause."
            ),
            flush=True,
        )


def validate_python_cmd(args: argparse.Namespace) -> None:
    exit_code = asyncio.run(_validate_python_async(args))
    if exit_code != 0:
        raise SystemExit(exit_code)


async def _validate_python_async(args: argparse.Namespace) -> int:
    workspace_root = _resolve_workspace_root(args.workspace_root)
    os.environ[ENV_WORKSPACE_ROOT] = str(workspace_root)
    try:
        selection = resolve_validation_scope(
            workspace_root=workspace_root,
            scope=args.scope,
            focus_paths=args.focus_paths,
            upstream_ref=args.upstream_ref,
            head_ref=args.head_ref,
        )
    except ValueError as exc:
        print(f"[aida-mcp][validate] {exc}", flush=True)
        print("[aida-mcp][validate] Validation failed (exit code 2).", flush=True)
        return 2

    matched_codegen_trigger_ids, steps = build_resolved_plan(
        workspace_root=workspace_root,
        scope=args.scope,
        changed_paths=selection.changed_paths,
    )
    if args.explain:
        print(
            f"[aida-mcp][validate] Scope source: {selection.scope_source}; changed paths: {len(selection.changed_paths)}",
            flush=True,
        )
        if matched_codegen_trigger_ids:
            print(
                f"[aida-mcp][validate] Matched codegen triggers: {', '.join(matched_codegen_trigger_ids)}",
                flush=True,
            )

    if not steps:
        print(f"[aida-mcp][validate] {NO_MATCHING_VALIDATION_ROUTES_TEXT}", flush=True)
        exit_code = 2 if selection.scope_source == "focus_paths" else 0
        status = "succeeded" if exit_code == 0 else f"failed (exit code {exit_code})"
        print(f"[aida-mcp][validate] Validation {status}.", flush=True)
        return exit_code

    if args.dry_run:
        print("[aida-mcp][validate] Dry-run: planned validation steps only.", flush=True)
        for idx, step in enumerate(steps, start=1):
            print(
                f"[aida-mcp][validate] {idx}/{len(steps)} {step.command_id}: {' '.join(step.command.argv)}",
                flush=True,
            )
        return 0

    overall_exit = 0
    for idx, step in enumerate(steps, start=1):
        if args.explain:
            print(
                (
                    f"[aida-mcp][validate] Step {idx}/{len(steps)} "
                    f"domain={step.domain} root={step.root} pipeline={step.pipeline_id} "
                    f"command={step.command_id} processor={step.processor_id}"
                ),
                flush=True,
            )
        exit_code = await _run_step(
            args=args,
            step=step,
            step_idx=idx,
            total_steps=len(steps),
        )
        if exit_code != 0:
            overall_exit = exit_code
            break

    status = "succeeded" if overall_exit == 0 else f"failed (exit code {overall_exit})"
    print(f"[aida-mcp][validate] Overall validation {status}.", flush=True)
    return overall_exit


async def _run_step(*, args: argparse.Namespace, step: ResolvedPlannedStep, step_idx: int, total_steps: int) -> int:
    if not step.command.found:
        print(
            f"[aida-mcp][validate] Step {step_idx}/{total_steps} {step.command_id} missing: {step.command.error}",
            flush=True,
        )
        return 127

    cmd = list(step.command.argv)
    cwd = step.command.cwd
    env = _build_command_env(base_env=step.command.env, pytest_output=args.pytest_output)
    effective_timeout = (
        args.timeout_sec if isinstance(args.timeout_sec, int) and args.timeout_sec > 0 else step.command.timeout_sec
    )
    effective_timeout = effective_timeout if effective_timeout > 0 else 3600

    files, full_handle, important_handle = _open_output_files(
        command_id=step.command_id,
        cwd=cwd,
        cmd=cmd,
        mode=args.mode,
    )
    writer = FilteredOutputWriter(
        mode=args.mode,
        processor_id=_coerce_processor_id(step.processor_id),
        command_id=step.command_id,
        argv=cmd,
        stdout_max_lines=args.stdout_max_lines,
        stdout_max_chars=args.stdout_max_chars,
        files=files,
        important_handle=important_handle,
    )
    stdout_tail: deque[str] = deque()
    stderr_tail: deque[str] = deque()
    process: asyncio.subprocess.Process | None = None
    exit_code = 1

    try:
        process = await asyncio.create_subprocess_exec(
            *cmd,
            cwd=str(cwd),
            env=env,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        await asyncio.wait_for(
            asyncio.gather(
                read_stream_chunked(
                    process.stdout,
                    stream_name="stdout",
                    log_handle=full_handle,
                    write_log_line=write_stream_log_line,
                    on_line=writer.on_stdout_line,
                    max_capture_lines=_TAIL_CAPTURE_LINES,
                    max_capture_chars=_TAIL_CAPTURE_CHARS,
                    max_line_bytes=_STREAM_LINE_MAX_BYTES,
                    sink=stdout_tail,
                    chars_counter=0,
                ),
                read_stream_chunked(
                    process.stderr,
                    stream_name="stderr",
                    log_handle=full_handle,
                    write_log_line=write_stream_log_line,
                    on_line=writer.on_stderr_line,
                    max_capture_lines=_TAIL_CAPTURE_LINES,
                    max_capture_chars=_TAIL_CAPTURE_CHARS,
                    max_line_bytes=_STREAM_LINE_MAX_BYTES,
                    sink=stderr_tail,
                    chars_counter=0,
                ),
                process.wait(),
            ),
            timeout=effective_timeout,
        )
        exit_code = process.returncode or 0
    except TimeoutError:
        if process is not None:
            with suppress(ProcessLookupError):
                process.kill()
            with suppress(Exception):
                await process.wait()
        timeout_line = f"Validation command timed out after {effective_timeout} seconds"
        writer.emit_fallback_excerpt([timeout_line])
        exit_code = 124

    summary = _extract_external_json_summary(stdout_tail)
    if summary is not None:
        writer.emit_summary(summary)

    if exit_code != 0 and writer.important_lines == 0:
        writer.emit_fallback_excerpt(_build_fallback_excerpt(stderr_tail=stderr_tail, stdout_tail=stdout_tail))

    _finalize_log(full_handle, exit_code)
    _finalize_log(important_handle, exit_code)
    writer.print_final_status(exit_code=exit_code)
    return exit_code


def _resolve_workspace_root(workspace_root_arg: str | None) -> Path:
    root = Path(workspace_root_arg).expanduser() if workspace_root_arg else Path.cwd()
    return root.resolve()


def _build_command_env(*, base_env: dict[str, str], pytest_output: PytestOutputMode) -> dict[str, str]:
    env = os.environ.copy()
    env.update(base_env)
    env.setdefault("PYTHONUNBUFFERED", "1")
    if pytest_output == "plain":
        env["NO_COLOR"] = "1"
        env["CLICOLOR"] = "0"
        env["CLICOLOR_FORCE"] = "0"
        env["FORCE_COLOR"] = "0"
        env["TERM"] = "dumb"
        env["PYTEST_ADDOPTS"] = _merge_pytest_addopts(
            env.get("PYTEST_ADDOPTS"),
            ["--color=no", "-o", "console_output_style=classic", "-vv"],
        )
    return env


def _merge_pytest_addopts(existing: str | None, additions: list[str]) -> str:
    current = existing.strip() if isinstance(existing, str) else ""
    merged = [current] if current else []
    merged.extend(additions)
    return " ".join(part for part in merged if part).strip()


def _extract_external_json_summary_from_line(line: str) -> ExternalJsonSummary | None:
    stripped = line.strip()
    if not stripped:
        return None
    try:
        payload = orjson.loads(stripped.encode("utf-8"))
    except Exception:
        return None
    if not isinstance(payload, dict):
        return None
    success = payload.get("success")
    text = payload.get("text")
    if isinstance(success, bool) and isinstance(text, str) and text.strip():
        return ExternalJsonSummary(success=success, text=text.strip())
    return None


def _extract_external_json_summary(stdout_tail: deque[str]) -> ExternalJsonSummary | None:
    for line in reversed(stdout_tail):
        summary = _extract_external_json_summary_from_line(line)
        if summary is not None:
            return summary
    return None


def _make_stream_filter(
    processor_id: ProcessorId, *, command_id: str, argv: list[str]
) -> PytestStreamFilter | GradleStreamFilter | TypeScriptRushStreamFilter | PassthroughStreamFilter | None:
    if processor_id == "pytest":
        return PytestStreamFilter()
    if processor_id == "gradle":
        return GradleStreamFilter()
    if processor_id == "typescript_rush_tail":
        return TypeScriptRushStreamFilter()
    if processor_id == "passthrough":
        return PassthroughStreamFilter()
    if processor_id == "external_json":
        return PassthroughStreamFilter()

    joined = " ".join([command_id, *argv]).lower()
    if "pytest" in joined or "python" in joined:
        return PytestStreamFilter()
    if "gradle" in joined or "gradlew" in joined or "kotlin" in joined:
        return GradleStreamFilter()
    if "rush" in joined or "typescript" in joined or "pnpm" in joined or "npm" in joined:
        return TypeScriptRushStreamFilter()
    return PassthroughStreamFilter()


def _coerce_processor_id(raw: str) -> ProcessorId:
    if raw in {"auto", "pytest", "external_json", "gradle", "typescript_rush_tail", "passthrough"}:
        return cast(ProcessorId, raw)
    return "auto"


def _open_output_files(*, command_id: str, cwd: Path, cmd: list[str], mode: StdoutMode) -> tuple[OutputFiles, Any, Any]:
    now = datetime.now(UTC)
    stamp = now.strftime("%Y%m%d-%H%M%S-%f")
    root = get_artifacts_root() / "validate" / now.strftime("%Y%m%d")
    root.mkdir(parents=True, exist_ok=True)
    base_name = _slugify(command_id)
    full_path = root / f"{stamp}-{base_name}-full.log"
    important_path = root / f"{stamp}-{base_name}-important.log"
    full_handle = full_path.open("w", encoding="utf-8")
    important_handle = important_path.open("w", encoding="utf-8")
    _write_header(full_handle, now=now, label="full", cwd=cwd, cmd=cmd, mode=mode)
    _write_header(important_handle, now=now, label="important", cwd=cwd, cmd=cmd, mode=mode)
    return OutputFiles(full_path=full_path, important_path=important_path), full_handle, important_handle


def _write_header(handle: Any, *, now: datetime, label: str, cwd: Path, cmd: list[str], mode: StdoutMode) -> None:
    handle.write(f"Timestamp: {now.isoformat()}\n")
    handle.write(f"Log Type: {label}\n")
    handle.write(f"Mode: {mode}\n")
    handle.write(f"Command: {' '.join(cmd)}\n")
    handle.write(f"CWD: {cwd}\n")
    handle.write("Exit Code: <pending>\n\n")
    handle.flush()


def _finalize_log(handle: Any, exit_code: int) -> None:
    try:
        handle.write(f"\nExit Code: {exit_code}\n")
        handle.flush()
        handle.close()
    except OSError:
        return


def _build_fallback_excerpt(*, stderr_tail: deque[str], stdout_tail: deque[str]) -> list[str]:
    excerpt: list[str] = []
    for line in list(stderr_tail)[-20:]:
        if line.strip():
            excerpt.append(line.strip())
    for line in list(stdout_tail)[-20:]:
        if line.strip():
            excerpt.append(line.strip())
    return excerpt[:20]


def _slugify(value: str) -> str:
    chars = [ch if ch.isalnum() or ch in "._-" else "-" for ch in value.strip()]
    slug = "".join(chars).strip("-")
    return slug or "validate"
