#!/usr/bin/env -S uv run
# /// script
# dependencies = ["pydantic", "python-dotenv", "pyyaml", "rich"]
# ///
"""Canonical factory SDLC: plan, build, verify, review, document, and commit.

The repair budget is read from ``workflow.max_repair_iterations`` in the
resolved config file and defaults to three.  A repair is never the last action:
every repair is followed by a new full-quality attempt, and every attempt runs
every configured quality command.
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import yaml

from adw_modules import agents, changes, gates, git_helper, hitl, quality, session, utils
from adw_modules.data_types import (
    AgentCall,
    BuildOutput,
    ChangeCapture,
    DocumentOutput,
    PhaseParams,
    PlanOutput,
    QualityResult,
    ReviewOutput,
)

REQUIRED_AGENTS = ["planner", "builder", "reviewer", "documenter"]
DEFAULT_MAX_REPAIR_ITERATIONS = 3
DEFAULT_MAX_REVISION_ITERATIONS = 2
DOCUMENT_NOTES = (
    "Read diff_path in full before writing. Document only what the diff shows, "
    "then copy the write-up into app_docs/ as your task describes."
)


def _workflow_limit(config: str, name: str, default: int) -> int:
    """Read an ADW orchestration limit without expanding the shared config model."""
    raw = yaml.safe_load(Path(config).read_text()) or {}
    value = (raw.get("workflow") or {}).get(name, default)
    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
        raise ValueError(f"workflow.{name} must be a non-negative integer")
    return value


def _record_quality(ph, result: QualityResult, attempt: int) -> None:
    """Put each check's exact verdict and command-log path in the live trace."""
    ph.log(
        attempt=attempt,
        passed=result.passed,
        checks=[
            {
                "name": check.name,
                "passed": check.passed,
                "command": check.command,
                "returncode": check.returncode,
                "output_artifact": check.output_artifact,
            }
            for check in result.checks
        ],
        artifacts=result.artifacts,
    )


def _failure_evidence(result: QualityResult) -> str:
    """Return command evidence verbatim, with its durable artifact alongside it."""
    evidence = []
    for check in result.checks:
        if not check.passed:
            evidence.append(
                f"{check.output_tail}\ncommand log: {check.output_artifact}".strip()
            )
    if not evidence:
        evidence.extend(result.failures)
    return "\n\n".join(evidence)


def run_quality_repairs(run, prompt: str, build: BuildOutput,
                        max_repairs: int) -> tuple[QualityResult, BuildOutput]:
    """Run full quality attempts, repairing only between attempts.

    ``max_repairs`` counts fixes, not test executions. Consequently a budget of
    N permits N+1 quality attempts and the Nth fix is always re-verified.
    """
    result: QualityResult | None = None
    for attempt in range(1, max_repairs + 2):
        terminal_attempt = attempt == max_repairs + 1
        with run.phase(PhaseParams(
            name=f"quality_{attempt}", kind="code", owner="quality",
            description=f"Run every configured quality check as verification attempt {attempt}",
        )) as ph:
            result = quality.run_quality(run)
            _record_quality(ph, result, attempt)
            if not result.passed and terminal_attempt:
                raise RuntimeError(_failure_evidence(result))

        if result.passed:
            return result, build

        with run.phase(PhaseParams(
            name=f"repair_{attempt}", kind="agent", owner="builder", retries=1,
            description=f"Repair the verbatim failures from quality attempt {attempt}",
        )) as ph:
            build = ph.call(AgentCall(
                output_type=BuildOutput,
                prompt=prompt,
                previous=quality.as_envelope(result, "quality"),
                gates=[gates.diff_matches_claims],
            ))

    raise AssertionError("quality repair loop did not terminate")


def _commit(run, ph, envelope) -> None:
    message = envelope.commit_message or f"sssf({run.adw_id}): {envelope.summary}"
    ph.log(sha=run.commit_all(message), message=message)


