"""Deterministic lint, typecheck, build, and test blocks.

A known command is not a judgement call. Anything whose invocation you can write
down belongs here as code — it runs in milliseconds, costs nothing, and returns
the same answer every time. Agents are for the parts that need reading and
deciding.

╔══════════════════════════════════════════════════════════════════════════════╗
║  THE COMMANDS LIVE IN CONFIG, NOT HERE.                                      ║
║                                                                              ║
║  The factory runs against any repo (`factory --repo <path>`), so no command   ║
║  can be hard-coded. Each target repo declares its own under `quality:` in the ║
║  config this run resolved — `<repo>/.factory/sssf.config.yaml` when present,  ║
║  else the overdeck default:                                                  ║
║                                                                              ║
║      quality:                                                                ║
║        test:                                                                 ║
║          argv: ["pnpm", "run", "test"]                                       ║
║          timeout_seconds: 1800                                               ║
║        typecheck:                                                            ║
║          argv: ["pnpm", "run", "typecheck"]                                  ║
║          operation: typecheck                                                ║
║                                                                              ║
║  FAIL-CLOSED: a check that is not declared FAILS. It is never skipped and     ║
║  never passes. A quality phase with no checks at all fails too — a gate that  ║
║  verified nothing must not report green.                                     ║
║                                                                              ║
║  Two rules when you write a command:                                         ║
║    1. argv LIST, never a shell string — no quoting bugs, no shell injection.  ║
║    2. Call binaries by BARE NAME. These blocks inherit the operator's         ║
║       environment (see utils.operator_env), so `bun`, `uv`, `pytest` resolve  ║
║       exactly as they do in their terminal. Never hard-code an absolute path  ║
║       like /Users/you/.bun/bin/bun — that bakes your machine into the trace.  ║
╚══════════════════════════════════════════════════════════════════════════════╝
"""

from __future__ import annotations

import shlex
import subprocess
import time
from pathlib import Path

from .control import read_proc_start_ticks
from .data_types import (EventRecord, GateReport, QualityCheckResult, QualityCheckSpec,
                         QualityResult, VerifyOutput)
from .utils import now_iso, operator_env

# How much of a failing command's output rides back inside the envelope. Enough
# for a builder to act on without opening the artifact; bounded so a runaway
# stack trace can't swamp the next agent's context.
TAIL_CHARS = 4_000
TAIL_TRUNCATION_MARKER = "… [command.log truncated to last 4000 characters]\n"

# Checks run in this order when the config declares them; anything else the
# config names runs after, alphabetically.
CHECK_ORDER = ("test", "lint", "typecheck", "build")

# Exit code reported for a check the config never declared. Nothing ran, so
# there is no real returncode to report; 78 is EX_CONFIG.
EX_CONFIG = 78


def _check_dir(run, name: str) -> Path:
    seq = run.phases[-1].seq if run.phases else 0
    path = (run.context_handoff_dir / "quality" / f"{seq:02d}_{name}").resolve()
    path.mkdir(parents=True, exist_ok=True)
    return path


def _command_log_tail(path: Path) -> str:
    text = path.read_text()
    if len(text) <= TAIL_CHARS:
        return text
    return TAIL_TRUNCATION_MARKER + text[-TAIL_CHARS:]


def _record_gate(run, result: QualityCheckResult) -> None:
    phase = run.phases[-1]
    command_log = str(Path(result.output_artifact).resolve())
    command = result.command or None
    duration_ms = (None if command is None
                   else round(result.duration_seconds * 1000))
    note = f"exit {result.returncode}"
    if duration_ms is not None:
        note += f"; {duration_ms}ms"
    note += f"; {command_log}"
    check_evidence = {
        "item": result.command or result.name,
        "ok": result.passed,
        "note": note,
        "command": command,
        "exit_code": result.returncode,
        "duration_ms": duration_ms,
        "command_log": command_log,
    }
    report = GateReport().check(
        check_evidence["item"], result.passed, check_evidence["note"]
    )
    violations = [] if result.passed else [_command_log_tail(Path(command_log))]
    run.tracer.gate_row(
        phase, f"quality:{result.name}", report, 1,
        violations=violations, checks=[check_evidence],
    )


