"""Tests for gzkit.rules — instruction classification, nested AGENTS.md, and canonical rules.

@covers ADR-0.16.0  OBPI-0.16.0-02 rules-as-content
@covers ADR-0.17.0  OBPI-0.17.0-02 rules-mirroring
"""

import tempfile
import unittest
from pathlib import Path

from gzkit.rules import (
    _GENERATED_MARKER,
    classify_instruction_rules,
    sync_claude_rules,
    sync_nested_agents_md,
    validate_rule_placement,
)
from gzkit.traceability import covers


def _instruction_file(apply_to: str, body: str, *, audience: str | None = None) -> str:
    """Build a minimal .instructions.md file."""
    lines = ["---", f'applyTo: "{apply_to}"']
    if audience is not None:
        lines.append(f"audience: {audience}")
    lines.append("---")
    lines.append("")
    lines.append(body)
    return "\n".join(lines)


class TestClassifyInstructionRules(unittest.TestCase):
    """Test classify_instruction_rules()."""

    def test_shared_default_when_audience_absent(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test rules"), encoding="utf-8"
            )

            rules = classify_instruction_rules(root)

            self.assertEqual(len(rules), 1)
            self.assertEqual(rules[0].audience, "shared")
            self.assertFalse(rules[0].is_global)
            self.assertEqual(rules[0].subtree_prefixes, ["tests"])

    def test_claude_only_explicit(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "secret.instructions.md").write_text(
                _instruction_file("src/**", "# Claude secret", audience="claude-only"),
                encoding="utf-8",
            )

            rules = classify_instruction_rules(root)

            self.assertEqual(len(rules), 1)
            self.assertEqual(rules[0].audience, "claude-only")

    def test_global_scope_detected(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "global.instructions.md").write_text(
                _instruction_file("**/*", "# Global rule"), encoding="utf-8"
            )

            rules = classify_instruction_rules(root)

            self.assertEqual(len(rules), 1)
            self.assertTrue(rules[0].is_global)
            self.assertEqual(rules[0].subtree_prefixes, [])

    def test_multi_pattern_extracts_multiple_prefixes(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "multi.instructions.md").write_text(
                _instruction_file("src/cli/**,tests/**", "# Multi"), encoding="utf-8"
            )

            rules = classify_instruction_rules(root)

            self.assertEqual(len(rules), 1)
            self.assertIn("src/cli", rules[0].subtree_prefixes)
            self.assertIn("tests", rules[0].subtree_prefixes)


class TestSyncNestedAgentsMd(unittest.TestCase):
    """Test sync_nested_agents_md()."""

    def test_generates_nested_file_for_subtree_scope(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test Policy\n\nUse unittest."), encoding="utf-8"
            )
            (root / "tests").mkdir()

            updated = sync_nested_agents_md(root)

            self.assertEqual(len(updated), 1)
            agents = root / "tests" / "AGENTS.md"
            self.assertTrue(agents.exists())
            content = agents.read_text(encoding="utf-8")
            self.assertIn("Generated by gzkit", content)
            self.assertIn("# tests Agent Instructions", content)
            self.assertIn("# Test Policy", content)
            self.assertIn("Use unittest.", content)

    def test_skips_nonexistent_subtree_dirs(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "warehouse.instructions.md").write_text(
                _instruction_file("src/airlineops/warehouse/**", "# Warehouse"), encoding="utf-8"
            )
            # Do NOT create the subtree directory

            updated = sync_nested_agents_md(root)

            self.assertEqual(updated, [])

    def test_deletes_stale_nested_agents_md(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)

            # Create a stale nested AGENTS.md with the generated marker
            stale_dir = root / "old_subtree"
            stale_dir.mkdir()
            stale_file = stale_dir / "AGENTS.md"
            stale_file.write_text(f"{_GENERATED_MARKER}\n# Stale content\n", encoding="utf-8")

            # No instruction files reference old_subtree
            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test rules"), encoding="utf-8"
            )
            (root / "tests").mkdir()

            sync_nested_agents_md(root)

            self.assertFalse(stale_file.exists())

    def test_preserves_non_generated_agents_md(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)

            # Manually created AGENTS.md (no generated marker)
            manual_dir = root / "manual"
            manual_dir.mkdir()
            manual_file = manual_dir / "AGENTS.md"
            manual_file.write_text("# Hand-written agent rules\n", encoding="utf-8")

            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test rules"), encoding="utf-8"
            )
            (root / "tests").mkdir()

            sync_nested_agents_md(root)

            self.assertTrue(manual_file.exists())
            self.assertIn("Hand-written", manual_file.read_text(encoding="utf-8"))

    def test_global_scope_shared_rules_no_nested_file(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "global.instructions.md").write_text(
                _instruction_file("**/*", "# Global governance"), encoding="utf-8"
            )

            updated = sync_nested_agents_md(root)

            self.assertEqual(updated, [])

    def test_file_path_apply_to_does_not_crash(self) -> None:
        """Regression: GHI #142.

        An applyTo pattern that resolves to a literal file path (no glob
        wildcards) must not cause sync_nested_agents_md to attempt writing
        AGENTS.md into the file, which triggers ENOTDIR.
        """
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            # The runbook is a real file, not a directory
            runbook_dir = root / "docs" / "user"
            runbook_dir.mkdir(parents=True)
            (runbook_dir / "runbook.md").write_text("# Runbook", encoding="utf-8")

            (inst / "filepath.instructions.md").write_text(
                _instruction_file("docs/user/runbook.md", "# File-scoped rule"), encoding="utf-8"
            )

            updated = sync_nested_agents_md(root)

            self.assertEqual(updated, [])
            # The runbook.md file must still exist and be unchanged
            self.assertTrue((runbook_dir / "runbook.md").is_file())


