"""Claude-based split planning.

Uses a lightweight ``claude --print`` call (default: sonnet) to analyze
the directive and produce a JSON split strategy.
"""

from __future__ import annotations

import asyncio
import glob as globmod
import json
import logging
import os
from pathlib import Path

from cleave.orchestrator.config import OrchestratorConfig
from cleave.orchestrator.errors import PlanningFailedError
from cleave.orchestrator.prompt import build_planner_prompt

logger = logging.getLogger(__name__)


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

    Calls claude with a planning prompt using the planner_model (lighter, faster)
    and parses the JSON response into a split strategy.

    Returns a dict with:
        - children: list[dict] each with label, description, scope
        - rationale: str
    """
    prompt = build_planner_prompt(
        directive=config.directive,
        repo_path=config.repo_path,
        success_criteria=config.success_criteria,
        allowed_tools=config.allowed_tools,
        child_timeout_seconds=config.child_timeout_seconds,
        child_budget_usd=config.child_budget_usd,
    )

    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 planning phase with model=%s", config.planner_model)
    if config.verbose:
        logger.debug("Planner prompt:\n%s", prompt[:500])

    # Strip CLAUDECODE to prevent "nested session" refusal when
    # the orchestrator is itself running inside a Claude Code session.
    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=300,  # 5 min max for planning
        )
    except TimeoutError as exc:
        proc.kill()
        await proc.communicate()
        raise PlanningFailedError("Planning phase timed out after 300s") 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 PlanningFailedError(f"Planner exited with code {proc.returncode}: {stderr}")

    return parse_plan_response(stdout)


def parse_plan_response(response: str) -> dict:
    """Parse the planner's JSON response.

    Handles common issues like markdown fences wrapping the JSON.
    """
    text = response.strip()

    # Strip markdown code fences if present
    if text.startswith("```"):
        lines = text.splitlines()
        # Remove first line (```json or ```)
        lines = lines[1:]
        # Remove last line if it's ```)
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()

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

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

    children = data.get("children", [])
    if not children or not isinstance(children, list):
        raise PlanningFailedError("Planner response has no 'children' array")

    if len(children) < 2:
        raise PlanningFailedError(
            f"Planner produced {len(children)} child(ren) — minimum is 2 for parallel decomposition"
        )

    if len(children) > 4:
        logger.warning(
            "Planner produced %d children (max recommended: 4), truncating to 4",
            len(children),
        )
        children = children[:4]
        data["children"] = children

    # Validate each child has required fields
    for i, child in enumerate(children):
        if not isinstance(child, dict):
            raise PlanningFailedError(f"Child {i} is not a dict")
        if "label" not in child:
            raise PlanningFailedError(f"Child {i} missing 'label'")
        if "description" not in child:
            raise PlanningFailedError(f"Child {i} missing 'description'")

    # Normalize
    for child in children:
        child.setdefault("scope", [])
        child.setdefault("depends_on", [])
        # Ensure label is kebab-case
        label = child["label"].lower().replace(" ", "-").replace("_", "-")
        child["label"] = label[:40]
        # Ensure depends_on is a list of strings
        if not isinstance(child["depends_on"], list):
            child["depends_on"] = []
        child["depends_on"] = [str(d) for d in child["depends_on"]]

    # Validate depends_on references
    all_labels = {c["label"] for c in children}
    for child in children:
        # Strip self-dependencies
        child["depends_on"] = [d for d in child["depends_on"] if d != child["label"]]
        # Prune unknown references
        unknown = [d for d in child["depends_on"] if d not in all_labels]
        if unknown:
            logger.warning(
                "Child %s depends_on unknown labels %s — pruning",
                child["label"], unknown,
            )
            child["depends_on"] = [d for d in child["depends_on"] if d in all_labels]

    # Cycle detection via Kahn's algorithm
    _detect_and_break_cycles(children)

    logger.info("Plan produced %d children: %s", len(children), [c["label"] for c in children])
    return data


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

    # Kahn's: start with nodes that have no incoming edges
    queue = [label for label, deg in in_degree.items() if deg == 0]
    visited: set[str] = set()

    while queue:
        node = queue.pop(0)
        visited.add(node)
        # Find children that depend on this node
        for child in children:
            if node in child["depends_on"]:
                in_degree[child["label"]] -= 1
                if in_degree[child["label"]] == 0:
                    queue.append(child["label"])

    # Any nodes not visited are in a cycle
    cyclic = set(in_degree.keys()) - visited
    if cyclic:
        logger.warning(
            "Dependency cycle detected among %s — clearing their depends_on",
            sorted(cyclic),
        )
        for label in cyclic:
            label_to_child[label]["depends_on"] = []


def validate_scopes(plan: dict, repo_path: Path) -> list[str]:
    """Validate planner scope patterns against the filesystem.

    Returns a list of warning strings. Does not raise — scopes may
    reference files to be created by the child.

    Checks:
    1. Each scope pattern is globbed against the repo; warns if no matches.
    2. Exact scope string overlap between children is flagged.
    """
    warnings: list[str] = []
    children = plan.get("children", [])
    repo_str = str(repo_path)

    # Check each scope pattern for filesystem matches
    for child in children:
        label = child.get("label", "?")
        for pattern in child.get("scope", []):
            full_pattern = str(Path(repo_str) / pattern)
            matches = globmod.glob(full_pattern, recursive=True)
            if not matches:
                warnings.append(
                    f"Child '{label}' scope '{pattern}' matches no existing files "
                    f"(may reference files to be created)"
                )

    # Check for exact scope overlap between children
    scope_owners: dict[str, list[str]] = {}
    for child in children:
        label = child.get("label", "?")
        for pattern in child.get("scope", []):
            scope_owners.setdefault(pattern, []).append(label)

    for pattern, owners in scope_owners.items():
        if len(owners) > 1:
            warnings.append(
                f"Scope '{pattern}' claimed by multiple children: {owners}"
            )

    return warnings