def _record_phase_failure(run, result: QualityResult) -> None:
    if result.passed:
        return
    failed = ", ".join(
        f"{check.name} (exit {check.returncode})"
        for check in result.checks if not check.passed
    )
    run.fail_active_phase(
        f"quality checks failed: {failed}" if failed else result.failures[0]
    )


def _run(spec: QualityCheckSpec, run) -> QualityCheckResult:
    phase = run.phases[-1]
    output_dir = _check_dir(run, spec.name)
    output_artifact = output_dir / "command.log"
    command = shlex.join(spec.argv)
    env = operator_env()             # the engineer's own shell environment

    run.console.note(f"quality {spec.name}: {command}")
    started_at = now_iso()
    clock = time.monotonic()
    stdout = ""
    stderr = ""
    process = None
    run.tracer.event(EventRecord(
        adw_id=run.adw_id, phase_id=phase.phase_id, type="tool_call_start",
        name=f"quality:{spec.name}", payload={"command": command,
                                               "output_artifact": str(output_artifact)},
        started_at=started_at,
    ))
    try:
        process = subprocess.Popen(spec.argv, cwd=run.repo_root, env=env,
                                   stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                   text=True, start_new_session=True)
        run.tracer.process_start(run.adw_id, "quality", spec.name,
                                 process.pid, command,
                                 start_ticks=read_proc_start_ticks(process.pid))
        stdout, stderr = process.communicate(timeout=spec.timeout_seconds)
        returncode = process.returncode
    except subprocess.TimeoutExpired as error:
        assert process is not None
        process.kill()
        stdout, stderr = process.communicate()
        returncode = 124
        stderr += f"\nTimed out after {spec.timeout_seconds}s."
    except OSError as error:
        # A missing binary lands here as exit 127 with the real message — no
        # pre-flight probe needed, and none wanted.
        returncode = 127
        stderr = str(error)
    finally:
        if process is not None:
            run.tracer.process_end(run.adw_id, process.pid)

    duration = time.monotonic() - clock
    output_artifact.write_text(
        f"$ {command}\nexit: {returncode}\nduration_seconds: {duration:.3f}\n"
        f"\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}\n"
    )
    passed = returncode == 0
    run.tracer.event(EventRecord(
        adw_id=run.adw_id,
        phase_id=phase.phase_id,
        type="tool_call",
        name=f"quality:{spec.name}",
        payload={
            "area": spec.area,
            "operation": spec.operation,
            "command": command,
            "returncode": returncode,
            "passed": passed,
            "output_artifact": str(output_artifact),
        },
        started_at=started_at,
        ended_at=now_iso(),
    ))
    run.console.note(
        f"quality {spec.name}: {'passed' if passed else 'failed'} "
        f"(exit {returncode}, {duration:.1f}s)"
    )
    return QualityCheckResult(
        name=spec.name,
        area=spec.area,
        operation=spec.operation,
        command=command,
        returncode=returncode,
        passed=passed,
        duration_seconds=duration,
        output_artifact=str(output_artifact),
        output_tail=(stdout + stderr)[-TAIL_CHARS:],
    )


# ── Blocks ────────────────────────────────────────────────────────────────────
# Every command comes from the run's config. See the banner at the top of this file.

def _unconfigured(run, name: str) -> QualityCheckResult:
    """A check the config never declared. It fails; it is never skipped.

    Skipping is what makes a gate lie: a repo that forgot to declare its test
    command would otherwise be graded green on zero evidence.
    """
    reason = (f"quality check '{name}' is not configured — declare quality.{name}.argv "
              f"in the sssf.config.yaml this run resolved. An undeclared check fails.")
    output_artifact = _check_dir(run, name) / "command.log"
    output_artifact.write_text(f"exit: {EX_CONFIG}\n\n{reason}\n")
    run.tracer.event(EventRecord(
        adw_id=run.adw_id,
        phase_id=run.phases[-1].phase_id if run.phases else "",
        type="gate_fail",
        name=f"quality:{name}",
        payload={"reason": reason, "output_artifact": str(output_artifact)},
    ))
    run.console.note(f"quality {name}: FAILED — not configured")
    return QualityCheckResult(
        name=name, area="backend", operation="build", command="",
        returncode=EX_CONFIG, passed=False, duration_seconds=0.0,
        output_artifact=str(output_artifact), output_tail=reason,
    )


