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

import agentic_toolbelt
from agentic_toolbelt.cli import REQUIRED_PAYLOAD_SUBPATHS
from setup import _data_files, _package_version

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - exercised on Python < 3.11
    import tomli as tomllib

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

REQUIRED_CUSTOM_AGENT_FIELDS = {
    "name",
    "description",
    "developer_instructions",
}

ALLOWED_CUSTOM_AGENT_FIELDS = {
    "name",
    "description",
    "developer_instructions",
    "nickname_candidates",
    "model",
    "model_reasoning_effort",
    "sandbox_mode",
    "mcp_servers",
    "skills",
}


class PackagingTests(unittest.TestCase):
    @staticmethod
    def _project_metadata():
        return tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))

    @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)
        agents = {}
        agents_dir = root / "templates" / "codex" / "agents"
        for agent_path in agents_dir.glob("*.toml"):
            config = tomllib.loads(agent_path.read_text(encoding="utf-8"))
            name = config.get("name")
            if isinstance(name, str) and name:
                agents[name] = agent_path

        return agents

    @classmethod
    def _routing_profiles_from_manifest(cls):
        root = resources.files(agentic_toolbelt)
        manifest = tomllib.loads((root / "templates" / "codex" / "routing-profiles.toml").read_text(encoding="utf-8"))
        return manifest["profiles"]

    @classmethod
    def _workflow_routing_profiles(cls, repo_root: Path):
        workflow_html = (repo_root / "docs" / "codex-agent-workflows.html").read_text(encoding="utf-8")
        profiles = {}

        for table_html, headers, rows in cls._agent_tables_and_names(workflow_html):
            if "routing profile" not in headers or "core chain" not in headers:
                continue

            profile_idx = headers.index("routing profile")
            chain_idx = headers.index("core chain")
            for row in rows:
                cells = cls._extract_cells(row)
                if len(cells) <= max(profile_idx, chain_idx):
                    continue
                name = cells[profile_idx].strip()
                chain = cells[chain_idx].replace(" → ", " -> ").strip()
                profiles[name] = chain

        return profiles

    @classmethod
    def _agents_routing_profiles(cls, repo_root: Path):
        agents_md = (repo_root / "agentic_toolbelt" / "AGENTS.md").read_text(encoding="utf-8")
        profiles = {}
        section_match = re.search(
            r"## Canonical routing profiles\n(.*?)(?:\n## |\Z)",
            agents_md,
            re.DOTALL,
        )
        if not section_match:
            return profiles

        for name, chain in re.findall(r"-\s+`([^`]+)`:\s+`([^`]+)`", section_match.group(1)):
            profiles[name.strip()] = chain.strip()

        return profiles

    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 / ".agents" / "tasks" / "README.md").is_file())
        self.assertTrue((root / ".agents" / "skills" / "task-breakdown" / "SKILL.md").is_file())
        self.assertTrue((root / ".agents" / "skills" / "task-breakdown" / "assets" / "task.template.json").is_file())
        self.assertTrue((root / ".codex" / "skills" / "commit" / "SKILL.md").is_file())
        self.assertTrue((root / ".tasks" / "README.md").is_file())
        self.assertTrue((root / "templates" / "codex" / "config.toml.example").is_file())
        self.assertTrue((root / "templates" / "codex" / "routing-profiles.toml").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_cli_runtime_codex_sources_are_packaged(self):
        root = resources.files(agentic_toolbelt)
        self.assertTrue((root / "templates" / "codex" / "config.toml.example").is_file())
        self.assertTrue((root / "templates" / "codex" / "routing-profiles.toml").is_file())
        self.assertTrue((root / "templates" / "codex" / "agents" / "planner.toml").is_file())
        self.assertTrue((root / "templates" / "codex" / "agents" / "implementer.toml").is_file())

    def test_setup_data_files_excludes_cache_artifacts(self):
        files = _data_files()

        self.assertIn(".agents/tasks/README.md", files)
        self.assertIn(".tasks/README.md", files)
        self.assertNotIn("__pycache__/__init__.cpython-312.pyc", files)
        self.assertFalse(any(path.endswith(".pyc") for path in files))
        self.assertFalse(any("node_modules/" in path for path in files))

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

        for raw_path in REQUIRED_PAYLOAD_SUBPATHS:
            path = root.joinpath(*raw_path.split("/"))
            self.assertTrue(path.exists(), f"missing required payload path: {raw_path}")
            if path.is_file():
                continue

            packaged_files = [child for child in path.rglob("*") if child.is_file()]
            self.assertTrue(
                packaged_files,
                f"required payload directory '{raw_path}' has no packaged files and may be dropped from built artifacts",
            )

    def test_setup_version_matches_pyproject(self):
        version = self._project_metadata()["project"]["version"]
        self.assertEqual(version, _package_version())

    def test_pyproject_declares_python_39_plus_and_tomli_fallback(self):
        metadata = self._project_metadata()

        self.assertEqual(">=3.9", metadata["project"]["requires-python"])
        self.assertIn("README.md", metadata["project"]["readme"])

        build_requires = metadata["build-system"]["requires"]
        self.assertIn("setuptools>=61", build_requires)
        self.assertIn("tomli>=1.1.0; python_version < '3.11'", build_requires)

        test_requires = metadata["project"]["optional-dependencies"]["test"]
        self.assertIn("pytest>=8", test_requires)
        self.assertIn("tomli>=1.1.0; python_version < '3.11'", test_requires)

    def test_setup_uses_tomllib_compatibility_import(self):
        setup_source = Path("setup.py").read_text(encoding="utf-8")

        self.assertIn("import tomllib", setup_source)
        self.assertIn("import tomli as tomllib", setup_source)
        self.assertIn("except ModuleNotFoundError", setup_source)

    def test_manifest_explicitly_includes_hidden_tasks_workspace(self):
        manifest = Path("MANIFEST.in").read_text(encoding="utf-8")

        self.assertIn("recursive-include agentic_toolbelt/.tasks *", manifest)

    def test_codex_agent_configs_are_declared_and_parse(self):
        agents = self._catalog_agents()
        self.assertIn("feature-builder", agents)
        self.assertIn("instruction-maintainer", agents)
        self.assertIn("gatekeeper", agents)

        for name, agent_path in agents.items():
            self.assertTrue(agent_path.is_file(), f"missing config for {name}")
            config = tomllib.loads(agent_path.read_text(encoding="utf-8"))
            self.assertEqual(name, config["name"])

    def test_codex_config_uses_only_global_agent_settings(self):
        root = resources.files(agentic_toolbelt)
        config_text = (root / "templates" / "codex" / "config.toml.example").read_text(encoding="utf-8")
        config = tomllib.loads(config_text)

        self.assertEqual({"max_threads", "max_depth"}, set(config["agents"].keys()))
        self.assertEqual(12, config["agents"]["max_threads"])
        self.assertEqual(1, config["agents"]["max_depth"])
        self.assertNotRegex(config_text, r"(?m)^\[agents\.")

    def test_codex_agent_files_follow_custom_agent_schema(self):
        agents = self._catalog_agents()

        for expected_name, agent_path in agents.items():
            config = tomllib.loads(agent_path.read_text(encoding="utf-8"))
            self.assertTrue(
                REQUIRED_CUSTOM_AGENT_FIELDS.issubset(config.keys()),
                f"{expected_name} missing required custom-agent fields: {REQUIRED_CUSTOM_AGENT_FIELDS - set(config.keys())}",
            )
            self.assertTrue(
                set(config.keys()).issubset(ALLOWED_CUSTOM_AGENT_FIELDS),
                f"{expected_name} uses unsupported custom-agent fields: {set(config.keys()) - ALLOWED_CUSTOM_AGENT_FIELDS}",
            )
            self.assertEqual(expected_name, config["name"])
            self.assertEqual(agent_path.stem, config["name"])
            self.assertIsInstance(config["description"], str)
            self.assertTrue(config["description"].strip(), f"{expected_name} must have a non-empty description")
            self.assertIsInstance(config["developer_instructions"], str)
            self.assertTrue(config["developer_instructions"].strip(), f"{expected_name} must have developer_instructions")

            if "nickname_candidates" in config:
                nicknames = config["nickname_candidates"]
                self.assertIsInstance(nicknames, list, f"{expected_name} nickname_candidates must be a list")
                self.assertTrue(nicknames, f"{expected_name} nickname_candidates must not be empty")
                self.assertEqual(len(nicknames), len(set(nicknames)), f"{expected_name} nickname_candidates must be unique")

            if "mcp_servers" in config:
                self.assertIsInstance(config["mcp_servers"], dict, f"{expected_name} mcp_servers must be a table")

            if "skills" in config:
                self.assertIsInstance(config["skills"], dict, f"{expected_name} skills must be a table")

    def test_agent_name_parity_across_templates_and_docs(self):
        repo_root = Path(__file__).resolve().parent.parent
        template_agents = set(self._catalog_agents().keys())
        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(template_agents - roster_agents, f"agents missing in roster table: {sorted(template_agents - roster_agents)}")
        self.assertFalse(roster_agents - template_agents, f"extra roster entries not in templates: {sorted(roster_agents - template_agents)}")
        self.assertFalse(template_agents - details_agents, f"agents missing in details table: {sorted(template_agents - details_agents)}")
        self.assertFalse(details_agents - template_agents, f"extra details entries not in templates: {sorted(details_agents - template_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_routing_profiles_are_declared_and_resolve_within_agent_catalog(self):
        agents = self._catalog_agents()
        catalog = set(agents.keys())
        profiles = self._routing_profiles_from_manifest()
        expected_profiles = {
            "docs_only",
            "trivial_code",
            "planned_change",
            "instruction_change",
            "release_gate",
            "incident",
        }

        self.assertEqual(expected_profiles, set(profiles.keys()))

        for name, profile in profiles.items():
            self.assertEqual(
                {"description", "entrypoint", "handoff_chain", "gate_required", "parallel_allowed", "use_when"},
                set(profile.keys()),
                f"{name} has unexpected routing profile fields",
            )
            self.assertIn(profile["entrypoint"], catalog, f"{name} entrypoint missing from agent catalog")
            self.assertIsInstance(profile["handoff_chain"], list, f"{name} handoff_chain must be a list")
            self.assertTrue(profile["handoff_chain"], f"{name} handoff_chain must not be empty")
            self.assertEqual(profile["entrypoint"], profile["handoff_chain"][0], f"{name} handoff_chain must start with the entrypoint")
            self.assertIsInstance(profile["gate_required"], bool, f"{name} gate_required must be a boolean")
            self.assertIsInstance(profile["parallel_allowed"], bool, f"{name} parallel_allowed must be a boolean")
            self.assertIsInstance(profile["description"], str)
            self.assertIsInstance(profile["use_when"], str)
            for target in profile["handoff_chain"]:
                self.assertIn(target, catalog, f"{name} references unknown agent {target}")

    def test_routing_profile_chains_match_workflow_docs(self):
        repo_root = Path(__file__).resolve().parent.parent
        profiles = self._routing_profiles_from_manifest()
        workflow_profiles = self._workflow_routing_profiles(repo_root)

        self.assertEqual(set(profiles.keys()), set(workflow_profiles.keys()))
        for name, profile in profiles.items():
            self.assertEqual(" -> ".join(profile["handoff_chain"]), workflow_profiles[name], f"workflow docs chain mismatch for {name}")

    def test_routing_profile_chains_match_root_agents_guidance(self):
        repo_root = Path(__file__).resolve().parent.parent
        profiles = self._routing_profiles_from_manifest()
        agents_profiles = self._agents_routing_profiles(repo_root)

        self.assertEqual(set(profiles.keys()), set(agents_profiles.keys()))
        for name, profile in profiles.items():
            self.assertEqual(" -> ".join(profile["handoff_chain"]), agents_profiles[name], f"AGENTS chain mismatch for {name}")

    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)
