#!/usr/bin/env python3
"""
builder_loop.py — Phased autonomous build loop (entry point)

Run from the project root:

  python3 skills/build-feature/builder_loop.py --start my-app

  # Use a different executor:
  python3 skills/build-feature/builder_loop.py \
    --start my-app --executor codex
  python3 skills/build-feature/builder_loop.py \
    --start my-app --executor claude
  python3 skills/build-feature/builder_loop.py \
    --start my-app --executor opencode

Behavior:
  Each iteration runs the active phases as separate, chained agent
  requests inside one chat session per executor:
    1. Product review (if needed) → creates task files
    2. Planning (if unplanned tasks exist) → updates task files
    3. Execution (if planned-but-unexecuted exist) → implements code + commits
  Then the loop pushes and repeats. Per-phase executor/model overrides
  are honored via `phase_executors` in `.autobuilder/config.yaml`.

Stdlib only.
"""

import argparse
import datetime as dt
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import List, Optional

from common import (
    AGENT_FAILURE_BACKOFF_BASE,
    AGENT_FAILURE_BACKOFF_CAP,
    AUTOBUILDER_DIR,
    CRASH_BACKOFF_BASE,
    CRASH_BACKOFF_CAP,
    DEFAULT_CLAUDE_MODEL,
    DEFAULT_CODEX_MODEL,
    DEFAULT_CURSOR_MODEL,
    DEFAULT_OPENCODE_MODEL,
    IDLE_BACKOFF_BASE,
    IDLE_BACKOFF_CAP,
    MAX_CONSECUTIVE_AGENT_FAILURES,
    MAX_CONSECUTIVE_CRASHES,
    MAX_ITERATIONS,
    PROCESS_MAX_RESTARTS,
    REPO_ROOT,
    log,
)
from config import resolve_project_from_config
from git_ops import ensure_github_remote
from hooks import build_hook_env, run_hook
from initiative import (
    create_initiative,
    initiatives_base_dir,
    list_initiatives,
    set_active_initiative,
)
from iteration import AgentFailed, GracefulStop, NothingToImprove, run_iteration
from process import _kill_all_children, install_signal_handlers, kill_previous_daemon
from project import (
    ExecutorConfigError,
    ensure_project_config,
    load_autobuilder_env,
    load_project_config,
    validate_executor_config,
)
from prompt import pick_review_strategy
from status import (
    append_journal_entry,
    check_stop_signal,
    remove_pid_file,
    write_pid_file,
    write_status,
)