class TestExtractSubtreePrefix(unittest.TestCase):
    """Test _extract_subtree_prefix() — GHI #142 regression guards."""

    def test_glob_pattern_returns_prefix(self) -> None:
        from gzkit.rules import _extract_subtree_prefix

        self.assertEqual(_extract_subtree_prefix("docs/**"), "docs")

    def test_nested_glob_returns_full_prefix(self) -> None:
        from gzkit.rules import _extract_subtree_prefix

        self.assertEqual(
            _extract_subtree_prefix("src/gzkit/commands/**"),
            "src/gzkit/commands",
        )

    def test_file_path_returns_none(self) -> None:
        """A literal file path (no wildcards) is not a subtree prefix."""
        from gzkit.rules import _extract_subtree_prefix

        self.assertIsNone(_extract_subtree_prefix("docs/user/runbook.md"))

    def test_global_pattern_returns_none(self) -> None:
        from gzkit.rules import _extract_subtree_prefix

        self.assertIsNone(_extract_subtree_prefix("**/*"))


class TestSyncClaudeRulesFromRules(unittest.TestCase):
    """Test that sync_claude_rules (now in rules.py) still works."""

    def test_claude_only_rules_still_mirror(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "secret.instructions.md").write_text(
                _instruction_file("src/**", "# Secret Claude rule", audience="claude-only"),
                encoding="utf-8",
            )

            updated = sync_claude_rules(root)

            self.assertEqual(len(updated), 1)
            target = root / ".claude" / "rules" / "secret.md"
            self.assertTrue(target.exists())
            self.assertIn("Secret Claude rule", target.read_text(encoding="utf-8"))

    def test_uses_config_claude_rules_path(self) -> None:
        from gzkit.config import GzkitConfig, PathConfig

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "demo.instructions.md").write_text(
                _instruction_file("tests/**", "# Demo rule"), encoding="utf-8"
            )

            config = GzkitConfig(paths=PathConfig(claude_rules=".custom/rules"))
            updated = sync_claude_rules(root, config)

            self.assertEqual(len(updated), 1)
            target = root / ".custom" / "rules" / "demo.md"
            self.assertTrue(target.exists())


class TestValidateRulePlacement(unittest.TestCase):
    """Test validate_rule_placement()."""

    def test_catches_missing_nested_agents_md(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test rules"), encoding="utf-8"
            )
            (root / "tests").mkdir()
            # Do NOT create nested AGENTS.md

            warnings = validate_rule_placement(root)

            self.assertTrue(len(warnings) > 0)
            self.assertIn("tests", warnings[0])
            self.assertIn("no nested AGENTS.md", warnings[0])

    def test_no_warnings_when_nested_agents_exists(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test rules"), encoding="utf-8"
            )
            tests_dir = root / "tests"
            tests_dir.mkdir()
            (tests_dir / "AGENTS.md").write_text(
                f"{_GENERATED_MARKER}\n# Tests\n", encoding="utf-8"
            )

            warnings = validate_rule_placement(root)

            self.assertEqual(warnings, [])

    def test_catches_claude_only_not_mirrored(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "secret.instructions.md").write_text(
                _instruction_file("src/**", "# Secret", audience="claude-only"), encoding="utf-8"
            )
            # No .claude/rules/secret.md exists

            warnings = validate_rule_placement(root)

            self.assertTrue(len(warnings) > 0)
            self.assertIn("claude-only", warnings[0])

    def test_vendor_mirror_subtree_does_not_require_nested_agents_md(self) -> None:
        """Self-referential vendor-mirror surfaces are output destinations, not
        user-content subtrees. A rule whose paths enumerate the vendor-mirror
        directory the rule itself is rendered into must not produce a
        rule-placement warning for that directory (GHI #375).
        """
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            inst = root / ".github" / "instructions"
            inst.mkdir(parents=True)
            (inst / "skill_surface_sync.instructions.md").write_text(
                _instruction_file(
                    ".claude/**, .gzkit/skills/**, .github/skills/**, .github/instructions/**",
                    "# Skill surface sync rule",
                ),
                encoding="utf-8",
            )
            (root / ".gzkit" / "skills").mkdir(parents=True)
            (root / ".github" / "skills").mkdir(parents=True)
            (root / ".github" / "instructions").mkdir(parents=True, exist_ok=True)
            (root / ".claude").mkdir()

            warnings = validate_rule_placement(root)

            for w in warnings:
                self.assertNotIn(".claude", w)
                self.assertNotIn(".github/instructions", w)
                self.assertNotIn(".github/skills", w)


