"""Architect-level phase decomposition via Claude CLI.

Uses ``claude --print`` (default: opus) to analyze the directive and
produce a DAG of phases, each of which becomes a ``cleave run`` invocation.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from pathlib import Path

from cleave.architect.config import ArchitectConfig
from cleave.architect.errors import PlanningError

logger = logging.getLogger(__name__)

_MAX_CLAUDE_MD_CHARS = 12000
_MAX_TREE_ENTRIES = 100


def _truncate(content: str, max_chars: int, label: str) -> str:
    if len(content) <= max_chars:
        return content
    logger.warning("%s truncated from %d to %d chars", label, len(content), max_chars)
    return content[:max_chars] + f"\n\n[... truncated at {max_chars} chars]"


def build_architect_prompt(config: ArchitectConfig) -> str:
    """Build the prompt for architect-level phase planning."""
    criteria_block = ""
    if config.success_criteria:
        criteria_block = "\n\nSuccess criteria:\n" + "\n".join(
            f"- {c}" for c in config.success_criteria
        )

    # Deeper repo context than orchestrator planner
    tree_lines: list[str] = []
    try:
        for item in sorted(config.repo_path.rglob("*")):
            rel = item.relative_to(config.repo_path)
            parts = rel.parts
            # Skip hidden dirs and common noise
            if any(p.startswith(".") for p in parts):
                continue
            if any(p in ("node_modules", "__pycache__", ".venv", "venv", "dist", "build") for p in parts):
                continue
            depth = len(parts) - 1
            if depth > 3:
                continue
            prefix = "dir " if item.is_dir() else "    "
            indent = "  " * depth
            tree_lines.append(f"  {indent}{prefix}{item.name}")
            if len(tree_lines) >= _MAX_TREE_ENTRIES:
                tree_lines.append("  ... (truncated)")
                break
    except OSError:
        tree_lines.append("  (unable to list directory)")

    tree_block = "\n".join(tree_lines)

    # Read CLAUDE.md
    claude_md = config.repo_path / "CLAUDE.md"
    claude_context = ""
    if claude_md.exists():
        try:
            text = claude_md.read_text(encoding="utf-8").strip()
        except OSError:
            text = ""
        if text:
            text = _truncate(text, _MAX_CLAUDE_MD_CHARS, "CLAUDE.md")
            claude_context = f"\n\nRepository CLAUDE.md:\n{text}\n"

    return f"""\
You are a software architect decomposing a large project into sequential phases.
Each phase will be executed as an independent ``cleave run`` (autonomous multi-agent
task that can split work further into 2-4 parallel children).

Directive: {config.directive}{criteria_block}

Repository: {config.repo_path.name}
{claude_context}
Repository structure:
{tree_block}

Phase execution constraints:
- Each phase runs as a ``cleave run`` with its own budget (${config.phase_budget_usd:.2f})
  and can internally split into up to {config.max_parallel_children} parallel children
- Phases execute sequentially in dependency order (v1: one at a time)
- Each phase has access to: Bash, Edit, Read, Write, Glob, Grep
- No MCP servers, web access, or external services during execution
- The repo state after one phase is the starting state for the next

Respond with ONLY a JSON object (no markdown fences, no explanation):

{{
  "phases": [
    {{
      "id": "phase-1-short-descriptor",
      "label": "Human-readable phase name",
      "description": "Detailed description of what this phase should accomplish. Must be self-contained enough to serve as a cleave directive — include specific files, APIs, patterns to create/modify.",
      "phase_type": "cleave_run",
      "depends_on": ["phase-id-that-must-finish-first"],
      "success_criteria": ["criterion 1", "criterion 2"]
    }}
  ],
  "rationale": "Brief explanation of the decomposition strategy and phase ordering"
}}

Rules:
- Produce 2-{config.max_phases} phases
- Use depends_on to express ordering when one phase's output feeds another
- Independent phases may share no depends_on (enabling future parallel execution)
- Phase IDs must be unique, kebab-case, prefixed with "phase-N-"
- Each phase description must be self-contained: a developer reading only that
  description should understand what to build, without needing other phase descriptions
- Reason about API contracts between phases: if phase 2 depends on types defined in
  phase 1, phase 2's description must specify what types/interfaces it expects
- Earlier phases should establish foundations (types, schemas, configs) before later
  phases build features on them