def main() -> int:
    os.environ.pop("PORT", None)

    args = _parse_args()
    config_path = Path(args.config) if args.config else None

    if args.start:
        resolved = resolve_project_from_config(args.start, config_path)
        args.project_dir = resolved["project_dir"]
        args.project_id = resolved["project_id"]
        args.root_initiative = resolved["root_initiative"]
        if args.port is None and resolved.get("port"):
            args.port = resolved["port"]

    if args.model is None:
        if args.executor == "codex":
            args.model = DEFAULT_CODEX_MODEL
        elif args.executor == "claude":
            args.model = DEFAULT_CLAUDE_MODEL
        elif args.executor == "opencode":
            args.model = DEFAULT_OPENCODE_MODEL
        else:
            args.model = DEFAULT_CURSOR_MODEL

    project_dir = Path(args.project_dir).resolve()
    project_id = args.project_id or project_dir.name
    root_initiative = args.root_initiative or project_dir.name

    _validate_project_dir(project_dir)
    _ensure_global_npm_deps()

    project_config = load_project_config(project_dir)
    no_push = args.no_push if args.no_push is not None else bool(project_config.get("no_push", False))

    # Validate executor/model — daemon defaults + every per-phase override.
    # Fail loudly BEFORE writing pidfiles, opening logs, killing prior
    # daemons, etc., so a typo doesn't kill an already-running loop.
    try:
        validate_executor_config(args.executor, args.model, project_config)
    except ExecutorConfigError as exc:
        sys.stderr.write(f"\n{exc}\n\n")
        return 2

    # Load port from .autobuilder/.env if not set via CLI or config.json
    if args.port is None:
        ab_env = load_autobuilder_env(project_dir)
        port_str = ab_env.get("PORT")
        if port_str:
            try:
                args.port = int(port_str)
            except ValueError:
                pass

    if args.env_file is None:
        project_env = project_dir / ".env"
        if project_env.exists():
            args.env_file = str(project_env)

    autobuilder_data = project_dir / ".autobuilder"
    autobuilder_data.mkdir(parents=True, exist_ok=True)

    # Ensure initiatives base directory exists
    init_base = initiatives_base_dir(project_dir)
    init_base.mkdir(parents=True, exist_ok=True)

    # ── Migrate legacy layouts ──
    _migrate_to_initiative_layout(project_dir, root_initiative)

    _ensure_project_exists(project_dir)
    ensure_project_config(project_dir)

    if not no_push:
        ensure_github_remote(project_dir, project_id)

    kill_previous_daemon(project_dir)

    log_path = Path(args.log_file) if args.log_file else (autobuilder_data / "loop.log")
    log_fh = open(log_path, "a", encoding="utf-8")
    install_signal_handlers(log_fh)

    print(f"Log file: {log_path}")
    print(f"Monitor:  tail -f {log_path}\n")

    _log_banner(log_fh, project_dir, project_id, args, no_push)

    backlog_path = autobuilder_data / "backlog.md"
    status_path = autobuilder_data / "status.md"

    write_pid_file(project_dir)

    import atexit
    atexit.register(lambda: (_kill_all_children(), remove_pid_file(project_dir)))

    return _run_loop(
        max_iterations=args.max_iterations,
        project_dir=project_dir,
        project_id=project_id,
        root_initiative=root_initiative,
        backlog_path=backlog_path,
        status_path=status_path,
        model=args.model,
        executor=args.executor,
        env_file=args.env_file,
        port=args.port,
        no_push=no_push,
        log_fh=log_fh,
    )


# ---------------------------------------------------------------------------
# Legacy migration
# ---------------------------------------------------------------------------

def _migrate_to_initiative_layout(
    project_dir: Path, root_initiative: str,
) -> None:
    """Migrate pre-initiative layouts into the initiative-based structure.

    Handles the legacy format where task ``.md`` files lived directly
    in ``.autobuilder/initiatives/``.  Moves them into a default
    initiative directory::

        .autobuilder/initiatives/0001-<slug>/tasks/*.md

    The project's ``scope.md`` and ``constraints.md`` are copied to the
    initiative's ``spec.md`` and ``constraints.md`` respectively.
    """
    ab = project_dir / ".autobuilder"
    init_base = ab / "initiatives"
    scope = ab / "scope.md"
    constraints = ab / "constraints.md"
    active_file = ab / "active-initiative"

    # Already in new layout? (initiative subdirs with spec.md)
    if init_base.exists():
        for child in init_base.iterdir():
            if child.is_dir() and (child / "spec.md").exists():
                return

    # Collect task .md files from old location
    md_files: List[Path] = []
    if init_base.exists():
        md_files.extend(init_base.glob("*.md"))

    if not md_files and not scope.exists():
        return

    slug = root_initiative or project_dir.name
    dir_name = f"0001-{slug}"
    init_dir = init_base / dir_name
    tasks_dir = init_dir / "tasks"
    tasks_dir.mkdir(parents=True, exist_ok=True)

    for md in md_files:
        dest = tasks_dir / md.name
        if not dest.exists():
            md.rename(dest)
        elif md.exists():
            md.unlink()

    if scope.exists() and not (init_dir / "spec.md").exists():
        shutil.copy2(str(scope), str(init_dir / "spec.md"))

    if constraints.exists() and not (init_dir / "constraints.md").exists():
        shutil.copy2(str(constraints), str(init_dir / "constraints.md"))

    if not active_file.exists():
        active_file.parent.mkdir(parents=True, exist_ok=True)
        active_file.write_text(dir_name + "\n", encoding="utf-8")


# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

def _parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser(
        description="Builder Agent — phased autonomous build loop",
    )
    ap.add_argument("--start", metavar="PROJECT_NAME", default=None,
                    help="Start by project name (looked up in config.json). "
                         "Overrides --project-dir, --project-id, --root-initiative.")
    ap.add_argument("--config", default=None,
                    help="Path to config.json with project definitions")
    ap.add_argument("--project-dir", default=".",
                    help="Path to the project directory (default: current directory)")
    ap.add_argument("--root-initiative", default=None,
                    help="Root initiative ID (default: project directory name)")
    ap.add_argument("--project-id", default=None,
                    help="Project ID for registry (defaults to project dir name)")
    ap.add_argument("--executor",
                    choices=["cursor", "codex", "claude", "opencode"],
                    default="cursor",
                    help='Code execution provider: "cursor" (default), '
                         '"codex", "claude", or "opencode"')
    ap.add_argument("--model", default=None,
                    help=f"Model override (default: {DEFAULT_CURSOR_MODEL} for cursor)")
    ap.add_argument("--port", type=int, default=None,
                    help="Frontend dev server port (from config.json or manual)")
    ap.add_argument("--env-file", default=None,
                    help=".env file (default: auto-detected from project dir)")
    ap.add_argument("--max-iterations", type=int, default=MAX_ITERATIONS,
                    help=f"Safety cap (default: {MAX_ITERATIONS})")
    ap.add_argument("--log-file", default=None, help="Log file path")

    push_group = ap.add_mutually_exclusive_group()
    push_group.add_argument(
        "--no-push", dest="no_push", action="store_true", default=None,
        help="Skip git push (commits stay local). Overrides per-project config.",
    )
    push_group.add_argument(
        "--push", dest="no_push", action="store_false",
        help="Force git push even if per-project config disables it.",
    )

    return ap.parse_args()


# ---------------------------------------------------------------------------
# Validation & setup helpers
# ---------------------------------------------------------------------------

_REQUIRED_GLOBAL_PACKAGES = [
    "agent-browser",
    "react-doctor",
]


def _ensure_global_npm_deps() -> None:
    """Install required global npm packages if missing.

    Runs once at daemon startup.  Each package is checked via
    ``shutil.which`` (is the binary on PATH?) and ``npm ls -g``
    (is it managed by npm?).  Installs or reinstalls as needed,
    then verifies the binary resolves.
    """
    for pkg in _REQUIRED_GLOBAL_PACKAGES:
        on_path = shutil.which(pkg) is not None
        npm_ok = False
        try:
            check = subprocess.run(
                ["npm", "ls", "-g", pkg],
                capture_output=True, text=True, timeout=30,
            )
            npm_ok = check.returncode == 0 and pkg in check.stdout
        except Exception:
            pass

        if on_path and npm_ok:
            continue

        action = "Reinstalling" if npm_ok else "Installing"
        print(f"{action} global dependency: {pkg}")
        result = subprocess.run(
            ["npm", "install", "-g", pkg],
            capture_output=True, text=True, timeout=120,
        )
        if result.returncode != 0:
            print(f"WARNING: npm install -g {pkg} failed: "
                  f"{result.stderr.strip()}", file=sys.stderr)
            continue

        if shutil.which(pkg) is None:
            print(f"WARNING: {pkg} installed but not found on PATH",
                  file=sys.stderr)


def _validate_project_dir(project_dir: Path) -> None:
    for forbidden, label in [
        (REPO_ROOT, "repository root"),
        (AUTOBUILDER_DIR, "autobuilder directory"),
    ]:
        if project_dir == forbidden or str(project_dir).startswith(str(forbidden) + os.sep):
            print(f"ERROR: --project-dir must NOT be inside the {label}.", file=sys.stderr)
            raise SystemExit(2)