# ---------------------------------------------------------------------------
# Canonical rule tests (OBPI-0.16.0-02)
# ---------------------------------------------------------------------------

_VALID_RULE = """\
---
id: test-rule
paths:
  - "src/**/*.py"
description: A test rule
---

# Test Rule

Body content here.
"""

_VALID_RULE_MULTI_PATH = """\
---
id: multi-path
paths:
  - "src/**/*.py"
  - "tests/**/*.py"
description: Multi-path rule
---

# Multi-path Rule
"""


class TestRuleFrontmatter(unittest.TestCase):
    """RuleFrontmatter Pydantic model constraints.

    @covers REQ-0.17.0-02-01
    """

    @covers("REQ-0.17.0-02-01")
    def test_valid_frontmatter(self) -> None:
        from gzkit.rules import RuleFrontmatter

        fm = RuleFrontmatter(id="my-rule", paths=["src/**"], description="A rule")
        self.assertEqual(fm.id, "my-rule")
        self.assertEqual(fm.paths, ["src/**"])
        self.assertEqual(fm.description, "A rule")

    @covers("REQ-0.17.0-02-01")
    def test_frozen(self) -> None:
        from pydantic import ValidationError

        from gzkit.rules import RuleFrontmatter

        fm = RuleFrontmatter(id="x", paths=["*"], description="d")
        with self.assertRaises(ValidationError):
            fm.id = "changed"

    @covers("REQ-0.17.0-02-01")
    def test_extra_forbid(self) -> None:
        from pydantic import ValidationError

        from gzkit.rules import RuleFrontmatter

        with self.assertRaises(ValidationError):
            RuleFrontmatter(
                id="x",
                paths=["*"],
                description="d",
                bogus="y",  # type: ignore[call-arg]
            )

    @covers("REQ-0.17.0-02-01")
    def test_paths_required_nonempty(self) -> None:
        from pydantic import ValidationError

        from gzkit.rules import RuleFrontmatter

        with self.assertRaises(ValidationError):
            RuleFrontmatter(id="x", paths=[], description="d")

    @covers("REQ-0.17.0-02-01")
    def test_missing_required_fields(self) -> None:
        from pydantic import ValidationError

        from gzkit.rules import RuleFrontmatter

        with self.assertRaises(ValidationError):
            RuleFrontmatter(id="x")


class TestCanonicalRule(unittest.TestCase):
    """CanonicalRule model.

    @covers REQ-0.17.0-02-01
    """

    @covers("REQ-0.17.0-02-01")
    def test_valid_canonical_rule(self) -> None:
        from gzkit.rules import CanonicalRule, RuleFrontmatter

        fm = RuleFrontmatter(id="test", paths=["*"], description="d")
        rule = CanonicalRule(frontmatter=fm, body="# Body", source_path="test.md")
        self.assertEqual(rule.frontmatter.id, "test")
        self.assertEqual(rule.body, "# Body")

    @covers("REQ-0.17.0-02-01")
    def test_frozen(self) -> None:
        from pydantic import ValidationError

        from gzkit.rules import CanonicalRule, RuleFrontmatter

        fm = RuleFrontmatter(id="test", paths=["*"], description="d")
        rule = CanonicalRule(frontmatter=fm, body="# Body", source_path="test.md")
        with self.assertRaises(ValidationError):
            rule.body = "changed"


class TestRenderRulesToDirPathForm(unittest.TestCase):
    """render_rules_to_dir returns project-relative posix paths (direct-fix).

    sync_all prints its write set verbatim in apply mode while dry-run
    (plan_sync_all) normalizes to project-relative posix. When this renderer
    returned absolute strings, apply and dry diverged for rule files —
    masked on macOS but a hard failure on Linux CI.
    """

    def test_returns_project_relative_posix_with_root(self) -> None:
        from gzkit.rules import CanonicalRule, RuleFrontmatter, render_rules_to_dir

        fm = RuleFrontmatter(id="pythonic", paths=["*"], description="d")
        rule = CanonicalRule(frontmatter=fm, body="# Body", source_path="pythonic.md")
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            written = render_rules_to_dir(
                [rule], root / ".claude" / "rules", "claude", project_root=root
            )
            self.assertEqual(written, [".claude/rules/pythonic.md"])
            for path in written:
                self.assertFalse(
                    Path(path).is_absolute(),
                    f"render_rules_to_dir must return relative paths; got absolute {path}",
                )

    def test_absolute_without_root_preserves_legacy_contract(self) -> None:
        from gzkit.rules import CanonicalRule, RuleFrontmatter, render_rules_to_dir

        fm = RuleFrontmatter(id="pythonic", paths=["*"], description="d")
        rule = CanonicalRule(frontmatter=fm, body="# Body", source_path="pythonic.md")
        with tempfile.TemporaryDirectory() as tmp:
            target = Path(tmp) / ".claude" / "rules"
            written = render_rules_to_dir([rule], target, "claude")
            self.assertTrue(Path(written[0]).is_absolute())


