"""Single-iteration orchestration: the body of one daemon loop cycle.

Handles phase selection, agent invocation, and post-run bookkeeping.
"""

import random
import re
import shlex
import shutil
import subprocess
import time as _time
from pathlib import Path
from typing import Dict, List, Optional, Set

from common import (
    AUTOBUILDER_DIR,
    DEFAULT_MAX_TASKS_FEATURE,
    DEFAULT_MAX_TASKS_POLISH,
    PHASE_HARD_TIMEOUT,
    REVIEW_POLISH_WEIGHT,
    REVIEW_STRATEGIES,
    SKILLS_TARGET,
    is_port_open,
    log,
)
from config import resolve_project_from_config  # noqa: F401 — re-exported for main
from frontmatter import parse_bool, read_frontmatter
from git_ops import get_current_branch, has_unpushed_commits, push_commits
from hooks import build_hook_env, run_augment_hook, run_hook
from initiative import (
    create_root_task_from_spec,
    get_initiative_constraints,
    get_initiative_spec,
    get_initiative_tasks_dir,
    has_tasks as initiative_has_tasks,
    initiatives_base_dir,
    list_initiatives,
    resolve_active_initiative,
)
from process import (
    build_phase_cmd,
    cleanup_browsers,
    close_browser_session,
    get_last_agent_pid,
    open_chat_session,
    run_streaming,
    scan_codex_session_line,
    scan_opencode_session_line,
    snapshot_browser_pids,
)
from project import (
    _untrack_ignored_files,
    cleanup_stale_mcp,
    consume_backlog,
    ensure_gitignore_entries,
    load_project_config,
    load_test_credentials,
    parse_env_var_names,
    read_file_text,
    resolve_phase_executor,
    resolve_review_strategies,
    setup_project_agents_md,
    sync_skills_to_project,
)
from prompt import (
    build_phase_prompt,
    generate_memory_index,
    pick_review_strategy,
)
from status import write_status
from tasks import (
    find_all_unplanned,
    find_planned_not_executed,
    get_task_ids,
    load_tasks,
    mark_executed,
    next_task_number,
    normalize_task_filenames,
    summarize_all_executed_tasks,
    topological_sort,
)


# ---------------------------------------------------------------------------
# Flow-control exceptions (carry journal metadata for the loop to record)
# ---------------------------------------------------------------------------

class GracefulStop(Exception):
    """Raised when the daemon should exit cleanly."""
    pass


class NothingToImprove(Exception):
    """Raised when a review finds no new tasks to create."""
    def __init__(self, journal_data: Optional[Dict] = None):
        super().__init__()
        self.journal_data: Dict = journal_data or {}


class AgentFailed(Exception):
    """Raised when the agent exits non-zero without producing useful work."""
    def __init__(self, msg: str, journal_data: Optional[Dict] = None):
        super().__init__(msg)
        self.journal_data: Dict = journal_data or {}


# ---------------------------------------------------------------------------
# Iteration body
# ---------------------------------------------------------------------------

def _build_initiative_map(
    project_dir: Path, active_initiative: str,
) -> str:
    """Build a compact status line for every initiative (python-computed)."""
    inits = list_initiatives(project_dir)
    if not inits:
        return ""
    lines = []
    for init_id, init_dir in inits:
        td = init_dir / "tasks"
        total = 0
        executed = 0
        if td.is_dir():
            for md in td.glob("*.md"):
                total += 1
                fm = read_frontmatter(md)
                if parse_bool(fm.get("executed")):
                    executed += 1
        is_active = (init_id == active_initiative)
        if is_active:
            if total == 0:
                label = "active — no tasks yet"
            else:
                label = f"active — {executed}/{total} executed"
        elif total > 0 and executed == total:
            label = f"completed — {total} tasks"
        elif total > 0:
            label = f"paused — {executed}/{total} executed"
        else:
            label = "empty"
        marker = " *" if is_active else ""
        lines.append(f"  {init_id} [{label}]{marker}")
    return "\n".join(lines)