def _ensure_project_exists(project_dir: Path) -> None:
    if not project_dir.exists():
        project_dir.mkdir(parents=True, exist_ok=True)
        subprocess.run(["git", "init"], cwd=str(project_dir), capture_output=True, check=False)
        gitignore = (
            "node_modules/\n.next/\ndist/\n.env\n.env.local\n"
            "*.log\n.DS_Store\ntest-results/\nplaywright-report/\n.vercel/\n"
        )
        (project_dir / ".gitignore").write_text(gitignore, encoding="utf-8")
        print(f"Created project directory: {project_dir}")


def _write_complete_marker(
    project_dir: Path,
    iteration: int,
    consecutive_idle: int,
    threshold: int,
) -> None:
    """Write ``.autobuilder/COMPLETE.md`` to record early-complete convergence.

    The marker file documents *when* and *why* the daemon stopped. Re-running
    the daemon will overwrite it on the next convergence event.
    """
    ab = project_dir / ".autobuilder"
    ab.mkdir(parents=True, exist_ok=True)
    ts = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    body = (
        f"# Early-Complete Reached\n\n"
        f"Timestamp: {ts}\n"
        f"Iteration: {iteration}\n"
        f"Consecutive idle iterations: {consecutive_idle}\n"
        f"Configured threshold: {threshold}\n\n"
        f"The autobuilder daemon converged: the review phase produced no "
        f"functional or critical-UX issues for the threshold number of "
        f"iterations in a row, so the loop exited cleanly.\n\n"
        f"To resume work on this project, delete this file and re-launch "
        f"`builder_loop.py`. Setting `early_complete: false` in "
        f"`.autobuilder/config.yaml` will return to forever-mode.\n"
    )
    (ab / "COMPLETE.md").write_text(body, encoding="utf-8")


def _log_banner(log_fh, project_dir, project_id, args, no_push) -> None:
    log(log_fh, "=" * 72)
    log(log_fh, "BUILDER DAEMON STARTED")
    log(log_fh, f"Project:       {project_dir}")
    log(log_fh, f"Project ID:    {project_id}")
    log(log_fh, f"Initiatives:   {project_dir / '.autobuilder' / 'initiatives'}")
    log(log_fh, f"Executor:      {args.executor}")
    log(log_fh, f"Model:         {args.model or '(provider default)'}")
    log(log_fh, f"Max iters:     {args.max_iterations}")
    if no_push:
        source = "CLI" if args.no_push is True else "config.yaml"
        log(log_fh, f"Push:          DISABLED (via {source})")
    else:
        log(log_fh, f"Push:          enabled")
    cfg = load_project_config(project_dir)
    if cfg.get("early_complete"):
        thr = cfg.get("early_complete_idle_threshold", 1)
        log(log_fh, f"Early-complete: ENABLED (idle threshold: {thr})")
    complete_marker = project_dir / ".autobuilder" / "COMPLETE.md"
    if complete_marker.exists():
        log(log_fh, f"NOTE: existing COMPLETE.md found at {complete_marker} — "
                    "it will be overwritten if the loop converges again.")
    log(log_fh, "=" * 72)


# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------

def _run_loop(
    *,
    max_iterations: int,
    project_dir: Path,
    project_id: str,
    root_initiative: str,
    backlog_path: Path,
    status_path: Path,
    model: str,
    executor: str,
    env_file: Optional[str],
    port: Optional[int],
    no_push: bool,
    log_fh,
) -> int:
    previous_improvements: List[str] = []
    consecutive_crashes = 0
    consecutive_idle = 0
    consecutive_agent_failures = 0

    base_env = build_hook_env(
        project_dir=project_dir,
        project_id=project_id,
        port=port,
        executor=executor,
        model=model,
    )

    on_start = run_hook(project_dir, "on-start", log_fh, env=base_env)
    if on_start is not None and not on_start.ok:
        log(log_fh, "on-start hook failed — daemon will not start.")
        write_status(status_path, project_id, 0, "start-failed",
                     f"on-start hook failed (exit {on_start.exit_code}). "
                     "Fix the hook and restart.",
                     project_dir=project_dir)
        log_fh.close()
        return 1

    try:
        return _iteration_loop(
            max_iterations=max_iterations,
            project_dir=project_dir,
            project_id=project_id,
            root_initiative=root_initiative,
            backlog_path=backlog_path,
            status_path=status_path,
            model=model,
            executor=executor,
            env_file=env_file,
            port=port,
            no_push=no_push,
            log_fh=log_fh,
            previous_improvements=previous_improvements,
            consecutive_crashes=consecutive_crashes,
            consecutive_idle=consecutive_idle,
            consecutive_agent_failures=consecutive_agent_failures,
            base_env=base_env,
        )
    finally:
        run_hook(project_dir, "on-stop", log_fh, env=base_env)
        log_fh.close()