def check(run, name: str) -> QualityCheckResult:
    """Run one configured check by name, or fail because it is not configured."""
    declared = run.cfg.quality.get(name)
    if declared is None:
        result = _unconfigured(run, name)
    else:
        result = _run(QualityCheckSpec(
            name=name,
            area=declared.area,
            operation=declared.operation,
            argv=declared.argv,
            timeout_seconds=declared.timeout_seconds,
        ), run)
    _record_gate(run, result)
    return result


def test(run) -> QualityCheckResult:
    """Run the project's test suite. The highest-value check to declare first."""
    return check(run, "test")


def run_tests(run) -> QualityResult:
    """The test suite alone, as a QualityResult — the deterministic test phase.

    This is what replaces a `tester` agent once the command is written down. An
    agent rediscovering the runner on every run costs a fortune to learn what a
    subprocess already knows; the repair loop is unchanged, because a failure
    still reaches the builder through `as_envelope` below.
    """
    check = test(run)
    failures = ([] if check.passed else
                [f"{check.name}: `{check.command}` exited {check.returncode}\n"
                 f"{check.output_tail}".rstrip()])
    return QualityResult(passed=check.passed, checks=[check], failures=failures,
                         artifacts=[check.output_artifact])


def as_envelope(result: QualityResult, what: str) -> VerifyOutput:
    """Wrap a deterministic result so an agent can be handed it directly.

    Agents hand each other typed envelopes; code blocks return QualityResult.
    This is the adapter, so a failing lint or test run flows back into the
    builder through exactly the same door an agent's report would — the ADW
    script is the only thing that knows the difference.
    """
    return VerifyOutput(
        status="success" if result.passed else "fail",
        summary=(f"{what}: all {len(result.checks)} check(s) passed" if result.passed
                 else f"{what}: {len(result.failures)} of {len(result.checks)} check(s) failed"),
        artifacts=result.artifacts,
        notes_for_next_agent=("" if result.passed else
                              "Fix every failure below. The output is verbatim from the "
                              "command — trust it over any summary."),
        passed=result.passed,
        failures=result.failures,
    )


def run_quality(run) -> QualityResult:
    """Run every configured check and collect ALL failures — one pass tells you everything.

    A failing check records the phase as failed without raising. The result still
    returns to the caller so the bounded repair loop can hand it to the builder.

    A config declaring no checks fails the result outright. A gate with nothing
    to run has verified nothing, and "nothing to verify" is not a pass.
    """
    declared = run.cfg.quality
    if not declared:
        reason = ("no quality checks configured — declare a `quality:` section in the "
                  "sssf.config.yaml this run resolved. A gate that runs nothing fails.")
        run.console.note(f"quality: FAILED — {reason}")
        result = QualityResult(passed=False, checks=[], failures=[reason], artifacts=[])
        _record_phase_failure(run, result)
        return result

    names = ([n for n in CHECK_ORDER if n in declared]
             + sorted(n for n in declared if n not in CHECK_ORDER))
    checks = [check(run, name) for name in names]
    # A failure is the command, its exit code, and what it actually printed —
    # everything a builder needs to repair without opening a log or being told
    # what the error "means" by a parser that guessed.
    failures = [
        f"{check.name}: `{check.command}` exited {check.returncode}\n{check.output_tail}".rstrip()
        for check in checks if not check.passed
    ]
    result = QualityResult(
        passed=not failures,
        checks=checks,
        failures=failures,
        artifacts=[check.output_artifact for check in checks],
    )
    _record_phase_failure(run, result)
    return result