def run_iteration(
    *,
    iteration: 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] = None,
    consecutive_idle: int,
    previous_improvements: List[str],
    no_push: bool = False,
    log_fh,
) -> Optional[Dict]:
    """Run one full iteration: push → single agent call → sync results.

    Returns a journal-data dict on success (for the loop to record),
    or ``None`` for early exits (push blocked, etc.) where no agent ran.
    Raises :class:`NothingToImprove` or :class:`AgentFailed` — both carry
    their own ``journal_data`` attribute.
    """
    t0 = _time.time()

    log(log_fh, "")
    log(log_fh, "#" * 72)
    log(log_fh, f"  ITERATION {iteration}")
    log(log_fh, "#" * 72)

    hook_env = build_hook_env(
        project_dir=project_dir,
        project_id=project_id,
        port=port,
        iteration=iteration,
        executor=executor,
        model=model,
    )

    # ── Phase 0: Push pending commits ──
    if no_push:
        log(log_fh, "Push disabled — skipping push phase.")
    elif has_unpushed_commits(project_dir):
        branch = get_current_branch(project_dir)
        push_env = {**hook_env, "AUTOBUILDER_PUSH_BRANCH": branch}

        pre_push = run_hook(project_dir, "pre-push", log_fh, env=push_env)
        if pre_push is not None and not pre_push.ok:
            log(log_fh, "pre-push hook blocked push — will retry next iteration.")
            write_status(status_path, project_id, iteration, "push-blocked",
                         "pre-push hook blocked push.", project_dir=project_dir)
            return None

        write_status(status_path, project_id, iteration, "pushing",
                     "Pushing commits...", project_dir=project_dir)
        push_ok = push_commits(project_dir, log_fh)
        if not push_ok:
            log(log_fh, "Push failed — will retry next iteration.")
            write_status(status_path, project_id, iteration, "push-failed",
                         "Push failed — will retry next iteration.", project_dir=project_dir)
            return None

        post_push = run_hook(project_dir, "post-push", log_fh, env=push_env)
        if post_push is not None and not post_push.ok:
            failure_detail = post_push.stdout.strip() or post_push.stderr.strip()
            log(log_fh, f"post-push hook failed (exit {post_push.exit_code}) — "
                        "iteration stopped.")
            if failure_detail:
                backlog_path.parent.mkdir(parents=True, exist_ok=True)
                with backlog_path.open("a", encoding="utf-8") as bf:
                    bf.write(
                        f"\n## Deploy/CI failure (iteration {iteration})\n\n"
                        f"{failure_detail}\n"
                    )
                log(log_fh, "Wrote post-push failure to backlog.md for "
                            "self-correction.")
            write_status(status_path, project_id, iteration, "deploy-failed",
                         f"post-push hook failed (exit {post_push.exit_code}).",
                         project_dir=project_dir)
            return None
    else:
        log(log_fh, "No unpushed commits — skipping push.")

    # ── Read project context (PRODUCT.md, with legacy fallback) ──
    product_md_path = project_dir / ".autobuilder" / "PRODUCT.md"
    project_context = read_file_text(product_md_path)
    if not project_context:
        _legacy_scope = read_file_text(project_dir / ".autobuilder" / "scope.md")
        _legacy_constraints = read_file_text(project_dir / ".autobuilder" / "constraints.md")
        if _legacy_scope or _legacy_constraints:
            _parts = []
            if _legacy_scope:
                _parts.append(_legacy_scope)
            if _legacy_constraints:
                _parts.append("## Project Constraints\n\n" + _legacy_constraints)
            project_context = "\n\n".join(_parts)

    backlog = consume_backlog(backlog_path)

    if project_context:
        log(log_fh, f"Project context loaded ({len(project_context)} chars)")
    if backlog:
        log(log_fh, f"Backlog consumed ({len(backlog)} chars): {backlog[:200]}...")

    # ── Resolve active initiative ──
    active_initiative = resolve_active_initiative(project_dir)

    if not active_initiative:
        log(log_fh, "No initiatives found — nothing to do. "
                    "Use the create-project skill to set up the first initiative.")
        raise NothingToImprove()

    tasks_dir = get_initiative_tasks_dir(project_dir, active_initiative)
    tasks_dir.mkdir(parents=True, exist_ok=True)
    tasks_rel = f".autobuilder/initiatives/{active_initiative}/tasks/"
    init_base = initiatives_base_dir(project_dir)

    initiative_spec = get_initiative_spec(project_dir, active_initiative)
    initiative_constraints = get_initiative_constraints(project_dir, active_initiative)
    initiative_map = _build_initiative_map(project_dir, active_initiative)
    log(log_fh, f"Active initiative: {active_initiative}"
                f" (spec {len(initiative_spec)} chars)")

    # Bootstrap: seed a root task if this initiative has no tasks yet
    if initiative_spec and not initiative_has_tasks(project_dir, active_initiative):
        root_path = create_root_task_from_spec(
            project_dir, active_initiative, root_initiative,
        )
        if root_path:
            log(log_fh, f"Bootstrapped initiative '{active_initiative}' "
                        f"→ {root_path.name}")
    elif not initiative_spec:
        log(log_fh, f"WARNING: Initiative '{active_initiative}' has no spec.md")

    # ── Determine which phases to include ──
    unplanned = find_all_unplanned(tasks_dir)
    planned_pending = find_planned_not_executed(tasks_dir)

    if unplanned:
        phases = ["plan", "execute"]
        log(log_fh, f"Found {len(unplanned)} unplanned task(s) — plan + execute.")
    elif planned_pending:
        phases = ["execute"]
        log(log_fh, f"Found {len(planned_pending)} planned-but-unexecuted — execute only.")
    else:
        phases = ["review", "plan", "execute"]
        log(log_fh, "No pending tasks — full cycle: review → plan → execute.")

    # ── Load per-project review configuration ──
    project_cfg = load_project_config(project_dir)
    early_complete = bool(project_cfg.get("early_complete", False))
    polish_weight = project_cfg.get("review_polish_weight", REVIEW_POLISH_WEIGHT)
    effective_strategies = resolve_review_strategies(project_cfg)

    if early_complete:
        # Early-complete forces polish-only; feature-mode is suppressed since
        # the goal is convergence on functionality + critical UX, not new ideas.
        review_mode = "polish"
    else:
        review_mode = "polish" if random.random() < polish_weight else "feature"
    review_strategy = (
        pick_review_strategy(
            iteration, project_dir=project_dir, strategies=effective_strategies,
        )
        if "review" in phases
        else (effective_strategies[0] if effective_strategies else REVIEW_STRATEGIES[0])
    )

    # Compute effective max_tasks: mode default → per-strategy override
    if review_mode == "polish":
        max_tasks: int = project_cfg.get(
            "review_max_tasks_polish", DEFAULT_MAX_TASKS_POLISH,
        )
    else:
        max_tasks = project_cfg.get(
            "review_max_tasks_feature", DEFAULT_MAX_TASKS_FEATURE,
        )
    strategy_limits = project_cfg.get("strategy_max_tasks", {})
    sname = review_strategy.get("name", "")
    if isinstance(strategy_limits, dict) and sname in strategy_limits:
        max_tasks = strategy_limits[sname]
    if "max_tasks" in review_strategy:
        try:
            max_tasks = int(review_strategy["max_tasks"])
        except (ValueError, TypeError):
            pass

    ec_tag = " | EARLY-COMPLETE" if early_complete else ""
    log(log_fh, f"Review mode: {review_mode} | Phases: {phases} | "
                f"Strategy: {review_strategy['name']} | "
                f"Max tasks: {max_tasks}{ec_tag}")

    jdata: Dict = {
        "iter": iteration,
        "strategy": review_strategy["name"],
        "mode": review_mode,
        "phases": phases,
        "initiative": active_initiative,
        "created": [],
        "executed": [],
        "rc": None,
    }

    # ── Ensure project gitignore covers runtime artifacts ──
    _GITIGNORE_ENTRIES = [
        ".cursor/skills/autobuilder",
        ".cursor/mcp.json",
        "AGENTS.md",
        "dogfood-output/",
        "*.log",
        "playwright-report/",
        ".autobuilder/.env",
        ".autobuilder/active-initiative",
        ".autobuilder/config.yaml",
        ".autobuilder/status.md",
        ".autobuilder/loop.log",
        ".autobuilder/stop",
        ".autobuilder/daemon.pid",
        ".autobuilder/PRODUCTION_READY.md",
        ".autobuilder/journal.jsonl",
        ".autobuilder/memory-index.md",
        "**/review-screenshots/",
    ]
    ensure_gitignore_entries(project_dir, _GITIGNORE_ENTRIES)
    _untrack_ignored_files(project_dir, _GITIGNORE_ENTRIES)

    cleanup_stale_mcp(project_dir)

    # ── Symlink skills + AGENTS.md ──
    skills_ok = sync_skills_to_project(project_dir)
    log(log_fh, f"Skills symlink {'ok' if skills_ok else 'FAILED'}: {project_dir / SKILLS_TARGET}")

    agents_ok = setup_project_agents_md(project_dir)
    if agents_ok:
        log(log_fh, f"AGENTS.md symlinked → {(AUTOBUILDER_DIR / 'AGENTS.md').resolve()}")

    # ── Copy .env if needed ──
    env_vars: List[str] = []
    if env_file:
        env_src = Path(env_file).resolve()
        env_dst = (project_dir / ".env").resolve()
        if env_src.exists() and env_src != env_dst:
            shutil.copy2(str(env_src), str(project_dir / ".env"))
        if env_src.exists():
            env_vars = parse_env_var_names(env_src)

    # ── Commit initiative content (specs, constraints, tasks, PRODUCT.md) ──
    # Only stage specific paths — never `git add -A` which would scoop up
    # unrelated working-tree changes and produce noisy empty commits.
    _COMMIT_PATHS = [".autobuilder/initiatives", ".autobuilder/PRODUCT.md"]
    paths_to_stage = [p for p in _COMMIT_PATHS if (project_dir / p).exists()]
    if paths_to_stage:
        subprocess.run(
            ["git", "add", "--"] + paths_to_stage,
            cwd=str(project_dir), capture_output=True, check=False,
        )
        diff_result = subprocess.run(
            ["git", "diff", "--cached", "--quiet"],
            cwd=str(project_dir), capture_output=True,
        )
        if diff_result.returncode != 0:
            subprocess.run(
                ["git", "commit", "-m", "Update task plans and skills"],
                cwd=str(project_dir), capture_output=True, check=False,
            )

    # ── Snapshot task state ──
    before_ids = set(get_task_ids(tasks_dir).keys())

    # ── Run prompt-augmentation hooks for active phases ──
    hook_env["AUTOBUILDER_PHASES"] = ",".join(phases)
    augment_ctx = {
        "project_dir": str(project_dir),
        "project_id": project_id,
        "iteration": iteration,
        "phases": phases,
        "port": port,
        "executor": executor,
        "model": model,
        "review_mode": review_mode,
        "review_strategy": review_strategy.get("name", ""),
        "active_initiative": active_initiative,
    }
    aug_review = (
        run_augment_hook(project_dir, "augment-review", log_fh,
                         env=hook_env, context=augment_ctx)
        if "review" in phases else ""
    )
    aug_plan = (
        run_augment_hook(project_dir, "augment-plan", log_fh,
                         env=hook_env, context=augment_ctx)
        if "plan" in phases else ""
    )
    aug_execute = (
        run_augment_hook(project_dir, "augment-execute", log_fh,
                         env=hook_env, context=augment_ctx)
        if "execute" in phases else ""
    )

    # ── Review-phase prep: screenshot dir, dev server check, credentials ──
    screenshot_dir: Optional[str] = None
    screenshot_next_prefix: int = 1
    dev_server_url: Optional[str] = None
    test_credentials: Optional[tuple] = None

    if "review" in phases:
        # Create screenshot directory inside the active initiative
        ss_dir = (project_dir / ".autobuilder" / "initiatives"
                  / active_initiative / "review-screenshots")
        ss_dir.mkdir(parents=True, exist_ok=True)
        screenshot_dir = str(ss_dir.relative_to(project_dir))

        # Determine next screenshot prefix from existing files
        existing_prefixes = []
        for png in ss_dir.glob("*.png"):
            match = re.match(r"^(\d+)", png.name)
            if match:
                existing_prefixes.append(int(match.group(1)))
        if existing_prefixes:
            screenshot_next_prefix = max(existing_prefixes) + 1

        # Resolve dev server URL
        if port:
            dev_server_url = f"http://localhost:{port}"
            if is_port_open(port):
                log(log_fh, f"Dev server detected on port {port}.")
            else:
                log(log_fh, f"Dev server NOT detected on port {port} — "
                            "agent will need to start it.")

        # Load test credentials
        test_credentials = load_test_credentials(project_dir)
        if test_credentials:
            log(log_fh, f"Test credentials loaded (user: {test_credentials[0]})")

    # ── Generate memory-index for agent context ──
    if "review" in phases:
        try:
            mi_content = generate_memory_index(
                project_dir, tasks_dir, init_base, root_id=root_initiative,
            )
            mi_path = project_dir / ".autobuilder" / "memory-index.md"
            mi_path.parent.mkdir(parents=True, exist_ok=True)
            mi_path.write_text(mi_content, encoding="utf-8")
            log(log_fh, f"Memory index written ({len(mi_content)} chars)")
        except Exception as exc:
            log(log_fh, f"Warning: memory-index generation failed: {exc}")

    # ── Global executed summary (across all initiatives) ──
    global_executed = summarize_all_executed_tasks(
        init_base, max_recent=10, root_id=root_initiative,
    )

    # ── Browser session isolation ──
    browser_session = f"ab-{project_id}" if dev_server_url else None

    log(log_fh, "=" * 72)
    log(log_fh, f"BUILDER AGENT — Iteration {iteration}")
    log(log_fh, f"Phases:    {phases}")
    log(log_fh, f"Executor:  {executor}")
    log(log_fh, f"CWD:       {project_dir}")
    log(log_fh, f"Initiative: {active_initiative}")
    log(log_fh, f"Tasks dir:  {tasks_rel}")
    log(log_fh, "=" * 72)

    # Pre-iteration: close any leftover browser session from a previous iteration
    if browser_session:
        close_browser_session(browser_session, log_fh=log_fh)

    browser_snapshot = snapshot_browser_pids()

    phase_kwargs = dict(
        project_dir=project_dir,
        project_id=project_id,
        root_initiative=root_initiative,
        tasks_dir=tasks_dir,
        tasks_rel=tasks_rel,
        active_initiative=active_initiative,
        initiative_spec=initiative_spec,
        initiative_constraints=initiative_constraints,
        iteration=iteration,
        backlog=backlog,
        phases=phases,
        review_mode=review_mode,
        review_strategy=review_strategy,
        env_vars=env_vars,
        port=port,
        project_context=project_context,
        initiative_map=initiative_map,
        global_executed_summary=global_executed,
        augment_review=aug_review,
        augment_plan=aug_plan,
        augment_execute=aug_execute,
        screenshot_dir=screenshot_dir,
        screenshot_next_prefix=screenshot_next_prefix,
        dev_server_url=dev_server_url,
        test_credentials=test_credentials,
        max_tasks=max_tasks,
        browser_session=browser_session,
        early_complete=early_complete,
    )

    rc, phase_rcs = _run_phased(
        default_executor=executor, default_model=model,
        project_config=project_cfg,
        project_dir=project_dir,
        iteration=iteration, phases=phases,
        status_path=status_path, project_id=project_id,
        log_fh=log_fh, phase_kwargs=phase_kwargs,
    )
    jdata["rc"] = rc
    jdata["phase_rcs"] = phase_rcs

    # Post-iteration: gracefully close our session, then force-kill stragglers
    if browser_session:
        close_browser_session(browser_session, log_fh=log_fh)

    agent_pid = get_last_agent_pid()
    if agent_pid is not None:
        cleanup_browsers(agent_pid, log_fh, pre_snapshot=browser_snapshot)

    jdata["dur"] = int(_time.time() - t0)

    log(log_fh, "")
    log(log_fh, "╔" + "═" * 70 + "╗")
    log(log_fh, f"║  END OF AGENT RESPONSE — exit code: {rc}" + " " * (33 - len(str(rc))) + "║")
    log(log_fh, "╚" + "═" * 70 + "╝")
    log(log_fh, "")

    # ── post-agent hook (quality gate) ──
    post_agent_env = {**hook_env, "AUTOBUILDER_AGENT_EXIT_CODE": str(rc)}
    post_agent = run_hook(project_dir, "post-agent", log_fh, env=post_agent_env)
    if post_agent is not None and not post_agent.ok:
        log(log_fh, "post-agent hook failed — iteration work not finalized.")
        if post_agent.stdout.strip():
            backlog_path.write_text(
                f"POST-AGENT VALIDATION FAILED (iteration {iteration}):\n"
                f"{post_agent.stdout.strip()}\n",
                encoding="utf-8",
            )
            log(log_fh, f"Wrote hook failure details to {backlog_path.name}")
        write_status(status_path, project_id, iteration, "validation-failed",
                     f"post-agent hook failed (exit {post_agent.exit_code}). "
                     "Will retry next iteration.", project_dir=project_dir)
        return None

    normalize_task_filenames(tasks_dir)

    # ── Check for production-ready declaration ──
    production_ready_file = project_dir / ".autobuilder" / "PRODUCTION_READY.md"
    if production_ready_file.exists():
        log(log_fh, "")
        log(log_fh, "=" * 72)
        log(log_fh, "  PRODUCTION READY (will continue after cooldown)")
        try:
            summary = production_ready_file.read_text(encoding="utf-8")[:2000]
            log(log_fh, summary)
        except Exception:
            pass
        log(log_fh, "=" * 72)
        production_ready_file.unlink(missing_ok=True)
        log(log_fh, "Deleted PRODUCTION_READY.md — entering idle backoff.")
        raise NothingToImprove(journal_data=jdata)

    # ── Mark executed tasks ──
    all_tasks_after = load_tasks(tasks_dir)
    newly_executed: List[str] = []

    for tid, task in all_tasks_after.items():
        if task["planned"] and not task["split"] and not task["executed"]:
            if parse_bool(read_frontmatter(task["path"]).get("executed")):
                newly_executed.append(tid)
                continue

            num_match = re.match(r"^(\d{4})-", task["path"].name)
            if num_match:
                num_prefix = num_match.group(1)
                try:
                    git_result = subprocess.run(
                        ["git", "log", "--oneline", "-5", f"--grep={num_prefix}:"],
                        cwd=str(project_dir), capture_output=True, text=True, timeout=10,
                    )
                    if git_result.returncode == 0 and num_prefix in git_result.stdout:
                        mark_executed(task["path"])
                        newly_executed.append(tid)
                except Exception:
                    pass

    if newly_executed:
        log(log_fh, f"Marked {len(newly_executed)} task(s) as executed: {newly_executed}")
        previous_improvements.extend(newly_executed)
    jdata["executed"] = newly_executed

    # ── Detect new tasks from product review ──
    new_ids: Set[str] = set()
    if "review" in phases:
        after_ids = set(get_task_ids(tasks_dir).keys())
        new_ids = after_ids - before_ids
        jdata["created"] = sorted(new_ids)
        if new_ids:
            log(log_fh, f"Product review created {len(new_ids)} new task(s): {list(new_ids)}")
        elif rc != 0:
            log(log_fh, "Builder agent failed and created no new tasks.")
        else:
            raise NothingToImprove(journal_data=jdata)

    # ── Detect agent failure: non-zero exit with no useful work ──
    if rc != 0 and not newly_executed and not new_ids:
        raise AgentFailed(
            f"Agent exited with code {rc} and produced no work "
            f"(no tasks created, none executed)",
            journal_data=jdata,
        )

    write_status(status_path, project_id, iteration, "iteration-complete",
                 f"Iteration {iteration} complete.\n"
                 f"Executed: {newly_executed}\n"
                 f"Total improvements so far: {previous_improvements}",
                 project_dir=project_dir)
    log(log_fh, f"Iteration {iteration} complete.")
    return jdata


