from __future__ import annotations

import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from math import fsum
from pathlib import Path
from typing import Any, Callable


ROOT = Path(__file__).resolve().parents[2]


def resolve_python_interpreter() -> list[str]:
    """Prefer the project Python 3.13 interpreter on Windows.

    The repo's working `python` command may point at an older interpreter, but
    the build and validation scripts depend on the package set installed under
    Python 3.13. Fall back to sys.executable only if the launcher is unavailable.
    """
    configured = os.environ.get("CODEX_PYTHON")
    if configured:
        return [configured]
    if os.name == "nt":
        for candidate in (
            r"C:\Users\kjh20\AppData\Local\Programs\Python\Python313\python.exe",
            r"C:\Users\kjh20\AppData\Local\Python\pythoncore-3.13-64\python.exe",
        ):
            if Path(candidate).exists():
                return [candidate]
        return ["py", "-3.13"]
    return [sys.executable]


def utf8_env() -> dict[str, str]:
    env = os.environ.copy()
    env.setdefault("PYTHONIOENCODING", "utf-8")
    env.setdefault("PYTHONUTF8", "1")
    return env


def _run_command(command: list[str], cwd: Path | None = None) -> dict[str, Any]:
    started = datetime.now(timezone.utc)
    resolved = list(command)
    if os.name == "nt" and resolved and resolved[0].lower() == "npm":
        resolved[0] = "npm.cmd"
    if resolved and resolved[0].endswith(".py"):
        resolved = [*resolve_python_interpreter(), *resolved]
    subprocess.run(resolved, cwd=cwd or ROOT, check=True, env=utf8_env())
    finished = datetime.now(timezone.utc)
    return {
        "kind": "command",
        "command": command,
        "started_at": started.isoformat(),
        "finished_at": finished.isoformat(),
        "elapsed_sec": round((finished - started).total_seconds(), 3),
        "status": "OK",
    }


def _run_callable(func: Callable[[], Any]) -> dict[str, Any]:
    started = datetime.now(timezone.utc)
    payload = func()
    finished = datetime.now(timezone.utc)
    return {
        "kind": "callable",
        "started_at": started.isoformat(),
        "finished_at": finished.isoformat(),
        "elapsed_sec": round((finished - started).total_seconds(), 3),
        "status": "OK",
        "payload": payload,
    }


def _parse_iso8601(value: str) -> datetime:
    text = str(value or "")
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    return datetime.fromisoformat(text)


def summarize_plan(results: list[dict[str, Any]]) -> dict[str, Any]:
    if not results:
        return {
            "step_count": 0,
            "command_step_count": 0,
            "callable_step_count": 0,
            "critical_path_sec": 0.0,
            "wall_clock_sec": 0.0,
            "parallel_width_max": 0,
            "step_names": [],
        }

    by_name = {str(item.get("name")): item for item in results if item.get("name")}
    memo: dict[str, float] = {}

    def critical_path_for(name: str) -> float:
        if name in memo:
            return memo[name]
        item = by_name[name]
        own = float(item.get("elapsed_sec") or 0.0)
        deps = [str(dep) for dep in (item.get("depends_on") or []) if str(dep) in by_name]
        if deps:
            own += max(critical_path_for(dep) for dep in deps)
        memo[name] = own
        return own

    starts = []
    ends = []
    points: list[tuple[datetime, int]] = []
    command_step_count = 0
    callable_step_count = 0
    for item in results:
        started = _parse_iso8601(str(item.get("started_at")))
        finished = _parse_iso8601(str(item.get("finished_at")))
        starts.append(started)
        ends.append(finished)
        points.append((started, 1))
        points.append((finished, -1))
        if item.get("kind") == "command":
            command_step_count += 1
        elif item.get("kind") == "callable":
            callable_step_count += 1

    points.sort(key=lambda pair: (pair[0], -pair[1]))
    active = 0
    parallel_width_max = 0
    for _, delta in points:
        active += delta
        if active > parallel_width_max:
            parallel_width_max = active

    step_elapsed_sum = fsum(float(item.get("elapsed_sec") or 0.0) for item in results)
    wall_clock_sec = round((max(ends) - min(starts)).total_seconds(), 3)
    critical_path_sec = round(max(critical_path_for(name) for name in by_name), 3)
    return {
        "step_count": len(results),
        "command_step_count": command_step_count,
        "callable_step_count": callable_step_count,
        "step_elapsed_sum_sec": round(step_elapsed_sum, 3),
        "critical_path_sec": critical_path_sec,
        "wall_clock_sec": wall_clock_sec,
        "parallel_width_max": parallel_width_max,
        "step_names": [str(item.get("name")) for item in results if item.get("name")],
    }


def run_plan(
    steps: list[dict[str, Any]],
    *,
    label: str = "orchestration",
    cwd: Path | None = None,
) -> list[dict[str, Any]]:
    """
    Execute a dependency plan.

    Each step accepts:
    - name: unique string
    - depends_on: list[str]
    - command: list[str] for subprocess execution, or
    - callable: zero-arg callable for in-process execution
    """
    if not steps:
        return []

    step_map: dict[str, dict[str, Any]] = {}
    dependents: dict[str, set[str]] = {}
    remaining: set[str] = set()
    for index, step in enumerate(steps):
        name = str(step.get("name") or "").strip()
        if not name:
            raise ValueError(f"{label}: step missing name at index {index}")
        if name in step_map:
            raise ValueError(f"{label}: duplicate step name: {name}")
        step_map[name] = dict(step)
        step_map[name]["_index"] = index
        remaining.add(name)
        dependents.setdefault(name, set())

    for name, step in step_map.items():
        deps = step.get("depends_on") or []
        if not isinstance(deps, list):
            raise ValueError(f"{label}: depends_on must be a list for {name}")
        step["_depends_on"] = {str(dep) for dep in deps}
        for dep in step["_depends_on"]:
            if dep not in step_map:
                raise ValueError(f"{label}: unknown dependency {dep} for {name}")
            dependents.setdefault(dep, set()).add(name)

    completed: set[str] = set()
    results: dict[str, dict[str, Any]] = {}
    while remaining:
        ready = sorted(
            [name for name in remaining if step_map[name]["_depends_on"].issubset(completed)],
            key=lambda n: step_map[n]["_index"],
        )
        if not ready:
            blocked = sorted(remaining)
            raise RuntimeError(f"{label}: cyclic or blocked dependencies: {blocked}")

        with ThreadPoolExecutor(max_workers=len(ready)) as executor:
            future_map = {}
            for name in ready:
                step = step_map[name]
                if "command" in step and step["command"] is not None:
                    future = executor.submit(_run_command, list(step["command"]), cwd)
                elif "callable" in step and step["callable"] is not None:
                    future = executor.submit(_run_callable, step["callable"])
                else:
                    raise ValueError(f"{label}: step {name} missing command/callable")
                future_map[future] = name

            for future in as_completed(future_map):
                name = future_map[future]
                result = future.result()
                result["name"] = name
                result["depends_on"] = list(step_map[name]["_depends_on"])
                result["index"] = step_map[name]["_index"]
                results[name] = result
                completed.add(name)
                remaining.remove(name)
                for child in dependents.get(name, set()):
                    if child in step_map:
                        step_map[child]["_depends_on"].discard(name)

    ordered = sorted(results.values(), key=lambda item: item["index"])
    for item in ordered:
        item.pop("index", None)
    return ordered