- The final phase should integrate and verify the complete system
"""


async def plan_phases(
    claude_cli: str,
    config: ArchitectConfig,
) -> dict:
    """Run the architect planning phase via claude --print.

    Returns a dict with:
        - phases: list[dict] each with id, label, description, depends_on, etc.
        - rationale: str
    """
    prompt = build_architect_prompt(config)

    args = [
        claude_cli,
        "--print",
        "--output-format", "text",
        "--model", config.planner_model,
        "--max-turns", "2",
        "--permission-mode", "plan",
        "--no-session-persistence",
        "--disable-slash-commands",
    ]

    logger.info("Running architect planning with model=%s", config.planner_model)
    if config.verbose:
        logger.debug("Architect prompt:\n%s", prompt[:1000])

    planner_env = dict(os.environ)
    planner_env.pop("CLAUDECODE", None)

    proc = await asyncio.create_subprocess_exec(
        *args,
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=str(config.repo_path),
        env=planner_env,
    )

    if proc.stdin is not None:
        proc.stdin.write(prompt.encode("utf-8"))
        await proc.stdin.drain()
        proc.stdin.close()

    try:
        stdout_bytes, stderr_bytes = await asyncio.wait_for(
            proc.communicate(),
            timeout=600,  # 10 min max for architect planning
        )
    except TimeoutError as exc:
        proc.kill()
        await proc.communicate()
        raise PlanningError("Architect planning timed out after 600s") from exc

    stdout = stdout_bytes.decode("utf-8", errors="replace").strip() if stdout_bytes else ""
    stderr = stderr_bytes.decode("utf-8", errors="replace").strip() if stderr_bytes else ""

    if proc.returncode != 0:
        raise PlanningError(f"Architect planner exited with code {proc.returncode}: {stderr}")

    return parse_plan_response(stdout, max_phases=config.max_phases)


def parse_plan_response(response: str, max_phases: int = 10) -> dict:
    """Parse the architect planner's JSON response."""
    text = response.strip()

    # Strip markdown code fences
    if text.startswith("```"):
        lines = text.splitlines()
        lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()

    try:
        data = json.loads(text)
    except json.JSONDecodeError as e:
        start = text.find("{")
        end = text.rfind("}")
        if start >= 0 and end > start:
            try:
                data = json.loads(text[start:end + 1])
            except json.JSONDecodeError:
                raise PlanningError(
                    f"Could not parse architect response as JSON: {e}\nResponse: {text[:500]}"
                ) from e
        else:
            raise PlanningError(f"No JSON found in architect response: {text[:500]}") from e

    if not isinstance(data, dict):
        raise PlanningError(f"Architect response is not a JSON object: {type(data)}")

    phases = data.get("phases", [])
    if not phases or not isinstance(phases, list):
        raise PlanningError("Architect response has no 'phases' array")

    if len(phases) < 2:
        raise PlanningError(
            f"Architect produced {len(phases)} phase(s) — minimum is 2"
        )

    if len(phases) > max_phases:
        logger.warning(
            "Architect produced %d phases (max: %d), truncating", len(phases), max_phases
        )
        phases = phases[:max_phases]
        data["phases"] = phases

    # Validate and normalize each phase
    for i, phase in enumerate(phases):
        if not isinstance(phase, dict):
            raise PlanningError(f"Phase {i} is not a dict")
        if "id" not in phase:
            # Auto-generate ID from label or index
            label = phase.get("label", f"phase-{i + 1}")
            phase["id"] = f"phase-{i + 1}-{label.lower().replace(' ', '-')[:30]}"
        if "label" not in phase:
            phase["label"] = phase["id"]
        if "description" not in phase:
            raise PlanningError(f"Phase {i} ('{phase.get('id', '?')}') missing 'description'")

    # Normalize
    for phase in phases:
        phase.setdefault("phase_type", "cleave_run")
        phase.setdefault("depends_on", [])
        phase.setdefault("success_criteria", [])
        # Normalize ID: kebab-case
        phase["id"] = phase["id"].lower().replace(" ", "-").replace("_", "-")[:50]
        # Ensure depends_on is a list of strings
        if not isinstance(phase["depends_on"], list):
            phase["depends_on"] = []
        phase["depends_on"] = [str(d) for d in phase["depends_on"]]

    # Validate depends_on references
    all_ids = {p["id"] for p in phases}
    for phase in phases:
        phase["depends_on"] = [d for d in phase["depends_on"] if d != phase["id"]]
        unknown = [d for d in phase["depends_on"] if d not in all_ids]
        if unknown:
            logger.warning(
                "Phase %s depends_on unknown IDs %s — pruning", phase["id"], unknown
            )
            phase["depends_on"] = [d for d in phase["depends_on"] if d in all_ids]

    # Cycle detection
    _detect_and_break_cycles(phases)

    logger.info("Architect plan: %d phases: %s", len(phases), [p["id"] for p in phases])
    return data


def _detect_and_break_cycles(phases: list[dict]) -> None:
    """Detect dependency cycles via Kahn's algorithm; clear deps on cyclic nodes."""
    id_to_phase = {p["id"]: p for p in phases}
    in_degree: dict[str, int] = {p["id"]: 0 for p in phases}
    for phase in phases:
        for dep in phase["depends_on"]:
            if dep in in_degree:
                in_degree[phase["id"]] = in_degree.get(phase["id"], 0) + 1

    queue = [pid for pid, deg in in_degree.items() if deg == 0]
    visited: set[str] = set()

    while queue:
        node = queue.pop(0)
        visited.add(node)
        for phase in phases:
            if node in phase["depends_on"]:
                in_degree[phase["id"]] -= 1
                if in_degree[phase["id"]] == 0:
                    queue.append(phase["id"])

    cyclic = set(in_degree.keys()) - visited
    if cyclic:
        logger.warning(
            "Dependency cycle detected among %s — clearing their depends_on",
            sorted(cyclic),
        )
        for pid in cyclic:
            id_to_phase[pid]["depends_on"] = []