# ---------------------------------------------------------------------------
# Phased execution
# ---------------------------------------------------------------------------


def _log_phase_banner(log_fh, iteration: int, phase: str, cmd_display: str) -> None:
    log(log_fh, "")
    log(log_fh, "─" * 72)
    log(log_fh, f"  ITERATION {iteration} — PHASE: {phase.upper()}")
    log(log_fh, f"  Command: {cmd_display}")
    log(log_fh, "─" * 72)


def _log_prompt(log_fh, label: str, prompt: str) -> None:
    log(log_fh, "")
    log(log_fh, "╔" + "═" * 70 + "╗")
    pad = max(0, 70 - 2 - len(label))
    log(log_fh, "║  " + label + " " * pad + "║")
    log(log_fh, "╚" + "═" * 70 + "╝")
    for prompt_line in prompt.split("\n"):
        log(log_fh, prompt_line)
    log(log_fh, "╔" + "═" * 70 + "╗")
    log(log_fh, "║  END OF PROMPT — AGENT RESPONSE FOLLOWS" + " " * 29 + "║")
    log(log_fh, "╚" + "═" * 70 + "╝")
    log(log_fh, "")


def _chunk(items: List, size: int) -> List[List]:
    """Slice *items* into chunks of up to *size* elements (preserving order)."""
    if size <= 0:
        return [list(items)] if items else []
    return [items[i:i + size] for i in range(0, len(items), size)]