def main(prompt: str, config: str = "sssf.config.yaml",
         adw_id: str | None = None, preset: str | None = None, account: str | None = None,
         slug_hint: str | None = None) -> int:
    cfg = agents.load_config(config, preset)
    cfg.account = account or cfg.account
    utils.operator_env(cfg.account)
    agents.validate(cfg, REQUIRED_AGENTS)
    max_repairs = _workflow_limit(config, "max_repair_iterations", DEFAULT_MAX_REPAIR_ITERATIONS)
    max_revisions = _workflow_limit(config, "max_revision_iterations", DEFAULT_MAX_REVISION_ITERATIONS)
    run = session.ensure(cfg, adw_id, mutates_repo=True, slug_hint=slug_hint)
    baseline = git_helper.rev("HEAD")

    with run.phase(PhaseParams(
        name="request", kind="engineer", owner=run.engineer,
        description="Capture the incoming ask and the immutable starting commit",
    )) as ph:
        ph.log(input=prompt, baseline=git_helper.short_sha(baseline))

    with run.phase(PhaseParams(
        name="plan", kind="agent", owner="planner", retries=1,
        description="Turn the request into an implementable plan and surface only genuine owner forks",
    )) as ph:
        plan = hitl.resolve_plan_decisions(run, ph, AgentCall(
            output_type=PlanOutput, prompt=prompt,
            gates=[gates.artifacts_exist, gates.files_non_empty],
        ))

    with run.phase(PhaseParams(
        name="build", kind="agent", owner="builder", retries=1,
        description="Implement the owner-resolved plan in the entitled working tree",
    )) as ph:
        build = ph.call(AgentCall(
            output_type=BuildOutput, prompt=prompt, previous=plan,
            gates=[gates.diff_matches_claims],
        ))

    _, build = run_quality_repairs(run, prompt, build, max_repairs)

    review: ReviewOutput | None = None
    revised = False
    for attempt in range(1, max_revisions + 2):
        with run.phase(PhaseParams(
            name=f"review_{attempt}", kind="agent", owner="reviewer", retries=1,
            description=f"Check every planned requirement against the verified tree, attempt {attempt}",
        )) as ph:
            review = ph.call(AgentCall(
                output_type=ReviewOutput, prompt=prompt, previous=build,
                gates=[gates.artifacts_exist, gates.verdict_consistent],
            ))
        if review.approved:
            break
        if attempt > max_revisions:
            return run.finish(
                accepted=False,
                reason=f"review evidence: {review.model_dump_json()}",
            )
        with run.phase(PhaseParams(
            name=f"revise_{attempt}", kind="agent", owner="builder", retries=1,
            description=f"Resolve the reviewer's blocking evidence from attempt {attempt}",
        )) as ph:
            build = ph.call(AgentCall(
                output_type=BuildOutput, prompt=prompt, previous=review,
                gates=[gates.diff_matches_claims],
            ))
            revised = True

    if revised:
        _, build = run_quality_repairs(run, prompt, build, max_repairs)

    with run.phase(PhaseParams(
        name="commit_build", kind="code", owner="git",
        description="Commit only run-entitled paths after quality and review are both green",
    )) as ph:
        _commit(run, ph, build)

    with run.phase(PhaseParams(
        name="changes", kind="code", owner="git",
        description="Capture the landed implementation against the pinned run baseline",
    )) as ph:
        changeset = changes.capture(run, ChangeCapture(base=baseline))
        ph.log(base=changeset.base.commit, files=len(changeset.files) + len(changeset.untracked),
               diff=changeset.diff_path)
        if changeset.empty:
            raise RuntimeError(f"nothing changed since {baseline}")

    with run.phase(PhaseParams(
        name="document", kind="agent", owner="documenter", retries=1,
        description="Document the verified implementation from its captured diff",
    )) as ph:
        document = ph.call(AgentCall(
            output_type=DocumentOutput, prompt=prompt,
            previous=changes.as_envelope(changeset, DOCUMENT_NOTES),
            gates=[gates.artifacts_exist, gates.files_non_empty],
        ))

    with run.phase(PhaseParams(
        name="commit_docs", kind="code", owner="git",
        description="Commit only the documentation paths attributed to this run",
    )) as ph:
        _commit(run, ph, document)

    return run.finish()


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("prompt", help="inline text or a path to a prompt file")
    parser.add_argument("--preset", default=None)
    parser.add_argument("--account", default=None)
    parser.add_argument("--config", default="sssf.config.yaml")
    parser.add_argument("--adw-id", default=None, help="join or pin an existing session")
    args = parser.parse_args()
    sys.exit(main(utils.resolve_prompt(args.prompt), args.config, args.adw_id,
                  slug_hint=args.prompt, preset=args.preset, account=args.account))