class TestLoadRule(unittest.TestCase):
    """load_rule() file parsing and validation.

    @covers REQ-0.17.0-02-01
    """

    @covers("REQ-0.17.0-02-01")
    def test_load_valid_rule(self) -> None:
        from gzkit.rules import load_rule

        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "test-rule.md"
            path.write_text(_VALID_RULE, encoding="utf-8")

            rule = load_rule(path)

            self.assertEqual(rule.frontmatter.id, "test-rule")
            self.assertEqual(rule.frontmatter.paths, ["src/**/*.py"])
            self.assertEqual(rule.frontmatter.description, "A test rule")
            self.assertIn("Body content here.", rule.body)

    @covers("REQ-0.17.0-02-01")
    def test_load_multi_path_rule(self) -> None:
        from gzkit.rules import load_rule

        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "multi.md"
            path.write_text(_VALID_RULE_MULTI_PATH, encoding="utf-8")

            rule = load_rule(path)

            self.assertEqual(len(rule.frontmatter.paths), 2)

    def test_missing_frontmatter_raises_value_error(self) -> None:
        from gzkit.rules import load_rule

        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "bad.md"
            path.write_text("# No frontmatter here", encoding="utf-8")

            with self.assertRaises(ValueError):
                load_rule(path)

    def test_unclosed_frontmatter_raises_value_error(self) -> None:
        from gzkit.rules import load_rule

        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "bad.md"
            path.write_text("---\nid: x\n# No closing delimiter", encoding="utf-8")

            with self.assertRaises(ValueError):
                load_rule(path)

    def test_invalid_frontmatter_raises_validation_error(self) -> None:
        from pydantic import ValidationError

        from gzkit.rules import load_rule

        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "bad.md"
            path.write_text("---\nid: x\n---\n# Missing paths and description", encoding="utf-8")

            with self.assertRaises(ValidationError):
                load_rule(path)


class TestLoadRules(unittest.TestCase):
    """load_rules() directory loading."""

    def test_load_multiple_rules_sorted_by_id(self) -> None:
        from gzkit.rules import load_rules

        with tempfile.TemporaryDirectory() as tmp:
            rules_dir = Path(tmp)
            (rules_dir / "zebra.md").write_text(
                '---\nid: zebra\npaths:\n  - "**"\ndescription: Z rule\n---\n# Z',
                encoding="utf-8",
            )
            (rules_dir / "alpha.md").write_text(
                '---\nid: alpha\npaths:\n  - "**"\ndescription: A rule\n---\n# A',
                encoding="utf-8",
            )

            rules = load_rules(rules_dir)

            self.assertEqual(len(rules), 2)
            self.assertEqual(rules[0].frontmatter.id, "alpha")
            self.assertEqual(rules[1].frontmatter.id, "zebra")

    def test_load_empty_directory(self) -> None:
        from gzkit.rules import load_rules

        with tempfile.TemporaryDirectory() as tmp:
            rules = load_rules(Path(tmp))
            self.assertEqual(rules, [])

    def test_nonexistent_directory_raises_value_error(self) -> None:
        from gzkit.rules import load_rules

        with self.assertRaises(ValueError):
            load_rules(Path("/nonexistent/path/rules"))

    @covers("REQ-0.17.0-02-05")
    def test_load_actual_canonical_rules(self) -> None:
        """Integration: load the real .gzkit/rules/ directory."""
        from gzkit.rules import load_rules

        rules_dir = Path(".gzkit/rules")
        if not rules_dir.is_dir():
            self.skipTest(".gzkit/rules/ not found")

        rules = load_rules(rules_dir)

        self.assertGreaterEqual(len(rules), 11)
        ids = {r.frontmatter.id for r in rules}
        self.assertIn("governance-core", ids)
        self.assertIn("tests", ids)
        self.assertIn("models", ids)
        self.assertIn("pythonic", ids)
        self.assertIn("cli", ids)


