import importlib.util
import json
import os
import subprocess
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock


SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "select_execution_target.py"
SPEC = importlib.util.spec_from_file_location("select_execution_target_test", SCRIPT)
selector = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = selector
SPEC.loader.exec_module(selector)


def catalog_value() -> dict:
    targets = {
        "first": {
            "agent": "agent-one",
            "model": "model-one",
            "execution_class": "local_model",
            "selfcheck_required": True,
            "runtime": {"command": ["agent-one", "{prompt}"]},
        },
        "second": {
            "agent": "agent-two",
            "model": "model-two",
            "execution_class": "cloud_model",
            "runtime": {"command": ["agent-two", "--model", "{model}", "{prompt}"]},
        },
    }
    routes = {"worker": {}, "review": {}}
    for stage in routes:
        for lane in ("local", "cloud"):
            for grade in range(1, 11):
                routes[stage][f"{lane}-G{grade:02d}"] = {
                    "candidates": ["first", "second"],
                    "rule_id": f"{stage}-{lane}-{grade:02d}",
                    "reason_codes": ["runtime-catalog"],
                }
    return {"schema_version": "1.0", "targets": targets, "routes": routes}


def write_catalog(root: Path, value: dict | None = None) -> Path:
    path = root / "catalog.json"
    path.write_text(json.dumps(value or catalog_value()), encoding="utf-8")
    return path


def write_task(
    root: Path,
    *,
    kind: str = "PLAN",
    lane: str = "cloud",
    grade: int = 5,
    task: str = "group/01_task",
    milestone_task: str | None = None,
) -> Path:
    milestone = f" milestone-task={milestone_task}" if milestone_task else ""
    path = root / f"{kind}-{lane}-G{grade:02d}.md"
    path.write_text(
        f"<!-- task={task} plan=0 tag=API{milestone} -->\n\n# Task\n",
        encoding="utf-8",
    )
    return path


