from importlib import resources
import unittest
from collections import Counter
import tomllib
import re
import html
from pathlib import Path

import agentic_toolbelt
from setup import _data_files, _package_version

CANONICAL_SKILLS = (
    "docs",
    "exec-plan",
    "implement",
    "plan",
    "publish",
)


class PackagingTests(unittest.TestCase):
    @staticmethod
    def _strip_html(value: str) -> str:
        return html.unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", value)).strip())

    @classmethod
    def _extract_table_rows(cls, html_text: str):
        return re.findall(r"<tr[^>]*>(.*?)</tr>", html_text, flags=re.DOTALL | re.IGNORECASE)

    @classmethod
    def _extract_headers(cls, table_html: str):
        headers = re.findall(r"<th[^>]*>(.*?)</th>", table_html, flags=re.DOTALL | re.IGNORECASE)
        return [cls._strip_html(header).lower() for header in headers]

    @classmethod
    def _extract_cells(cls, row_html: str):
        cells = re.findall(r"<td[^>]*>(.*?)</td>", row_html, flags=re.DOTALL | re.IGNORECASE)
        return [cls._strip_html(cell) for cell in cells]

    @classmethod
    def _agent_tables_and_names(cls, docs_html: str):
        tables = re.findall(r"<table[^>]*>(.*?)</table>", docs_html, flags=re.DOTALL | re.IGNORECASE)
        for table_html in tables:
            headers = cls._extract_headers(table_html)
            rows = cls._extract_table_rows(table_html)
            yield table_html, headers, rows

    @classmethod
    def _roster_agents_and_efforts(cls, repo_root: Path):
        roster_html = (repo_root / "docs" / "codex-agent-roster.html").read_text(encoding="utf-8")
        names = {}
        for table_html, headers, rows in cls._agent_tables_and_names(roster_html):
            if "model / effort" not in headers or "agent" not in headers:
                continue

            agent_idx = headers.index("agent")
            effort_idx = headers.index("model / effort")
            for row in rows:
                cells = cls._extract_cells(row)
                if len(cells) <= max(agent_idx, effort_idx):
                    continue

                name = cells[agent_idx].strip()
                effort = cells[effort_idx].lower()
                if "/" in effort:
                    effort = effort.split("/", 1)[1].strip()
                names[name] = effort
        return names

    @classmethod
    def _roster_agents_and_models(cls, repo_root: Path):
        roster_html = (repo_root / "docs" / "codex-agent-roster.html").read_text(encoding="utf-8")
        models = {}
        for table_html, headers, rows in cls._agent_tables_and_names(roster_html):
            if "model / effort" not in headers or "agent" not in headers:
                continue

            agent_idx = headers.index("agent")
            model_idx = headers.index("model / effort")
            for row in rows:
                cells = cls._extract_cells(row)
                if len(cells) <= max(agent_idx, model_idx):
                    continue

                name = cells[agent_idx].strip()
                model_effort = cells[model_idx]
                if "/" in model_effort:
                    model_effort = model_effort.split("/", 1)[0].strip()
                models[name] = model_effort
        return models

    @classmethod
    def _details_agents_and_efforts(cls, repo_root: Path):
        details_html = (repo_root / "docs" / "codex-agent-details.html").read_text(encoding="utf-8")
        reasons = {}
        for table_html, headers, rows in cls._agent_tables_and_names(details_html):
            if "reasoning" not in headers or "agent" not in headers:
                continue

            agent_idx = headers.index("agent")
            reason_idx = headers.index("reasoning")
            for row in rows:
                cells = cls._extract_cells(row)
                if len(cells) <= max(agent_idx, reason_idx):
                    continue

                name = cells[agent_idx].strip()
                reasons[name] = cells[reason_idx].lower()
        return reasons

    @classmethod
    def _details_agents_and_models(cls, repo_root: Path):
        details_html = (repo_root / "docs" / "codex-agent-details.html").read_text(encoding="utf-8")
        models = {}
        for table_html, headers, rows in cls._agent_tables_and_names(details_html):
            if "model" not in headers or "agent" not in headers:
                continue

            agent_idx = headers.index("agent")
            model_idx = headers.index("model")
            for row in rows:
                cells = cls._extract_cells(row)
                if len(cells) <= max(agent_idx, model_idx):
                    continue

                name = cells[agent_idx].strip()
                models[name] = cells[model_idx].lower()
        return models

    @classmethod
    def _catalog_agents(cls):
        root = resources.files(agentic_toolbelt)
        config = tomllib.loads((root / "templates" / "codex" / "config.toml.example").read_text(encoding="utf-8"))

        agents = {}
        for name, metadata in config["agents"].items():
            if not isinstance(metadata, dict) or "config_file" not in metadata:
                continue
            agents[name] = cls._agent_config_path(root, metadata)

        return agents

    @classmethod
    def _agent_config_path(cls, root: Path, metadata: dict) -> Path:
        raw = str(metadata["config_file"]).strip().replace("\\", "/")
        if raw.startswith("./"):
            raw = raw[2:]
        if raw.startswith("templates/codex/"):
            raw = raw[len("templates/codex/"):]
        if raw.startswith("/"):
            raw = raw[1:]
        if raw.startswith("agents/"):
            raw = raw[len("agents/"):]
            return root / "templates" / "codex" / "agents" / raw

        return root / "templates" / "codex" / raw

    def test_payload_files_are_available(self):
        root = resources.files(agentic_toolbelt)
        self.assertTrue((root / ".agents").is_dir())
        self.assertTrue((root / ".codex").is_dir())
        self.assertTrue((root / "AGENTS.md").is_file())
        self.assertTrue((root / ".agents" / "ralph" / "agents.sh").is_file())
        self.assertTrue((root / ".codex" / "skills" / "commit" / "SKILL.md").is_file())
        self.assertTrue((root / "templates" / "codex" / "config.toml.example").is_file())
        self.assertTrue((root / "templates" / "codex" / "agent-contract.schema.md").is_file())
        self.assertTrue((root / "templates" / "codex" / "agents" / "explorer.toml").is_file())
        self.assertTrue((root / "templates" / "codex" / "agents" / "reviewer.toml").is_file())
        self.assertTrue((root / "templates" / "codex" / "agents" / "worker.toml").is_file())
        self.assertTrue((root / ".agents" / "skills" / "plan" / "SKILL.md").is_file())
        self.assertTrue((root / ".agents" / "skills" / "implement" / "SKILL.md").is_file())
        self.assertTrue((root / ".agents" / "skills" / "docs" / "SKILL.md").is_file())
        self.assertTrue((root / ".agents" / "skills" / "publish" / "SKILL.md").is_file())

    def test_setup_data_files_excludes_cache_artifacts(self):
        files = _data_files()

        self.assertNotIn("__pycache__/__init__.cpython-312.pyc", files)
        self.assertFalse(any(path.endswith(".pyc") for path in files))

    def test_setup_version_matches_pyproject(self):
        version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
        self.assertEqual(version, _package_version())

    def test_codex_agent_configs_are_declared_and_parse(self):
        root = resources.files(agentic_toolbelt)
        config = tomllib.loads((root / "templates" / "codex" / "config.toml.example").read_text())

        agents = config["agents"]
        self.assertIn("feature-builder", agents)
        self.assertIn("instruction-maintainer", agents)
        self.assertIn("gatekeeper", agents)

        for name, metadata in agents.items():
            if not isinstance(metadata, dict) or "config_file" not in metadata:
                continue
            agent_config = self._agent_config_path(root, metadata)
            self.assertTrue(agent_config.is_file(), f"missing config for {name}")
            tomllib.loads(agent_config.read_text())

    def test_codex_agent_contract_metadata_is_declared_in_config_and_templates(self):
        root = resources.files(agentic_toolbelt)
        config_path = root / "templates" / "codex" / "config.toml.example"
        config = tomllib.loads(config_path.read_text())
        agents = config["agents"]

        self.assertIn("contract_version", agents)
        self.assertEqual("1.0", agents["contract_version"])
        self.assertIn("depth_profiles", config["agents"])

        required_config_fields = {
            "role_scope",
            "risk_level",
            "allow_parallel",
            "parallel_cap",
            "serial_required",
            "handoff_mode",
        }

        required_contract_fields = {
            "scope",
            "risk_level",
            "inputs",
            "outputs",
            "handoff_to",
            "artifacts",
            "must_not_change",
            "done_criteria",
            "escalation_criteria",
        }

        for name, metadata in agents.items():
            if not isinstance(metadata, dict) or "config_file" not in metadata:
                continue

            self.assertTrue(
                required_config_fields.issubset(metadata.keys()),
                f"missing policy fields in config for {name}: {required_config_fields - set(metadata.keys())}",
            )

            agent_config = self._agent_config_path(root, metadata)
            contract = tomllib.loads(agent_config.read_text())

            contract_version = contract.get("contract_version")
            self.assertIsNotNone(
                contract_version,
                f"{name}: contract_version must be a top-level field in {agent_config}",
            )
            self.assertEqual("1.0", contract_version, f"missing contract_version in {name}")
            self.assertIn("agent_contract", contract, f"missing agent_contract in {name}")
            agent_contract = contract["agent_contract"]

            self.assertTrue(required_contract_fields.issubset(agent_contract.keys()), f"missing fields in {name}: {required_contract_fields - set(agent_contract.keys())}")

            for key in ("inputs", "outputs", "handoff_to", "artifacts", "must_not_change", "done_criteria", "escalation_criteria"):
                self.assertIsInstance(agent_contract[key], list, f"{name}: {key} must be a list")

    def test_agent_name_parity_across_config_templates_and_docs(self):
        repo_root = Path(__file__).resolve().parent.parent
        agents = self._catalog_agents()
        config_agents = set(agents.keys())
        template_agents = {agent_path.stem for agent_path in (resources.files(agentic_toolbelt) / "templates" / "codex" / "agents").glob("*.toml")}
        roster_agents = set(self._roster_agents_and_efforts(repo_root).keys())
        details_agents = set(self._details_agents_and_efforts(repo_root).keys())

        self.assertTrue(roster_agents, "Could not parse roster model/effort table from docs/codex-agent-roster.html")
        self.assertTrue(details_agents, "Could not parse details reasoning table from docs/codex-agent-details.html")

        self.assertFalse(config_agents - template_agents, f"agents missing in templates: {sorted(config_agents - template_agents)}")
        self.assertFalse(template_agents - config_agents, f"extra templates missing from config: {sorted(template_agents - config_agents)}")
        self.assertFalse(config_agents - roster_agents, f"agents missing in roster table: {sorted(config_agents - roster_agents)}")
        self.assertFalse(roster_agents - config_agents, f"extra roster entries not in config: {sorted(roster_agents - config_agents)}")
        self.assertFalse(config_agents - details_agents, f"agents missing in details table: {sorted(config_agents - details_agents)}")
        self.assertFalse(details_agents - config_agents, f"extra details entries not in config: {sorted(details_agents - config_agents)}")

    def test_model_reasoning_effort_parity_between_templates_and_docs(self):
        repo_root = Path(__file__).resolve().parent.parent
        agents = self._catalog_agents()
        roster_efforts = self._roster_agents_and_efforts(repo_root)
        details_efforts = self._details_agents_and_efforts(repo_root)

        template_efforts = {}
        for name, agent_path in agents.items():
            agent_config = tomllib.loads(agent_path.read_text(encoding="utf-8"))
            template_efforts[name] = str(agent_config.get("model_reasoning_effort", "")).lower()
            self.assertNotEqual(
                "",
                template_efforts[name],
                f"missing model_reasoning_effort in template {name}",
            )

        for name in agents:
            self.assertIn(name, roster_efforts, f"missing roster model/reasoning row for {name}")
            self.assertIn(name, details_efforts, f"missing details reasoning row for {name}")
            self.assertEqual(template_efforts[name], roster_efforts[name], f"reasoning mismatch for {name} in roster")
            self.assertEqual(template_efforts[name], details_efforts[name], f"reasoning mismatch for {name} in details")

    def test_model_parity_when_declared_in_templates(self):
        repo_root = Path(__file__).resolve().parent.parent
        agents = self._catalog_agents()
        roster_models = self._roster_agents_and_models(repo_root)
        details_models = self._details_agents_and_models(repo_root)

        template_models = {}
        for name, agent_path in agents.items():
            agent_config = tomllib.loads(agent_path.read_text(encoding="utf-8"))
            model = str(agent_config.get("model", "")).strip().lower()
            if model:
                template_models[name] = model

        for name, template_model in template_models.items():
            self.assertIn(name, roster_models, f"missing template model row for {name} in roster")
            self.assertIn(name, details_models, f"missing template model row for {name} in details")
            self.assertEqual(template_model, roster_models[name], f"model mismatch for {name} in roster")
            self.assertEqual(template_model, details_models[name], f"model mismatch for {name} in details")

    def test_handoff_to_targets_resolve_within_agent_catalog(self):
        agents = self._catalog_agents()
        catalog = set(agents.keys())

        for name, agent_path in agents.items():
            contract = tomllib.loads(agent_path.read_text(encoding="utf-8")).get("agent_contract", {})
            handoff_targets = contract.get("handoff_to", [])
            self.assertIsInstance(handoff_targets, list, f"{name} handoff_to must be a list")
            for target in handoff_targets:
                self.assertIsInstance(target, str, f"{name} handoff_to target should be a string: {target!r}")
                self.assertIn(target, catalog, f"{name}: handoff target '{target}' not in agent catalog")

    def test_shared_skills_are_canonical_in_agents_only(self):
        root = resources.files(agentic_toolbelt)

        for skill in CANONICAL_SKILLS:
            self.assertTrue((root / ".agents" / "skills" / skill / "SKILL.md").is_file())
            self.assertFalse((root / ".github" / "skills" / skill / "SKILL.md").exists())

    def test_skill_names_are_unique_across_packaged_sources(self):
        root = resources.files(agentic_toolbelt)
        skill_files = list(root.glob(".agents/skills/*/SKILL.md")) + list(
            root.glob(".github/skills/*/SKILL.md")
        ) + list(root.glob(".codex/skills/*/SKILL.md"))
        counts = Counter(path.parent.name for path in skill_files)
        duplicates = {name: count for name, count in counts.items() if count > 1}

        self.assertEqual({}, duplicates)