class TestRegistryRuleIntegration(unittest.TestCase):
    """Registry now has RuleFrontmatter for Rule type."""

    def test_rule_type_has_frontmatter_model(self) -> None:
        from gzkit.registry import REGISTRY
        from gzkit.rules import RuleFrontmatter

        rule_type = REGISTRY.get("Rule")
        self.assertIs(rule_type.frontmatter_model, RuleFrontmatter)

    def test_rule_type_has_schema_name(self) -> None:
        from gzkit.registry import REGISTRY

        rule_type = REGISTRY.get("Rule")
        self.assertEqual(rule_type.schema_name, "rule")

    def test_validate_artifact_rule_valid(self) -> None:
        from gzkit.registry import REGISTRY

        fm = {"id": "test-rule", "paths": ["src/**"], "description": "A rule"}
        errors = REGISTRY.validate_artifact("Rule", fm, "test.md")
        self.assertEqual(errors, [])

    def test_validate_artifact_rule_invalid(self) -> None:
        from gzkit.registry import REGISTRY

        fm = {"id": "test-rule"}  # missing paths and description
        errors = REGISTRY.validate_artifact("Rule", fm, "test.md")
        self.assertGreater(len(errors), 0)


class TestRulesLayoutDualSurface(unittest.TestCase):
    """Dual-surface invariant for OBPI-0.0.32-03: .gzkit/rules <-> src/gzkit/rules."""

    def _repo_root(self) -> Path:
        return Path(__file__).resolve().parent.parent

    @covers("REQ-0.0.32-03-03")
    def test_rules_py_does_not_exist(self) -> None:
        rules_py = self._repo_root() / "src" / "gzkit" / "rules.py"
        self.assertFalse(
            rules_py.exists(),
            f"src/gzkit/rules.py must not exist after OBPI-0.0.32-03 migration; found {rules_py}",
        )

    @covers("REQ-0.0.32-03-03")
    def test_rules_init_exists(self) -> None:
        init_py = self._repo_root() / "src" / "gzkit" / "rules" / "__init__.py"
        self.assertTrue(
            init_py.exists(),
            f"src/gzkit/rules/__init__.py must exist after migration; missing {init_py}",
        )

    @covers("REQ-0.0.32-03-01")
    @covers("REQ-0.0.32-08-06")
    def test_dual_surface_rule_count(self) -> None:
        # AGENTS.md is package-internal (not a canonical rule slug per OBPI-04).
        canonical = self._repo_root() / ".gzkit" / "rules"
        package = self._repo_root() / "src" / "gzkit" / "rules"
        canonical_count = len([p for p in canonical.glob("*.md") if p.name != "AGENTS.md"])
        package_count = (
            len([p for p in package.glob("*.md") if p.name != "AGENTS.md"])
            if package.exists()
            else 0
        )
        self.assertGreater(canonical_count, 0, "Canonical .gzkit/rules has no .md files")
        self.assertEqual(
            canonical_count,
            package_count,
            f"Rule count mismatch: canonical={canonical_count}, package={package_count}",
        )

    @covers("REQ-0.0.32-03-02")
    @covers("REQ-0.0.32-08-03")
    @covers("REQ-0.0.32-15-10")  # audit-exempt: regression-invariant-overlay OBPI-03 byte parity
    def test_dual_surface_byte_parity(self) -> None:
        # AGENTS.md is package-internal (not a canonical rule slug per OBPI-04).
        canonical = self._repo_root() / ".gzkit" / "rules"
        package = self._repo_root() / "src" / "gzkit" / "rules"
        canonical_slugs = {p.name for p in canonical.glob("*.md") if p.name != "AGENTS.md"}
        package_slugs = (
            {p.name for p in package.glob("*.md") if p.name != "AGENTS.md"}
            if package.exists()
            else set()
        )
        missing_in_package = canonical_slugs - package_slugs
        extra_in_package = package_slugs - canonical_slugs
        self.assertEqual(
            missing_in_package,
            set(),
            f"Rules missing from package surface: {sorted(missing_in_package)}",
        )
        self.assertEqual(
            extra_in_package, set(), f"Extra files in package surface: {sorted(extra_in_package)}"
        )
        for slug in sorted(canonical_slugs):
            self.assertEqual(
                (canonical / slug).read_bytes(),
                (package / slug).read_bytes(),
                f"Byte drift in {slug} between .gzkit/rules and src/gzkit/rules",
            )

    @covers("REQ-0.0.32-03-03")
    def test_public_symbol_reexports_resolve(self) -> None:
        from gzkit.rules import (  # noqa: PLC0415 — runtime check of import surface
            CanonicalRule,
            ClassifiedRule,
            RuleFrontmatter,
            _extract_body_after_frontmatter,
            _extract_subtree_prefix,
            _parse_canonical_frontmatter,
            _parse_instruction_frontmatter,
            classify_instruction_rules,
            load_rule,
            load_rules,
            render_rule_for_claude,
            render_rule_for_copilot,
            render_rules_to_dir,
            sync_claude_rules,
            sync_nested_agents_md,
            validate_rule_placement,
        )

        for symbol in (
            CanonicalRule,
            ClassifiedRule,
            RuleFrontmatter,
            _extract_body_after_frontmatter,
            _extract_subtree_prefix,
            _parse_canonical_frontmatter,
            _parse_instruction_frontmatter,
            classify_instruction_rules,
            load_rule,
            load_rules,
            render_rule_for_claude,
            render_rule_for_copilot,
            render_rules_to_dir,
            sync_claude_rules,
            sync_nested_agents_md,
            validate_rule_placement,
        ):
            self.assertIsNotNone(symbol)