class SelectorTests(unittest.TestCase):
    def test_bundled_catalog_is_used_by_default(self):
        with TemporaryDirectory() as tmp, mock.patch.dict(os.environ, {}, clear=True):
            task = write_task(Path(tmp))
            result = selector.select_execution_target(task)
        self.assertEqual(result["selected"]["target_id"], "opencode-glm-max")
        self.assertEqual(result["selected"]["agent"], "opencode")
        self.assertEqual(result["selected"]["model"], "glm-5.2")
        self.assertEqual(result["selected"]["reasoning_effort"], "high")
        self.assertEqual(
            result["catalog"]["source"],
            str(selector.DEFAULT_CATALOG_PATH.resolve()),
        )

    def test_bundled_catalog_preserves_operator_route_matrix(self):
        expected_worker = {
            **{
                f"local-G{grade:02d}": ["pi-ornith-high"]
                for grade in range(1, 7)
            },
            "local-G04": ["pi-ornith-fast-high"],
            "local-G07": ["opencode-glm-max", "codex-terra-high"],
            "local-G08": ["opencode-glm-max", "codex-terra-high"],
            "local-G09": ["codex-sol-high", "codex-terra-high"],
            "local-G10": ["codex-sol-high", "codex-terra-high"],
            "cloud-G01": ["codex-spark-xhigh", "opencode-glm-medium", "codex-terra-high"],
            "cloud-G02": ["codex-spark-xhigh", "opencode-glm-medium", "codex-terra-high"],
            "cloud-G03": ["opencode-glm-high", "codex-terra-high"],
            "cloud-G04": ["opencode-glm-high", "codex-terra-high"],
            "cloud-G05": ["opencode-glm-max", "codex-terra-high"],
            "cloud-G06": ["opencode-glm-max", "codex-terra-high"],
            "cloud-G07": ["codex-sol-medium", "codex-terra-high"],
            "cloud-G08": ["codex-sol-medium", "codex-terra-high"],
            "cloud-G09": ["codex-sol-high", "codex-terra-high"],
            "cloud-G10": ["codex-sol-high", "codex-terra-high"],
        }
        expected_targets = {
            "pi-ornith-high",
            "pi-ornith-fast-high",
            "opencode-glm-medium",
            "opencode-glm-high",
            "opencode-glm-max",
            "codex-spark-xhigh",
            "codex-sol-medium",
            "codex-sol-high",
            "codex-sol-xhigh",
            "codex-terra-high",
        }
        with mock.patch.dict(os.environ, {}, clear=True):
            catalog = selector.load_runtime_catalog()
        self.assertEqual(set(catalog.targets), expected_targets)
        for route_id, expected in expected_worker.items():
            lane, raw_grade = route_id.split("-G")
            decision = selector.policy.select_policy(
                catalog=catalog,
                stage="worker",
                lane=lane,
                grade=int(raw_grade),
                evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            )
            self.assertEqual(
                [target.catalog_id for target in decision.candidates],
                expected,
                route_id,
            )
        for lane in ("local", "cloud"):
            for grade in range(1, 11):
                expected = (
                    ["codex-terra-high"]
                    if grade <= 4
                    else ["codex-sol-medium"]
                    if grade <= 8
                    else ["codex-sol-high"]
                )
                decision = selector.policy.select_policy(
                    catalog=catalog,
                    stage="review",
                    lane=lane,
                    grade=grade,
                    evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
                )
                self.assertEqual(
                    [target.catalog_id for target in decision.candidates],
                    expected,
                )
        night = selector.policy.select_policy(
            catalog=catalog,
            stage="worker",
            lane="local",
            grade=7,
            evaluated_at=datetime(2026, 1, 1, 16, tzinfo=timezone.utc),
        )
        self.assertEqual(night.rule_id, "worker-local-g07-kst-night-catalog")
        self.assertEqual(night.reason_codes, ("worker_catalog_lane_kst_night",))

    def test_bundled_catalog_preserves_operator_runtime_contracts(self):
        with mock.patch.dict(os.environ, {}, clear=True):
            catalog = selector.load_runtime_catalog()
        pi = catalog.targets["pi-ornith-high"]
        self.assertEqual((pi.agent, pi.model), ("pi", "ornith:35b"))
        self.assertEqual(pi.reasoning_effort, "high")
        self.assertTrue(pi.selfcheck_required)
        self.assertIn("--thinking", pi.runtime["command"])
        self.assertIn("{reasoning_effort}", pi.runtime["command"])
        self.assertTrue(pi.runtime["native_session_monitor"])
        self.assertEqual(pi.runtime["terminal_success"], "agent_end")
        self.assertIn("--session", pi.runtime["resume_command"])
        self.assertIn("{resume_session_dir}", pi.runtime["resume_command"])
        self.assertIn("{session_id}", pi.runtime["session_path"])
        opencode = catalog.targets["opencode-glm-max"]
        self.assertIn("iop-glm/glm-5.2", opencode.runtime["command"])
        self.assertEqual(opencode.reasoning_effort, "high")
        self.assertTrue(opencode.completion_selfcheck_on_incomplete)
        self.assertIn("{reasoning_effort}", opencode.runtime["command"])
        for target_id in (
            "opencode-glm-medium",
            "opencode-glm-high",
            "opencode-glm-max",
        ):
            with self.subTest(target_id=target_id):
                runtime = catalog.targets[target_id].runtime
                self.assertEqual(
                    runtime["environment"]["TMPDIR"],
                    "/tmp",
                )
                self.assertTrue(runtime["session_stall_resume"])
                self.assertIn("--session", runtime["resume_command"])
                self.assertIn("{resume_session}", runtime["resume_command"])
        terra = catalog.targets["codex-terra-high"]
        self.assertEqual(terra.reasoning_effort, "high")
        self.assertIn(
            'model_reasoning_effort="{reasoning_effort}"',
            terra.runtime["command"],
        )
        sol_medium = catalog.targets["codex-sol-medium"]
        self.assertEqual(sol_medium.reasoning_effort, "medium")
        self.assertEqual(sol_medium.runtime["terminal_success"], "turn_completed")
        sol_high = catalog.targets["codex-sol-high"]
        self.assertEqual(sol_high.reasoning_effort, "high")
        self.assertEqual(sol_high.runtime["terminal_success"], "turn_completed")
        sol_xhigh = catalog.targets["codex-sol-xhigh"]
        self.assertEqual(sol_xhigh.reasoning_effort, "xhigh")
        self.assertEqual(sol_xhigh.runtime["terminal_success"], "turn_completed")

    def test_initial_decision_contains_catalog_evidence_and_no_quota(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            catalog = write_catalog(root)
            result = selector.select_execution_target(
                write_task(root),
                catalog_path=catalog,
                evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            )
        self.assertEqual(result["schema_version"], "3.0")
        self.assertEqual(result["selected"]["target_id"], "first")
        self.assertEqual(result["selected"]["agent"], "agent-one")
        self.assertEqual(result["selected"]["model"], "model-one")
        self.assertEqual(result["catalog"]["source"], str(catalog.resolve()))
        self.assertEqual([item["target_id"] for item in result["candidates"]], ["first", "second"])
        self.assertEqual(result["decision"]["timezone"], "Asia/Seoul")
        self.assertEqual(
            datetime.fromisoformat(result["decision"]["evaluated_at"]).utcoffset(),
            timedelta(hours=9),
        )
        self.assertNotIn("quota", result)
        self.assertTrue(all("quota_status" not in item for item in result["candidates"]))

    def test_catalog_can_be_injected_by_environment(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            catalog = write_catalog(root)
            with mock.patch.dict(os.environ, {selector.CATALOG_ENV: str(catalog)}):
                result = selector.select_execution_target(write_task(root))
        self.assertEqual(result["catalog"]["source"], str(catalog.resolve()))

    def test_runtime_quota_error_moves_to_next_catalog_target(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            catalog = write_catalog(root)
            task = write_task(root)
            first = selector.select_execution_target(task, catalog_path=catalog)
            second = selector.select_execution_target(
                task,
                catalog_path=catalog,
                transition="failover",
                prior_decision=first,
                failure_class="provider-quota",
            )
        self.assertEqual(second["selected"]["target_id"], "second")
        self.assertEqual(second["transition"]["trigger"], "failover")
        self.assertEqual(second["transition"]["previous_target"]["target_id"], "first")

    def test_failover_accepts_bounded_failures_and_requires_unused_candidate(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            catalog = write_catalog(root)
            task = write_task(root)
            for failure in ("generic-error", "session-stall"):
                first = selector.select_execution_target(task, catalog_path=catalog)
                second = selector.select_execution_target(
                    task,
                    catalog_path=catalog,
                    transition="failover",
                    prior_decision=first,
                    failure_class=failure,
                )
                self.assertEqual(second["selected"]["target_id"], "second")
            generic_second = second
            with self.assertRaises(selector.SelectorInputError) as ctx:
                selector.select_execution_target(
                    task,
                    catalog_path=catalog,
                    transition="failover",
                    prior_decision=generic_second,
                    failure_class="provider-quota",
                )
        self.assertEqual(ctx.exception.code, "no_failover_candidate")

    def test_resume_pins_target_and_catalog_revision(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            catalog = write_catalog(root)
            task = write_task(root)
            first = selector.select_execution_target(task, catalog_path=catalog)
            resumed = selector.select_execution_target(
                task,
                catalog_path=catalog,
                transition="resume",
                prior_decision=first,
            )
            changed = catalog_value()
            changed["targets"]["first"]["model"] = "changed-model"
            catalog.write_text(json.dumps(changed), encoding="utf-8")
            with self.assertRaises(selector.SelectorInputError) as ctx:
                selector.select_execution_target(
                    task,
                    catalog_path=catalog,
                    transition="resume",
                    prior_decision=resumed,
                )
        self.assertTrue(resumed["decision"]["pinned"])
        self.assertEqual(ctx.exception.code, "catalog_revision_mismatch")

    def test_stage_and_milestone_header_contract(self):
        with TemporaryDirectory() as tmp:
            root = Path(tmp)
            catalog = write_catalog(root)
            review = write_task(root, kind="CODE_REVIEW", lane="local", grade=2)
            self.assertEqual(
                selector.select_execution_target(review, catalog_path=catalog)["stage"],
                "review",
            )
            with self.assertRaises(selector.SelectorInputError) as ctx:
                selector.select_execution_target(review, stage="worker", catalog_path=catalog)
            self.assertEqual(ctx.exception.code, "stage_mismatch")
            missing = write_task(root, task="m-feature/01_task")
            with self.assertRaises(selector.SelectorInputError) as ctx:
                selector.select_execution_target(missing, catalog_path=catalog)
            self.assertEqual(ctx.exception.code, "missing_milestone_task")

    def test_cli_uses_bundled_catalog_without_override(self):
        with TemporaryDirectory() as tmp:
            task = write_task(Path(tmp))
            completed = subprocess.run(
                [sys.executable, str(SCRIPT), str(task)],
                capture_output=True,
                text=True,
                env={key: value for key, value in os.environ.items() if key != selector.CATALOG_ENV},
                check=False,
            )
        self.assertEqual(completed.returncode, 0, completed.stderr)
        self.assertEqual(completed.stderr, "")
        self.assertEqual(
            json.loads(completed.stdout)["selected"]["target_id"],
            "opencode-glm-max",
        )


if __name__ == "__main__":
    unittest.main()
