from __future__ import annotations

import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock


SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "run_epic_cycle.py"
SPEC = importlib.util.spec_from_file_location("run_epic_cycle", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)


def command(cwd: Path, *args: str) -> str:
    result = subprocess.run(
        list(args),
        cwd=cwd,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=True,
    )
    return result.stdout.strip()


class EpicCycleContractTest(unittest.TestCase):
    def test_cycle_requires_runtime_catalog_and_defaults_review_target(self) -> None:
        args = MODULE.parser().parse_args(
            [
                "--workspace", "/workspace",
                "--milestone", "milestone.md",
                "--epic", "epic",
                "--execution-catalog", "/runtime/catalog.json",
                "--planner-target", "planner-primary",
            ]
        )
        MODULE.apply_defaults(args)
        self.assertEqual(args.planner_target, "planner-primary")
        self.assertEqual(args.review_target, "planner-primary")

        missing = MODULE.parser().parse_args(
            ["--workspace", "/workspace", "--milestone", "milestone.md", "--epic", "epic"]
        )
        with self.assertRaises(MODULE.CycleError):
            MODULE.apply_defaults(missing)

    def test_live_stage_result_requires_tracking_without_relaunch(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            state_root = root / "state"
            workspace.mkdir()
            locator = state_root / "runs" / "locator.json"
            locator.parent.mkdir(parents=True)
            locator.write_text("{}\n", encoding="utf-8")
            result_path = state_root / "attempts" / "materialize.json"
            MODULE.atomic_json(
                result_path,
                {
                    "workspace": str(workspace),
                    "label": "sample-epic-materialize",
                    "status": "tracking",
                    "agent_pid": os.getpid(),
                    "agent_process_start_token": MODULE.process_start_token(os.getpid()),
                    "locator": str(locator),
                },
            )
            with self.assertRaises(MODULE.TrackingRequired):
                MODULE.run_agent_stage(
                    workspace=workspace,
                    state_root=state_root,
                    identity="sample-epic",
                    stage="materialize",
                    prompt="prompt",
                    execution_catalog="/runtime/catalog.json",
                    target_id="planner-primary",
                    prior_cycle_status="tracking",
                    retry=False,
                )

    def test_dead_tracking_result_requires_explicit_artifact_adoption(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            workspace = root / "workspace"
            state_root = root / "state"
            workspace.mkdir()
            locator = state_root / "runs" / "locator.json"
            locator.parent.mkdir(parents=True)
            locator.write_text("{}\n", encoding="utf-8")
            result_path = state_root / "attempts" / "materialize.json"
            MODULE.atomic_json(
                result_path,
                {
                    "workspace": str(workspace),
                    "label": "sample-epic-materialize",
                    "status": "tracking",
                    "agent_pid": 999_999_999,
                    "agent_process_start_token": "proc:missing",
                    "locator": str(locator),
                },
            )
            values = {
                "workspace": workspace,
                "state_root": state_root,
                "identity": "sample-epic",
                "stage": "materialize",
                "prompt": "prompt",
                "execution_catalog": "/runtime/catalog.json",
                "target_id": "planner-primary",
                "prior_cycle_status": "tracking",
            }
            with self.assertRaises(MODULE.TrackingRecoveryRequired):
                MODULE.run_agent_stage(**values, retry=False)
            self.assertEqual(
                MODULE.run_agent_stage(**values, retry=True),
                result_path,
            )

    def test_parse_and_select_exact_epic(self) -> None:
        epics = MODULE.parse_epics(
            """## 기능

### Epic: [first] First Epic

- [x] [done-task] completed
- [ ] [open-task] pending

### Epic: [second] Second Epic

- [ ] [other-task] pending
"""
        )
        selected = MODULE.select_epic(epics, "first")
        self.assertEqual(selected.task_ids, ("done-task", "open-task"))
        self.assertEqual(selected.incomplete_ids, ("open-task",))

    def test_validate_only_emits_batch_event_with_terminal_state(self) -> None:
        cases = (
            ("- [x] [task-one] completed", set(), [], "EPIC_COMPLETED"),
            (
                "- [ ] [task-one] pending",
                {"task-one"},
                [(Path("PLAN-local-G01.md"), Path("CODE_REVIEW-local-G01.md"))],
                "EPIC_WORK_ITEMS_READY",
            ),
        )
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            milestone = (
                workspace
                / "agent-roadmap"
                / "phase"
                / "phase-one"
                / "milestones"
                / "sample-milestone.md"
            )
            milestone.parent.mkdir(parents=True)
            current = workspace / "agent-roadmap" / "current.md"
            current.parent.mkdir(parents=True, exist_ok=True)
            current.write_text(
                "phase/phase-one/milestones/sample-milestone.md\n",
                encoding="utf-8",
            )

            for task_line, task_union, pairs, terminal in cases:
                with self.subTest(terminal=terminal):
                    milestone.write_text(
                        "# Milestone: [sample-01] Sample\n\n"
                        "## 상태\n\n[계획]\n\n"
                        "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n"
                        "## 기능\n\n"
                        "### Epic: [sample-epic] Sample Epic\n\n"
                        f"{task_line}\n",
                        encoding="utf-8",
                    )
                    args = MODULE.parser().parse_args(
                        [
                            "--workspace",
                            str(workspace),
                            "--milestone",
                            str(milestone.relative_to(workspace)),
                            "--epic",
                            "sample-epic",
                            "--execution-catalog",
                            "/runtime/catalog.json",
                            "--planner-target",
                            "planner-primary",
                            "--validate-only",
                        ]
                    )
                    output = io.StringIO()
                    with (
                        mock.patch.object(MODULE, "resolve_workspace", return_value=workspace),
                        mock.patch.object(
                            MODULE,
                            "git",
                            side_effect=lambda _workspace, *arguments, **_kwargs: {
                                ("config", "--get", "gitflow.prefix.feature"): "feature/",
                                ("branch", "--show-current"): "feature/sample-milestone",
                            }[arguments],
                        ),
                        mock.patch.object(
                            MODULE,
                            "validate_pairs",
                            return_value=(pairs, task_union),
                        ),
                        mock.patch("sys.stdout", output),
                    ):
                        self.assertEqual(MODULE.cycle(args), 0)

                    payload = json.loads(output.getvalue())
                    self.assertEqual(payload["event"], "EPIC_BATCH_VALIDATED")
                    self.assertEqual(payload["terminal"], terminal)
                    self.assertEqual(payload["plans"], len(pairs))

    def test_validate_pair_rejects_task_outside_epic(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            task = workspace / "agent-task" / "m-sample" / "01_work"
            task.mkdir(parents=True)
            header = "<!-- task=m-sample/01_work plan=0 tag=TEST milestone-task=outside -->\n"
            (task / "PLAN-local-G01.md").write_text(header + "# Plan\n", encoding="utf-8")
            (task / "CODE_REVIEW-local-G01.md").write_text(header + "# Review\n", encoding="utf-8")
            with self.assertRaises(MODULE.CycleError):
                MODULE.validate_pairs(workspace, "m-sample", {"inside"})

    def test_validate_pair_allows_earlier_epic_pair_in_selected_batch(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            command(workspace, "git", "init")
            root = workspace / "agent-task" / "m-sample"
            first = root / "01_first"
            second = root / "02_second"
            first.mkdir(parents=True)
            second.mkdir(parents=True)
            for task, task_id in ((first, "first-task"), (second, "second-task")):
                header = (
                    f"<!-- task=m-sample/{task.name} plan=0 tag=TEST "
                    f"milestone-task={task_id} -->\n"
                )
                (task / "PLAN-local-G01.md").write_text(header + "# Plan\n", encoding="utf-8")
                (task / "CODE_REVIEW-local-G01.md").write_text(
                    header + "# Review\n", encoding="utf-8"
                )
            pairs, union = MODULE.validate_pairs(
                workspace,
                "m-sample",
                {"second-task"},
                {"first-task", "second-task"},
            )
            self.assertEqual([pair[0].parent.name for pair in pairs], ["02_second"])
            self.assertEqual(union, {"second-task"})

    def test_validate_pair_allows_documented_milestone_slug_notation(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            command(workspace, "git", "init")
            task = workspace / "agent-task" / "m-sample" / "01_work"
            task.mkdir(parents=True)
            header = (
                "<!-- task=m-sample/01_work plan=0 tag=TEST "
                "milestone-task=inside -->\n"
            )
            (task / "PLAN-local-G01.md").write_text(
                header + "# Plan\n",
                encoding="utf-8",
            )
            (task / "CODE_REVIEW-local-G01.md").write_text(
                header
                + "# Review\n\n"
                + "For task groups matching `m-<milestone-slug>`, preserve metadata.\n",
                encoding="utf-8",
            )

            pairs, union = MODULE.validate_pairs(workspace, "m-sample", {"inside"})

            self.assertEqual(len(pairs), 1)
            self.assertEqual(union, {"inside"})

    def test_validate_pair_rejects_bare_milestone_slug_placeholder(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            command(workspace, "git", "init")
            task = workspace / "agent-task" / "m-sample" / "01_work"
            task.mkdir(parents=True)
            header = (
                "<!-- task=m-sample/01_work plan=0 tag=TEST "
                "milestone-task=inside -->\n"
            )
            (task / "PLAN-local-G01.md").write_text(
                header + "# Plan\n\nPath: agent-task/m-<milestone-slug>/\n",
                encoding="utf-8",
            )
            (task / "CODE_REVIEW-local-G01.md").write_text(
                header + "# Review\n",
                encoding="utf-8",
            )

            with self.assertRaisesRegex(MODULE.CycleError, "unresolved template token"):
                MODULE.validate_pairs(workspace, "m-sample", {"inside"})

    def test_validate_pair_rejects_untracked_whitespace_error(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            command(workspace, "git", "init")
            task = workspace / "agent-task" / "m-sample" / "01_work"
            task.mkdir(parents=True)
            header = (
                "<!-- task=m-sample/01_work plan=0 tag=TEST "
                "milestone-task=inside -->\n"
            )
            (task / "PLAN-local-G01.md").write_text(
                header + "# Plan\n",
                encoding="utf-8",
            )
            (task / "CODE_REVIEW-local-G01.md").write_text(
                header + "# Review\n\n",
                encoding="utf-8",
            )

            with self.assertRaisesRegex(MODULE.CycleError, "whitespace validation failed"):
                MODULE.validate_pairs(workspace, "m-sample", {"inside"})

    def test_retry_prompt_includes_parent_failure(self) -> None:
        prompt = MODULE.stage_prompt(
            stage="initial-review",
            workspace=Path("/workspace"),
            milestone=Path("/workspace/milestone.md"),
            epic=MODULE.Epic("sample", "Sample", ("inside",), ("inside",), ""),
            task_group="m-sample",
            base_head="abc123",
            checkpoint_head=None,
            recovery_reason="git diff --cached --check failed",
        )

        self.assertIn("explicit retry", prompt)
        self.assertIn("git diff --cached --check failed", prompt)

    def test_finalization_artifacts_allow_replan_logs_but_reject_verdicts(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            task = workspace / "agent-task" / "m-sample" / "01_work"
            task.mkdir(parents=True)
            replan_log = task / "code_review_local_G01_0.log"
            replan_log.write_text("# Code Review Reference\n", encoding="utf-8")

            self.assertEqual(
                MODULE.active_task_finalization_artifacts(workspace, "m-sample"),
                [],
            )

            verdict_log = task / "code_review_local_G02_0.log"
            verdict_log.write_text(
                "# Code Review Reference\n\n## Code Review Result\n\nPASS\n",
                encoding="utf-8",
            )
            complete_log = task / "complete.log"
            complete_log.write_text("done\n", encoding="utf-8")

            self.assertEqual(
                MODULE.active_task_finalization_artifacts(workspace, "m-sample"),
                [verdict_log, complete_log],
            )

    def test_validate_pair_rejects_foreign_pair_outside_selected_batch(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            workspace = Path(raw)
            task = workspace / "agent-task" / "m-sample" / "03_foreign"
            task.mkdir(parents=True)
            header = (
                "<!-- task=m-sample/03_foreign plan=0 tag=TEST "
                "milestone-task=foreign-task -->\n"
            )
            (task / "PLAN-local-G01.md").write_text(header + "# Plan\n", encoding="utf-8")
            (task / "CODE_REVIEW-local-G01.md").write_text(
                header + "# Review\n", encoding="utf-8"
            )
            with self.assertRaisesRegex(MODULE.CycleError, "outside selected Epic batch"):
                MODULE.validate_pairs(
                    workspace,
                    "m-sample",
                    {"second-task"},
                    {"first-task", "second-task"},
                )

    def test_full_cycle_with_fresh_injected_target_passes_and_pushes(self) -> None:
        with tempfile.TemporaryDirectory() as raw:
            root = Path(raw)
            remote = root / "remote.git"
            workspace = root / "workspace"
            command(root, "git", "init", "--bare", str(remote))
            command(root, "git", "init", "-b", "dev", str(workspace))
            command(workspace, "git", "config", "user.name", "Test Agent")
            command(workspace, "git", "config", "user.email", "agent@example.test")
            command(workspace, "git", "config", "gitflow.branch.develop", "dev")
            command(workspace, "git", "config", "gitflow.prefix.feature", "feature/")
            command(workspace, "git", "remote", "add", "origin", str(remote))
            milestone = (
                workspace
                / "agent-roadmap"
                / "phase"
                / "phase-one"
                / "milestones"
                / "sample-milestone.md"
            )
            milestone.parent.mkdir(parents=True)
            milestone.write_text(
                "# Milestone: [sample-01] Sample\n\n"
                "## 상태\n\n[계획]\n\n"
                "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n"
                "## 기능\n\n"
                "### Epic: [sample-epic] Sample Epic\n\n"
                "- [ ] [large-task] large work\n",
                encoding="utf-8",
            )
            current = workspace / "agent-roadmap" / "current.md"
            current.write_text(
                "# 현재 로드맵 컨텍스트\n\n"
                "## 활성 Milestone\n\n"
                "- [계획] Sample\n"
                "  - 경로: [Sample](phase/phase-one/milestones/sample-milestone.md)\n",
                encoding="utf-8",
            )
            (workspace / ".gitignore").write_text("agent-roadmap/current.md\n", encoding="utf-8")
            command(workspace, "git", "add", ".gitignore", "agent-roadmap")
            command(workspace, "git", "commit", "-m", "init")
            command(workspace, "git", "push", "-u", "origin", "dev")
            command(workspace, "git", "switch", "-c", "feature/sample-milestone")
            command(workspace, "git", "push", "-u", "origin", "feature/sample-milestone")

            def fake_agent_stage(**values: object) -> None:
                if "Materialize this Epic once" not in str(values["prompt"]):
                    return
                task = workspace / "agent-task" / "m-sample-milestone"
                task.mkdir(parents=True, exist_ok=True)
                header = "<!-- task=m-sample-milestone plan=0 tag=TEST milestone-task=large-task -->\n"
                (task / "PLAN-local-G01.md").write_text(header + "# Plan\n", encoding="utf-8")
                (task / "CODE_REVIEW-local-G01.md").write_text(
                    header + "# Review\n", encoding="utf-8"
                )

            with mock.patch.object(MODULE, "run_agent_stage", side_effect=fake_agent_stage):
                result = MODULE.main(
                    [
                        "--workspace",
                        str(workspace),
                        "--milestone",
                        str(milestone.relative_to(workspace)),
                        "--epic",
                        "sample-epic",
                        "--execution-catalog",
                        "/runtime/catalog.json",
                        "--planner-target",
                        "planner-primary",
                    ]
                )
            self.assertEqual(result, 0)
            self.assertFalse(command(workspace, "git", "status", "--porcelain=v1"))
            self.assertEqual(
                command(workspace, "git", "rev-parse", "HEAD"),
                command(workspace, "git", "rev-parse", "origin/feature/sample-milestone"),
            )
            state_path = (
                MODULE.git_common_dir(workspace)
                / "epic-work-preparation"
                / "sample-milestone"
                / "sample-epic"
                / "state.json"
            )
            state = MODULE.read_state(state_path)
            self.assertEqual(state["event"], "EPIC_WORK_ITEMS_READY")

            reused_in_larger_batch = MODULE.main(
                [
                    "--workspace",
                    str(workspace),
                    "--milestone",
                    str(milestone.relative_to(workspace)),
                    "--epic",
                    "sample-epic",
                    "--execution-catalog",
                    "/runtime/catalog.json",
                    "--planner-target",
                    "planner-primary",
                    "--batch-task-ids",
                    "large-task,later-task",
                ]
            )
            self.assertEqual(reused_in_larger_batch, 0)

            task_root = workspace / "agent-task" / "m-sample-milestone"
            for path in task_root.iterdir():
                path.unlink()
            task_root.rmdir()
            milestone.write_text(
                milestone.read_text(encoding="utf-8").replace(
                    "- [ ] [large-task] large work",
                    "- [x] [large-task] large work",
                ),
                encoding="utf-8",
            )
            command(workspace, "git", "add", "-A", "agent-roadmap", "agent-task")
            command(workspace, "git", "commit", "-m", "complete epic work")
            command(workspace, "git", "push")

            resumed = MODULE.main(
                [
                    "--workspace",
                    str(workspace),
                    "--milestone",
                    str(milestone.relative_to(workspace)),
                    "--epic",
                    "sample-epic",
                    "--execution-catalog",
                    "/runtime/catalog.json",
                    "--planner-target",
                    "planner-primary",
                ]
            )
            self.assertEqual(resumed, 0)
            self.assertEqual(MODULE.read_state(state_path)["event"], "EPIC_COMPLETED")


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