class TestRulesLayoutScopeGuards(unittest.TestCase):
    """Negative-scope guards for OBPI-0.0.32-03 (REQ-04 through REQ-08)."""

    def _repo_root(self) -> Path:
        return Path(__file__).resolve().parent.parent

    @covers("REQ-0.0.32-03-06")
    def test_req06_pyproject_has_rules_wheel_include(self) -> None:
        """REQ-06: pyproject.toml carries rules wheel-include (landed by OBPI-0.0.32-06)."""
        pyproject = self._repo_root() / "pyproject.toml"
        body = pyproject.read_text(encoding="utf-8")
        self.assertIn(
            "src/gzkit/rules/**/*.md",
            body,
            "REQ-06: wheel-include glob expected; OBPI-06 landed wheel manifest",
        )

    @covers("REQ-0.0.32-03-07")
    def test_req07_agent_sync_propagates_rules_to_pkg(self) -> None:
        """REQ-07: sync_pkg_surfaces propagates .gzkit/rules/ to src/gzkit/rules/ (OBPI-08)."""
        from gzkit.config import GzkitConfig  # noqa: PLC0415
        from gzkit.sync_surfaces import sync_pkg_surfaces  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            pkg_init = root / "src" / "gzkit" / "rules" / "__init__.py"
            pkg_init.parent.mkdir(parents=True)
            pkg_init.write_text("", encoding="utf-8")
            canonical = root / ".gzkit" / "rules" / "test-rule.md"
            canonical.parent.mkdir(parents=True)
            canonical.write_text("# Test Rule\n\nContent.\n", encoding="utf-8")

            sync_pkg_surfaces(root, GzkitConfig(project_name="test"))

            pkg_copy = root / "src" / "gzkit" / "rules" / "test-rule.md"
            self.assertTrue(
                pkg_copy.exists(),
                "REQ-07: OBPI-08 must propagate .gzkit/rules -> src/gzkit/rules",
            )
            self.assertEqual(pkg_copy.read_bytes(), canonical.read_bytes())

    @covers("REQ-0.0.32-03-08")
    def test_req08_rules_package_imports_resolve(self) -> None:
        """REQ-08: post-migration import surface is healthy."""
        import importlib  # noqa: PLC0415 — runtime import-resolution probe

        module = importlib.import_module("gzkit.rules")
        self.assertTrue(hasattr(module, "load_rules"))
        self.assertTrue(hasattr(module, "RuleFrontmatter"))
        self.assertTrue(hasattr(module, "sync_claude_rules"))
        self.assertTrue(callable(module.load_rules))


