"""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")
            )

            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")
            )

            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"))

            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")
            )

            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.")
            )
            (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()
            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")
            )
            # 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")
            )
            (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")

            (inst / "tests.instructions.md").write_text(
                _instruction_file("tests/**", "# Test rules")
            )
            (root / "tests").mkdir()

            sync_nested_agents_md(root)

            self.assertTrue(manual_file.exists())
            self.assertIn("Hand-written", manual_file.read_text())

    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")
            )

            updated = sync_nested_agents_md(root)

            self.assertEqual(updated, [])


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")
            )

            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())

    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"))

            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")
            )
            (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")
            )
            tests_dir = root / "tests"
            tests_dir.mkdir()
            (tests_dir / "AGENTS.md").write_text(f"{_GENERATED_MARKER}\n# Tests\n")

            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")
            )
            # No .claude/rules/secret.md exists

            warnings = validate_rule_placement(root)

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


# ---------------------------------------------------------------------------
# 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 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)