def _iteration_loop(
    *,
    max_iterations: int,
    project_dir: Path,
    project_id: str,
    root_initiative: str,
    backlog_path: Path,
    status_path: Path,
    model: str,
    executor: str,
    env_file: Optional[str],
    port: Optional[int],
    no_push: bool,
    log_fh,
    previous_improvements: List[str],
    consecutive_crashes: int,
    consecutive_idle: int,
    consecutive_agent_failures: int,
    base_env: dict,
) -> int:
    for iteration in range(1, max_iterations + 1):
        if check_stop_signal(project_dir):
            log(log_fh, "STOP SIGNAL received. Shutting down gracefully.")
            write_status(status_path, project_id, iteration, "stopped",
                         "Stopped by user request.", project_dir=project_dir)
            return 0

        iter_env = {**base_env, "AUTOBUILDER_ITERATION": str(iteration)}

        pre_iter = run_hook(project_dir, "pre-iteration", log_fh, env=iter_env)
        if pre_iter is not None and not pre_iter.ok:
            log(log_fh, f"pre-iteration hook failed — skipping iteration {iteration}.")
            time.sleep(30)
            continue

        try:
            jdata = run_iteration(
                iteration=iteration,
                project_dir=project_dir,
                project_id=project_id,
                root_initiative=root_initiative,
                backlog_path=backlog_path,
                status_path=status_path,
                model=model,
                executor=executor,
                env_file=env_file,
                port=port,
                consecutive_idle=consecutive_idle,
                previous_improvements=previous_improvements,
                no_push=no_push,
                log_fh=log_fh,
            )
            if jdata is not None:
                jdata["outcome"] = "productive"
                append_journal_entry(project_dir, jdata)
            consecutive_crashes = 0
            consecutive_idle = 0
            consecutive_agent_failures = 0

        except GracefulStop:
            return 0

        except NothingToImprove as exc:
            consecutive_idle += 1
            if exc.journal_data:
                exc.journal_data["outcome"] = "idle"
                append_journal_entry(project_dir, exc.journal_data)

            # Early-complete: if configured, declare convergence after N
            # consecutive idle iterations and exit cleanly.
            iter_cfg = load_project_config(project_dir)
            if iter_cfg.get("early_complete"):
                threshold = int(iter_cfg.get("early_complete_idle_threshold", 1))
                if consecutive_idle >= threshold:
                    _write_complete_marker(
                        project_dir, iteration, consecutive_idle, threshold,
                    )
                    log(log_fh, "")
                    log(log_fh, "=" * 72)
                    log(log_fh, f"  EARLY-COMPLETE — converged after "
                                f"{consecutive_idle} idle iteration(s) "
                                f"(threshold {threshold}).")
                    log(log_fh, "  Wrote .autobuilder/COMPLETE.md. "
                                "Daemon exiting cleanly.")
                    log(log_fh, "=" * 72)
                    write_status(
                        status_path, project_id, iteration, "complete",
                        f"Early-complete reached after {consecutive_idle} "
                        f"idle iteration(s). Daemon stopped.",
                        project_dir=project_dir,
                    )
                    run_hook(project_dir, "on-idle", log_fh, env=iter_env)
                    return 0

            next_strategy = pick_review_strategy(iteration + 1, project_dir=project_dir)
            wait = min(IDLE_BACKOFF_BASE * (2 ** (consecutive_idle - 1)), IDLE_BACKOFF_CAP)
            log(log_fh, f"Nothing to improve — idle #{consecutive_idle}. "
                        f"Next strategy: {next_strategy['name']}. Sleeping {wait}s...")
            write_status(status_path, project_id, iteration, "idle",
                         f"No improvements found (idle #{consecutive_idle}). "
                         f"Next: {next_strategy['name']} in {wait // 60}min.",
                         project_dir=project_dir)
            run_hook(project_dir, "on-idle", log_fh, env=iter_env)
            time.sleep(wait)

        except AgentFailed as exc:
            consecutive_agent_failures += 1
            if exc.journal_data:
                exc.journal_data["outcome"] = "failed"
                append_journal_entry(project_dir, exc.journal_data)
            log(log_fh, f"AGENT FAILED ({consecutive_agent_failures}/{MAX_CONSECUTIVE_AGENT_FAILURES}): {exc}")
            err_env = {**iter_env,
                       "AUTOBUILDER_ERROR": str(exc),
                       "AUTOBUILDER_CONSECUTIVE_FAILURES": str(consecutive_agent_failures)}
            run_hook(project_dir, "on-error", log_fh, env=err_env)
            if consecutive_agent_failures >= MAX_CONSECUTIVE_AGENT_FAILURES:
                log(log_fh, "Too many consecutive agent failures. Daemon stopping.")
                write_status(status_path, project_id, iteration, "agent-broken",
                             f"Agent failed {consecutive_agent_failures} times in a row. "
                             "Check executor auth/config and restart manually.",
                             project_dir=project_dir)
                return 1
            wait = min(AGENT_FAILURE_BACKOFF_BASE * (2 ** (consecutive_agent_failures - 1)),
                       AGENT_FAILURE_BACKOFF_CAP)
            log(log_fh, f"Backing off {wait}s before retry...")
            write_status(status_path, project_id, iteration, "agent-failing",
                         f"Agent failed {consecutive_agent_failures} times. Retrying in {wait // 60}min.",
                         project_dir=project_dir)
            time.sleep(wait)

        except Exception as e:
            consecutive_crashes += 1
            import traceback
            tb = traceback.format_exc()
            log(log_fh, f"CRASH in iteration {iteration} ({consecutive_crashes}/{MAX_CONSECUTIVE_CRASHES}): {e}")
            log(log_fh, tb)
            err_env = {**iter_env,
                       "AUTOBUILDER_ERROR": str(e),
                       "AUTOBUILDER_CONSECUTIVE_FAILURES": str(consecutive_crashes)}
            run_hook(project_dir, "on-error", log_fh, env=err_env)
            write_status(status_path, project_id, iteration, "error",
                         f"Crash #{consecutive_crashes}: {e}\nWill retry automatically.",
                         project_dir=project_dir)
            if consecutive_crashes >= MAX_CONSECUTIVE_CRASHES:
                log(log_fh, "Too many consecutive crashes. Daemon stopping.")
                return 1
            wait = min(CRASH_BACKOFF_BASE * (2 ** (consecutive_crashes - 1)), CRASH_BACKOFF_CAP)
            log(log_fh, f"Retrying in {wait}s...")
            time.sleep(wait)

    log(log_fh, f"Safety cap reached ({max_iterations} iterations). Stopping.")
    return 0


# ---------------------------------------------------------------------------
# Entry point with crash-restart wrapper
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    for _restart in range(PROCESS_MAX_RESTARTS):
        try:
            raise SystemExit(main())
        except KeyboardInterrupt:
            print("\nStopped by user")
            raise SystemExit(130)
        except SystemExit:
            raise
        except Exception as exc:
            wait = min(CRASH_BACKOFF_BASE * (2 ** _restart), CRASH_BACKOFF_CAP)
            print(f"FATAL CRASH #{_restart + 1}: {exc}. Restarting in {wait}s...", file=sys.stderr)
            time.sleep(wait)
    print("Too many fatal crashes. Daemon giving up.", file=sys.stderr)
    raise SystemExit(1)