class TestCoreRulesRegistry(unittest.TestCase):
    """Tests for CORE_RULES, _iter_canonical_rule_slugs, scaffold_core_rules.

    @covers REQ-0.0.32-04-01
    @covers REQ-0.0.32-04-02
    @covers REQ-0.0.32-04-03
    @covers REQ-0.0.32-04-06
    """

    @covers("REQ-0.0.32-04-01")
    def test_core_rules_is_a_list(self) -> None:
        from gzkit.rules import CORE_RULES  # noqa: PLC0415

        self.assertIsInstance(CORE_RULES, list)

    @covers("REQ-0.0.32-04-01")
    def test_core_rules_has_at_least_one_slug(self) -> None:
        from gzkit.rules import CORE_RULES  # noqa: PLC0415

        self.assertGreater(len(CORE_RULES), 0)

    @covers("REQ-0.0.32-04-01")
    def test_core_rules_slugs_are_strings(self) -> None:
        from gzkit.rules import CORE_RULES  # noqa: PLC0415

        for slug in CORE_RULES:
            self.assertIsInstance(slug, str)

    @covers("REQ-0.0.32-04-01")
    def test_core_rules_contains_known_slugs(self) -> None:
        from gzkit.rules import CORE_RULES  # noqa: PLC0415

        # Check a representative set of known rules
        for expected in ("governance-core", "tests", "models", "pythonic"):
            self.assertIn(expected, CORE_RULES, f"{expected!r} missing from CORE_RULES")

    @covers("REQ-0.0.32-04-02")
    def test_iter_canonical_rule_slugs_returns_entries(self) -> None:
        from gzkit.rules import _iter_canonical_rule_slugs  # noqa: PLC0415

        slugs = list(_iter_canonical_rule_slugs())
        self.assertGreater(len(slugs), 0)

    @covers("REQ-0.0.32-04-02")
    def test_iter_canonical_rule_slugs_count_matches_core_rules(self) -> None:
        from gzkit.rules import CORE_RULES, _iter_canonical_rule_slugs  # noqa: PLC0415

        slugs = list(_iter_canonical_rule_slugs())
        self.assertEqual(len(slugs), len(CORE_RULES))

    @covers("REQ-0.0.32-04-02")
    def test_iter_canonical_rule_slugs_excludes_agents_md(self) -> None:
        """AGENTS.md is a package-internal agent contract; not an operator rule."""
        from gzkit.rules import _iter_canonical_rule_slugs  # noqa: PLC0415

        names = [e.name for e in _iter_canonical_rule_slugs()]
        self.assertNotIn("AGENTS.md", names)

    @covers("REQ-0.0.32-04-03")
    def test_scaffold_core_rules_is_callable(self) -> None:
        from gzkit.rules import scaffold_core_rules  # noqa: PLC0415

        self.assertTrue(callable(scaffold_core_rules))

    @covers("REQ-0.0.32-04-03")
    def test_scaffold_core_rules_writes_files_to_fresh_dir(self) -> None:
        from gzkit.config import GzkitConfig, PathConfig  # noqa: PLC0415
        from gzkit.rules import CORE_RULES, scaffold_core_rules  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / ".gzkit.json").write_text("{}", encoding="utf-8")
            config = GzkitConfig(paths=PathConfig())
            created = scaffold_core_rules(root, config)
            self.assertGreater(len(created), 0)
            rules_dir = root / ".gzkit" / "rules"
            self.assertTrue(rules_dir.exists())
            created_names = {p.name for p in created}
            for slug in CORE_RULES:
                self.assertIn(f"{slug}.md", created_names)
            self.assertIn("complexity-thresholds.json", created_names)

    @covers("REQ-0.0.32-04-03")
    def test_scaffold_core_rules_returns_list_of_paths(self) -> None:
        from gzkit.config import GzkitConfig, PathConfig  # noqa: PLC0415
        from gzkit.rules import scaffold_core_rules  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            config = GzkitConfig(paths=PathConfig())
            created = scaffold_core_rules(root, config)
            self.assertIsInstance(created, list)
            for p in created:
                self.assertIsInstance(p, Path)

    @covers("REQ-0.0.32-04-06")
    def test_scaffold_core_rules_skip_existing_preserves_operator_edits(self) -> None:
        from gzkit.config import GzkitConfig, PathConfig  # noqa: PLC0415
        from gzkit.rules import scaffold_core_rules  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            config = GzkitConfig(paths=PathConfig())
            rules_dir = root / ".gzkit" / "rules"
            rules_dir.mkdir(parents=True)
            # Pre-create an operator-edited file
            (rules_dir / "governance-core.md").write_text("# My custom edit\n", encoding="utf-8")

            scaffold_core_rules(root, config, skip_existing=True)

            # Operator edit must be preserved
            content = (rules_dir / "governance-core.md").read_text(encoding="utf-8")
            self.assertEqual(content, "# My custom edit\n")

    @covers("REQ-0.0.32-04-06")
    def test_scaffold_core_rules_skip_existing_false_overwrites(self) -> None:
        from gzkit.config import GzkitConfig, PathConfig  # noqa: PLC0415
        from gzkit.rules import scaffold_core_rules  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            config = GzkitConfig(paths=PathConfig())
            rules_dir = root / ".gzkit" / "rules"
            rules_dir.mkdir(parents=True)
            (rules_dir / "governance-core.md").write_text("# Old content\n", encoding="utf-8")

            scaffold_core_rules(root, config, skip_existing=False)

            # Should have been overwritten — old placeholder content is gone
            content = (rules_dir / "governance-core.md").read_text(encoding="utf-8")
            self.assertNotEqual(content, "# Old content\n")