def _expand_phase_into_calls(
    *,
    phase: str,
    project_config: dict,
    tasks_dir: Path,
) -> List[Optional[List]]:
    """Return the list of task-subsets for each agent call in *phase*.

    - Returns ``[None]`` for the ``review`` phase (single call, no
      subset semantics).
    - Returns ``[None]`` when ``plan_batch_size`` / ``execute_batch_size``
      is ``None`` (legacy "all in one prompt" mode).
    - Returns ``[]`` when the phase has no work — the caller should
      skip the phase entirely.
    - Returns ``[[task1], [task2], ...]`` for per-task fanout.
    """
    if phase == "review":
        return [None]

    if phase == "plan":
        unplanned = find_all_unplanned(tasks_dir)
        if not unplanned:
            return []
        batch_size = project_config.get("plan_batch_size", 1)
        if batch_size is None:
            return [None]
        return _chunk(unplanned, int(batch_size))

    if phase == "execute":
        all_tasks = load_tasks(tasks_dir)
        already_executed = {tid for tid, t in all_tasks.items() if t["executed"]}
        planned_pending = find_planned_not_executed(tasks_dir)
        if not planned_pending:
            return []
        batch_size = project_config.get("execute_batch_size", 1)
        if batch_size is None:
            return [None]
        # Order tasks topologically so deps run first.
        sorted_ids = topological_sort(all_tasks, already_executed)
        pending_set = {tid for tid, _ in planned_pending}
        ordered = [
            (tid, all_tasks[tid]["path"])
            for tid in sorted_ids
            if tid in pending_set
        ]
        return _chunk(ordered, int(batch_size))

    return [None]