class TestClassifyRuleFile(unittest.TestCase):
    """Per-surface classifier for the rules canonical surface (REQ-0.0.32-15-04/05).

    Signature-compatible with ``gzkit.chores._classify_chore_file``: returns
    one of ``"canonical"``, ``"package_only"``, or ``"runtime_state"`` for any
    path under (or relative to) ``src/gzkit/rules/`` and ``.gzkit/rules/``.
    """

    @covers("REQ-0.0.32-15-04")
    def test_importable(self) -> None:
        """``_classify_rule_file`` is importable from ``gzkit.rules``."""
        try:
            from gzkit.rules import _classify_rule_file  # noqa: PLC0415, F401
        except ImportError as e:  # pragma: no cover - failure surfaces in assertion
            self.fail(
                f"_classify_rule_file must be importable from gzkit.rules; got ImportError: {e}"
            )

    @covers("REQ-0.0.32-15-04")
    def test_package_only_init_py(self) -> None:
        """``__init__.py`` files classify as ``package_only``."""
        from gzkit.rules import _classify_rule_file  # noqa: PLC0415

        result = _classify_rule_file(Path("src/gzkit/rules/__init__.py"))
        self.assertEqual(result, "package_only")

    @covers("REQ-0.0.32-15-04")
    def test_canonical_md(self) -> None:
        """``*.md`` files classify as ``canonical`` (default for markdown)."""
        from gzkit.rules import _classify_rule_file  # noqa: PLC0415

        result = _classify_rule_file(Path("src/gzkit/rules/governance-core.md"))
        self.assertEqual(result, "canonical")

    @covers("REQ-0.0.32-15-05")
    def test_package_only_scaffolder_py(self) -> None:
        """``_scaffolder.py`` (no ``.gzkit/`` counterpart) classifies ``package_only``."""
        from gzkit.rules import _classify_rule_file  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "src" / "gzkit" / "rules").mkdir(parents=True)
            (root / ".gzkit" / "rules").mkdir(parents=True)
            scaffolder = root / "src" / "gzkit" / "rules" / "_scaffolder.py"
            scaffolder.write_text("# stub\n", encoding="utf-8")
            # No counterpart at .gzkit/rules/_scaffolder.py.

            result = _classify_rule_file(scaffolder, project_root=root)
            self.assertEqual(result, "package_only")

    @covers("REQ-0.0.32-15-05")
    def test_canonical_json_with_gzkit_counterpart(self) -> None:
        """JSON file with a ``.gzkit/rules/`` counterpart classifies ``canonical``."""
        from gzkit.rules import _classify_rule_file  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "src" / "gzkit" / "rules").mkdir(parents=True)
            (root / ".gzkit" / "rules").mkdir(parents=True)
            pkg_json = root / "src" / "gzkit" / "rules" / "complexity-thresholds.json"
            pkg_json.write_text("{}\n", encoding="utf-8")
            # Counterpart exists at .gzkit/rules/complexity-thresholds.json.
            (root / ".gzkit" / "rules" / "complexity-thresholds.json").write_text(
                "{}\n", encoding="utf-8"
            )

            result = _classify_rule_file(pkg_json, project_root=root)
            self.assertEqual(result, "canonical")

    @covers("REQ-0.0.32-15-05")
    def test_package_only_json_without_gzkit_counterpart(self) -> None:
        """JSON file without a ``.gzkit/rules/`` counterpart classifies ``package_only``."""
        from gzkit.rules import _classify_rule_file  # noqa: PLC0415

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "src" / "gzkit" / "rules").mkdir(parents=True)
            (root / ".gzkit" / "rules").mkdir(parents=True)
            pkg_json = root / "src" / "gzkit" / "rules" / "internal-only.json"
            pkg_json.write_text("{}\n", encoding="utf-8")
            # No counterpart under .gzkit/rules/.

            result = _classify_rule_file(pkg_json, project_root=root)
            self.assertEqual(result, "package_only")

    @covers("REQ-0.0.32-15-05")
    def test_canonical_when_path_under_gzkit_rules(self) -> None:
        """Files already under ``.gzkit/rules/`` classify ``canonical``."""
        from gzkit.rules import _classify_rule_file  # noqa: PLC0415

        result = _classify_rule_file(Path(".gzkit/rules/governance-core.md"))
        self.assertEqual(result, "canonical")

    @covers("REQ-0.0.32-15-09")
    def test_pyproject_includes_rules_json(self) -> None:
        """pyproject.toml must include src/gzkit/rules/**/*.json for canonical json files."""
        import tomllib

        project_root = Path(__file__).resolve().parents[1]
        pyproject = project_root / "pyproject.toml"
        with open(pyproject, "rb") as f:
            data = tomllib.load(f)
        include_globs = (
            data.get("tool", {})
            .get("hatch", {})
            .get("build", {})
            .get("targets", {})
            .get("wheel", {})
            .get("include", [])
        )
        self.assertIn(
            "src/gzkit/rules/**/*.json",
            include_globs,
            "pyproject.toml must include src/gzkit/rules/**/*.json for canonical json files",
        )


class TestSyncClassifierIntegration(unittest.TestCase):
    """sync_pkg_surfaces consults per-surface classifiers (REQ-0.0.32-15-07)."""

    @covers("REQ-0.0.32-15-07")
    def test_sync_surfaces_has_rules_classifier_integration(self) -> None:
        """sync_pkg_surfaces imports and consults _classify_rule_file for rules sync."""
        sync_src = Path(__file__).resolve().parents[1] / "src" / "gzkit" / "sync_surfaces.py"
        content = sync_src.read_text(encoding="utf-8")
        self.assertIn(
            "_classify_rule_file",
            content,
            "sync_surfaces.py must import and consult _classify_rule_file for rules sync",
        )
        self.assertIn(
            "_classify_persona_file",
            content,
            "sync_surfaces.py must import and consult _classify_persona_file for personas sync",
        )
        self.assertIn(
            "_classify_template_file",
            content,
            "sync_surfaces.py must import and consult _classify_template_file for templates sync",
        )