def _run_phased(
    *,
    default_executor: str,
    default_model: Optional[str],
    project_config: dict,
    project_dir: Path,
    iteration: int,
    phases: List[str],
    status_path: Path,
    project_id: str,
    log_fh,
    phase_kwargs: dict,
) -> tuple:
    """Run each phase as its own resumed agent call.

    By default plan and execute phases batch every pending task into a
    single agent call (``plan_batch_size`` / ``execute_batch_size`` =
    ``None`` in the project config — cheapest under per-token billing).
    Set either to ``1`` for one-task-per-prompt (strongest checkpoints
    but ~quadratic input-token amplification), or to a positive int
    ``N`` to bundle up to N tasks per call.

    Resolves ``(executor, model)`` per phase via ``phase_executors`` in
    project config. Phases sharing the same executor reuse one chat
    session and chain via ``--resume``; sub-calls within the same phase
    always share a session. When the executor changes between phases,
    a fresh chat session is opened for the new executor and the
    iteration header is re-sent.

    Returns ``(overall_rc, phase_rcs)``. ``phase_rcs`` is keyed by
    ``phase`` for single-call phases and by ``"<phase>:<idx>/<total>"``
    for fanned-out phases. ``overall_rc`` = first non-zero rc, else 0.
    """
    phase_rcs: Dict[str, int] = {}
    overall_rc = 0

    current_executor: Optional[str] = None
    chat_id: Optional[str] = None
    header_sent = False

    tasks_dir: Path = phase_kwargs["tasks_dir"]

    for phase in phases:
        sub_calls = _expand_phase_into_calls(
            phase=phase, project_config=project_config, tasks_dir=tasks_dir,
        )
        if not sub_calls:
            log(log_fh, f"Phase {phase}: no work — skipping.")
            continue

        phase_executor, phase_model = resolve_phase_executor(
            phase, project_config, default_executor, default_model,
        )

        # Open or reopen chat when the executor changes between phases.
        if phase_executor != current_executor:
            if current_executor is not None:
                log(log_fh,
                    f"Executor switched: {current_executor} → {phase_executor}. "
                    "Starting a fresh chat session — on-disk state (tasks, "
                    "memory-index, status) carries context across the switch.")
            chat_id = open_chat_session(
                phase_executor, str(project_dir), log_fh,
            )
            if chat_id:
                log(log_fh,
                    f"Opened {phase_executor} chat session: {chat_id}")
            elif phase_executor == "cursor":
                log(log_fh,
                    "WARNING: failed to open cursor chat session — "
                    "this phase will run as a fresh, unconnected call.")
            elif phase_executor == "codex":
                log(log_fh,
                    "Codex session id will be captured from this phase's "
                    "banner output.")
            elif phase_executor == "opencode":
                log(log_fh,
                    "OpenCode session id will be captured from the "
                    "first JSON event of this phase.")
            current_executor = phase_executor
            header_sent = False

        total_calls = len(sub_calls)
        if total_calls > 1:
            log(log_fh,
                f"Phase {phase}: fanning out into {total_calls} per-task "
                f"agent call(s).")

        phase_aborted = False

        for call_idx, subset in enumerate(sub_calls, start=1):
            task_progress = (call_idx, total_calls) if total_calls > 1 else None

            # Subset-aware label for status / banner.
            if subset:
                ids = [tid for tid, _ in subset]
                subset_label = ",".join(ids[:3])
                if len(ids) > 3:
                    subset_label += f"+{len(ids) - 3}"
            else:
                subset_label = ""

            prompt = build_phase_prompt(
                phase=phase,
                include_header=not header_sent,
                task_subset=subset,
                task_progress=task_progress,
                **phase_kwargs,
            )

            cmd = build_phase_cmd(
                executor=phase_executor, chat_id=chat_id,
                model=phase_model, prompt=prompt,
                is_new_session=not header_sent,
            )
            cmd_display = " ".join(shlex.quote(c) for c in cmd[:6]) + " ..."

            model_tag = f" model={phase_model}" if phase_model else ""
            progress_str = (
                f" ({call_idx}/{total_calls}"
                + (f": {subset_label}" if subset_label else "")
                + ")"
                if total_calls > 1
                else ""
            )
            write_status(
                status_path, project_id, iteration,
                f"running-{phase}",
                f"Running {phase} phase{progress_str} via "
                f"{phase_executor}{model_tag} (chat {chat_id or 'pending'})...",
                project_dir=project_dir,
            )

            banner_label = f"{phase}{progress_str} ({phase_executor}{model_tag})"
            _log_phase_banner(log_fh, iteration, banner_label, cmd_display)
            _log_prompt(
                log_fh,
                f"PROMPT — phase: {phase}{progress_str}",
                prompt,
            )

            # Some executors mint the session id only on first run and
            # echo it on stdout — codex in its banner, opencode on every
            # JSON event. We scrape it through a line callback so we can
            # ``--resume`` on the next sub-call within the phase.
            captured_session_id: List[Optional[str]] = [None]

            def _line_cb(line: str) -> None:
                if chat_id is not None:
                    return
                if phase_executor == "codex":
                    sid = scan_codex_session_line(line)
                elif phase_executor == "opencode":
                    sid = scan_opencode_session_line(line)
                else:
                    return
                if sid and captured_session_id[0] is None:
                    captured_session_id[0] = sid

            needs_session_scrape = phase_executor in ("codex", "opencode")
            rc = run_streaming(
                cmd, cwd=str(project_dir), log_fh=log_fh,
                hard_timeout=PHASE_HARD_TIMEOUT,
                label=f"builder-{phase}",
                line_callback=_line_cb if needs_session_scrape else None,
            )

            rc_key = (
                f"{phase}:{call_idx}/{total_calls}"
                if total_calls > 1 else phase
            )
            phase_rcs[rc_key] = rc
            if rc != 0 and overall_rc == 0:
                overall_rc = rc

            if (needs_session_scrape
                    and chat_id is None
                    and captured_session_id[0]):
                chat_id = captured_session_id[0]
                log(log_fh,
                    f"Captured {phase_executor} session id: {chat_id}")
            elif (needs_session_scrape
                    and chat_id is None
                    and call_idx == 1):
                log(log_fh,
                    f"WARNING: did not capture {phase_executor} session "
                    f"id from {phase} phase output — subsequent sub-calls "
                    "will start fresh.")

            header_sent = True

            if rc != 0:
                log(log_fh,
                    f"Phase {phase}{progress_str} exited with code {rc} "
                    f"— stopping further work for this iteration.")
                phase_aborted = True
                break

        if phase_aborted:
            break

    return overall_rc, phase_rcs
