"""Tests for commit_check.rule_builder module."""

from commit_check.rule_builder import ValidationRule, RuleBuilder
from commit_check.rules_catalog import RuleCatalogEntry
import pytest

# String constants used across tests
BAD_FORMAT_ERROR = "Bad format"


class TestValidationRule:
    @pytest.mark.benchmark
    def test_validation_rule_to_dict_with_ignored(self):
        """Test ValidationRule.to_dict() method with ignored field."""
        rule = ValidationRule(check="test_check", ignored=["ignored1", "ignored2"])

        result = rule.to_dict()
        assert result["ignored"] == ["ignored1", "ignored2"]


class TestRuleBuilder:
    @pytest.mark.benchmark
    def test_rule_builder_conventional_commits_disabled(self):
        """Test RuleBuilder when conventional_commits is disabled (line 115)."""
        config = {"commit": {"conventional_commits": False}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="message", regex="", error="", suggest=""
        )

        # This should return None when conventional_commits is False
        rule = builder._build_conventional_commit_rule(catalog_entry)
        assert rule is None

    @pytest.mark.benchmark
    def test_rule_builder_conventional_branch_disabled(self):
        """Test RuleBuilder when conventional_branch is disabled (line 133)."""
        config = {"branch": {"conventional_branch": False}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

        # This should return None when conventional_branch is False
        rule = builder._build_conventional_branch_rule(catalog_entry)
        assert rule is None

    @pytest.mark.benchmark
    def test_rule_builder_ignore_authors_list(self):
        """Test RuleBuilder with ignore_authors list."""
        config = {"commit": {"ignore_authors": ["spam@example.com", "bot@example.com"]}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="ignore_authors",
            regex="",
            error="Author ignored",
            suggest="Use different author",
        )

        # This should create a rule with ignored authors
        rule = builder._build_author_list_rule(catalog_entry, "ignore_authors")
        assert rule is not None
        assert rule.check == "ignore_authors"
        assert rule.ignored == ["spam@example.com", "bot@example.com"]

    @pytest.mark.benchmark
    def test_rule_builder_invalid_author_list_type(self):
        """Test RuleBuilder with invalid author list type returns None."""
        config = {"commit": {"ignore_authors": "not_a_list"}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="ignore_authors", regex="", error="", suggest=""
        )

        # This should return None for invalid type
        rule = builder._build_author_list_rule(catalog_entry, "ignore_authors")
        assert rule is None

    @pytest.mark.benchmark
    def test_rule_builder_length_rule_with_format(self):
        """Test RuleBuilder length rule with formatted error message (lines 154-160)."""
        config = {"commit": {"max_length": 50}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="max_length",
            regex="",
            error="Message too long: max {max_len} characters",
            suggest="Keep message short",
        )

        # This should create a rule with formatted error
        rule = builder._build_length_rule(catalog_entry, "max_length")
        assert rule is not None
        assert rule.check == "max_length"
        assert rule.error == "Message too long: max 50 characters"
        assert rule.suggest == "Keep message short"
        assert rule.value == 50

    @pytest.mark.benchmark
    def test_rule_builder_merge_base_rule_valid_target(self):
        """Test RuleBuilder merge base rule with valid target (line 193)."""
        config = {"branch": {"require_rebase_target": "main"}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="require_rebase_target",
            regex="",
            error="Branch must be rebased on target",
            suggest="Rebase on target branch",
        )

        # This should create a rule with regex target
        rule = builder._build_merge_base_rule(catalog_entry)
        assert rule is not None
        assert rule.check == "require_rebase_target"
        assert rule.regex == "main"
        assert rule.error == "Branch must be rebased on target"
        assert rule.suggest == "Rebase on target branch"

    @pytest.mark.benchmark
    def test_rule_builder_boolean_rule_enabled(self):
        """Test RuleBuilder boolean rule when enabled (line 236)."""
        config = {"commit": {"require_signed_off_by": True}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="require_signed_off_by",
            regex="^Signed-off-by:",
            error="Missing signoff",
            suggest="Add Signed-off-by line",
        )

        # This should create a rule when boolean is True
        rule = builder._build_boolean_rule(catalog_entry, builder.commit_config)
        assert rule is not None
        assert rule.check == "require_signed_off_by"
        assert rule.regex == "^Signed-off-by:"
        assert rule.error == "Missing signoff"
        assert rule.suggest == "Add Signed-off-by line"
        assert rule.value is True

    @pytest.mark.benchmark
    def test_rule_builder_boolean_rule_subject_disabled(self):
        """Test RuleBuilder boolean rule for subject checks when disabled (line 232)."""
        config = {"commit": {"subject_capitalized": False}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="subject_capitalized",
            regex="^[A-Z]",
            error="Subject must be capitalized",
            suggest="Capitalize first letter",
        )

        # This should return None when subject_capitalized is False (line 232)
        rule = builder._build_boolean_rule(catalog_entry, builder.commit_config)
        assert rule is None

    @pytest.mark.benchmark
    def test_rule_builder_allow_branch_names_default(self):
        """Test RuleBuilder with default branch names (no allow_branch_names configured)."""
        config = {"branch": {"conventional_branch": True}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

        rule = builder._build_conventional_branch_rule(catalog_entry)
        assert rule is not None
        # Should include default branch names: master, main, HEAD, PR-*
        assert "(master)" in rule.regex
        assert "(main)" in rule.regex
        assert "(HEAD)" in rule.regex
        assert "(PR-.+)" in rule.regex

    @pytest.mark.benchmark
    def test_rule_builder_allow_branch_names_custom(self):
        """Test RuleBuilder with custom branch names (allow_branch_names configured)."""
        config = {
            "branch": {
                "conventional_branch": True,
                "allow_branch_names": ["develop", "staging", "production"],
            }
        }

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

        rule = builder._build_conventional_branch_rule(catalog_entry)
        assert rule is not None
        # Should include both default and custom branch names
        assert "(master)" in rule.regex
        assert "(main)" in rule.regex
        assert "(HEAD)" in rule.regex
        assert "(PR-.+)" in rule.regex
        assert "(develop)" in rule.regex
        assert "(staging)" in rule.regex
        assert "(production)" in rule.regex

    @pytest.mark.benchmark
    def test_rule_builder_allow_branch_names_empty_list(self):
        """Test RuleBuilder with empty allow_branch_names list."""
        config = {"branch": {"conventional_branch": True, "allow_branch_names": []}}

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

        rule = builder._build_conventional_branch_rule(catalog_entry)
        assert rule is not None
        # Should only include default branch names
        assert "(master)" in rule.regex
        assert "(main)" in rule.regex
        assert "(HEAD)" in rule.regex
        assert "(PR-.+)" in rule.regex

    @pytest.mark.benchmark
    def test_ai_agent_and_bot_branch_types_in_default(self):
        """AI agent and bot prefixes are valid by default."""
        import re
        from commit_check import DEFAULT_BRANCH_TYPES

        assert "ai" in DEFAULT_BRANCH_TYPES
        assert "claude" in DEFAULT_BRANCH_TYPES
        assert "codex" in DEFAULT_BRANCH_TYPES
        assert "copilot" in DEFAULT_BRANCH_TYPES
        assert "cursor" in DEFAULT_BRANCH_TYPES
        assert "dependabot" in DEFAULT_BRANCH_TYPES
        assert "renovate" in DEFAULT_BRANCH_TYPES
        assert "build" in DEFAULT_BRANCH_TYPES
        assert "ci" in DEFAULT_BRANCH_TYPES
        assert "docs" in DEFAULT_BRANCH_TYPES
        assert "perf" in DEFAULT_BRANCH_TYPES
        assert "refactor" in DEFAULT_BRANCH_TYPES
        assert "style" in DEFAULT_BRANCH_TYPES
        assert "test" in DEFAULT_BRANCH_TYPES

        config = {"branch": {"conventional_branch": True}}
        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")
        rule = builder._build_conventional_branch_rule(catalog_entry)
        assert rule is not None

        valid_branches = [
            # Conventional branch types matching default commit types
            "build/update-ci-image",
            "ci/github-actions",
            "docs/update-readme",
            "perf/optimize-parser",
            "refactor/simplify-engine",
            "style/format-code",
            "test/add-api-tests",
            # AI agent branches
            "ai/refactor-auth-flow",
            "claude/stoic-hypatia-v65p1f",
            "claude/fix-login-bug",
            "codex/optimize-query",
            "copilot/add-login-page",
            "cursor/fix-header-bug",
            # Bot/automation branches
            "dependabot/go_modules/go-deps-c57c3fe1e0",
            "dependabot/npm_and_yarn/lodash-4.17.21",
            "dependabot/pip/certifi-2022.12.7",
            "renovate/lodash-5.x",
            "renovate/major-lodash-5.x",
        ]
        for branch in valid_branches:
            assert re.match(rule.regex, branch), f"Branch '{branch}' should be valid"

    @pytest.mark.benchmark
    def test_rule_builder_allow_branch_names_with_duplicates(self):
        """Test RuleBuilder with duplicate branch names in allow_branch_names."""
        config = {
            "branch": {
                "conventional_branch": True,
                "allow_branch_names": ["develop", "develop", "staging", "develop"],
            }
        }

        builder = RuleBuilder(config)
        allowed_names = builder._get_allowed_branch_names()
        # Should deduplicate while preserving order
        assert allowed_names == ["develop", "staging"]

    @pytest.mark.benchmark
    def test_message_pattern_takes_precedence(self):
        """When message_pattern is set, it replaces the auto-generated regex."""
        config = {
            "commit": {
                "conventional_commits": True,
                "message_pattern": r"^PROJ-\d+: .+",
            }
        }

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="message", regex="", error=BAD_FORMAT_ERROR, suggest="Use JIRA format"
        )

        rule = builder._build_conventional_commit_rule(catalog_entry)
        assert rule is not None
        assert rule.regex == r"^PROJ-\d+: .+"
        assert rule.error == BAD_FORMAT_ERROR
        assert "required pattern" in rule.suggest

    @pytest.mark.benchmark
    def test_message_pattern_overrides_conventional_commits(self):
        """message_pattern works even when conventional_commits is false."""
        config = {
            "commit": {
                "conventional_commits": False,
                "message_pattern": r"^\[ISSUE-\d+\] .+",
            }
        }

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="message",
            regex="",
            error=BAD_FORMAT_ERROR,
            suggest="Use correct format",
        )

        rule = builder._build_conventional_commit_rule(catalog_entry)
        assert rule is not None
        assert rule.regex == r"^\[ISSUE-\d+\] .+"

    @pytest.mark.benchmark
    def test_message_pattern_empty_falls_back(self):
        """When message_pattern is empty string, fall back to conventional commits."""
        config = {
            "commit": {
                "conventional_commits": True,
                "message_pattern": "",
                "allow_commit_types": ["feat", "fix"],
            }
        }

        builder = RuleBuilder(config)
        catalog_entry = RuleCatalogEntry(
            check="message", regex="", error=BAD_FORMAT_ERROR, suggest="..."
        )

        rule = builder._build_conventional_commit_rule(catalog_entry)
        assert rule is not None
        # Should use auto-generated regex, not empty string
        assert "feat" in rule.regex
        assert "fix" in rule.regex


class TestPushRuleBuilder:
    """Tests for push rule building."""

    @pytest.mark.benchmark
    def test_push_rule_not_built_when_force_push_allowed(self):
        """No rule is built when allow_force_push is True (default)."""
        config = {"push": {"allow_force_push": True}}
        builder = RuleBuilder(config)
        rules = builder.build_all_rules()
        push_rules = [r for r in rules if r.check == "no_force_push"]
        assert len(push_rules) == 0

    @pytest.mark.benchmark
    def test_push_rule_built_when_force_push_disabled(self):
        """A rule is built when allow_force_push is False."""
        config = {"push": {"allow_force_push": False}}
        builder = RuleBuilder(config)
        rules = builder.build_all_rules()
        push_rules = [r for r in rules if r.check == "no_force_push"]
        assert len(push_rules) == 1
        assert push_rules[0].error == "Force push is not allowed"
        assert push_rules[0].suggest is not None

    @pytest.mark.benchmark
    def test_push_rule_not_built_by_default(self):
        """No rule is built with empty config (default: allow force push)."""
        builder = RuleBuilder({})
        rules = builder.build_all_rules()
        push_rules = [r for r in rules if r.check == "no_force_push"]
        assert len(push_rules) == 0

    @pytest.mark.benchmark
    def test_push_rule_unknown_check_returns_none(self):
        """A push rule with an unrecognized check name returns None."""
        builder = RuleBuilder({})
        unknown_entry = RuleCatalogEntry(check="unknown_push_check")
        rule = builder._build_push_rule(unknown_entry)
        assert rule is None


class TestAiAttributionRuleBuilder:
    """Tests for AI attribution rule building."""

    @pytest.mark.benchmark
    def test_ai_attribution_ignore_returns_none(self):
        """ai_attribution='ignore' (default) returns None."""
        config = {"commit": {"ai_attribution": "ignore"}}
        builder = RuleBuilder(config)
        entry = RuleCatalogEntry(check="ai_attribution")
        rule = builder._build_ai_attribution_rule(entry)
        assert rule is None

    @pytest.mark.benchmark
    def test_ai_attribution_forbid_creates_rule(self):
        """ai_attribution='forbid' creates a validation rule."""
        config = {"commit": {"ai_attribution": "forbid"}}
        builder = RuleBuilder(config)
        entry = RuleCatalogEntry(check="ai_attribution")
        rule = builder._build_ai_attribution_rule(entry)
        assert rule is not None
        assert rule.check == "ai_attribution"
        assert rule.value == "forbid"

    @pytest.mark.benchmark
    def test_ai_attribution_require_returns_none(self):
        """ai_attribution='require' returns None (only forbid supported)."""
        config = {"commit": {"ai_attribution": "require"}}
        builder = RuleBuilder(config)
        entry = RuleCatalogEntry(check="ai_attribution")
        rule = builder._build_ai_attribution_rule(entry)
        assert rule is None

    @pytest.mark.benchmark
    def test_build_all_rules_forbid_includes_attribution(self):
        """forbid mode includes ai_attribution and no other AI rules."""
        config = {"commit": {"ai_attribution": "forbid"}}
        builder = RuleBuilder(config)
        rules = builder.build_all_rules()
        ai_rules = [r for r in rules if r.check.startswith("ai_")]
        assert len(ai_rules) == 1
        assert ai_rules[0].check == "ai_attribution"

    @pytest.mark.benchmark
    def test_build_all_rules_no_ai_by_default(self):
        """build_all_rules does not include AI rules by default."""
        builder = RuleBuilder({})
        rules = builder.build_all_rules()
        ai_rules = [r for r in rules if r.check.startswith("ai_")]
        assert len(ai_rules) == 0


class TestLengthRuleMessages:
    """The configured limit reaches both the error and the advice.

    The suggestion used to name no length at all — "Provide a meaningful
    subject (>= configured min)" sat directly under an error that had already
    said "at least 5 characters", so the advice was vaguer than the complaint
    above it.
    """

    @pytest.mark.benchmark
    def test_min_length_names_the_limit(self):
        rules = RuleBuilder({"commit": {"subject_min_length": 12}}).build_all_rules()
        rule = next(r for r in rules if r.check == "subject_min_length")
        assert rule.error == "Subject must be at least 12 characters"
        assert rule.suggest == "Write a subject of at least 12 characters"

    @pytest.mark.benchmark
    def test_max_length_names_the_limit(self):
        rules = RuleBuilder({"commit": {"subject_max_length": 72}}).build_all_rules()
        rule = next(r for r in rules if r.check == "subject_max_length")
        assert rule.error == "Subject must be at most 72 characters"
        assert rule.suggest == "Shorten the subject to 72 characters or fewer"

    @pytest.mark.benchmark
    @pytest.mark.parametrize("check", ["subject_min_length", "subject_max_length"])
    def test_no_placeholder_survives_into_output(self, check):
        """A missed substitution would print a literal brace to the user."""
        rules = RuleBuilder({"commit": {check: 40}}).build_all_rules()
        rule = next(r for r in rules if r.check == check)
        assert "{" not in (rule.error or "")
        assert "{" not in (rule.suggest or "")
        assert "40" in (rule.suggest or "")


class TestTagRules:
    @pytest.mark.benchmark
    def test_tag_rule_built_with_default_regex(self):
        """With no [tag] config the rule carries the SemVer default."""
        from commit_check import DEFAULT_TAG_REGEX

        builder = RuleBuilder({})
        rules = builder.build_all_rules()
        tag_rules = [r for r in rules if r.check == "tag"]
        assert len(tag_rules) == 1
        assert tag_rules[0].regex == DEFAULT_TAG_REGEX
        assert tag_rules[0].rule_id == "CC401"
        assert tag_rules[0].docs_url == "https://commit-check.com/rules/#cc401"

    @pytest.mark.benchmark
    def test_tag_rule_custom_regex(self):
        """A [tag] regex in config replaces the default."""
        builder = RuleBuilder({"tag": {"regex": r"^rel-\d+$"}})
        rules = builder.build_all_rules()
        tag_rule = next(r for r in rules if r.check == "tag")
        assert tag_rule.regex == r"^rel-\d+$"

    @pytest.mark.benchmark
    def test_tag_rule_empty_regex_disables_pattern(self):
        """An empty regex deliberately disables the pattern match."""
        builder = RuleBuilder({"tag": {"regex": ""}})
        rules = builder.build_all_rules()
        tag_rule = next(r for r in rules if r.check == "tag")
        assert tag_rule.regex is None

    @pytest.mark.benchmark
    def test_default_tag_regex_accepts_semver_forms(self):
        """The default accepts v-prefixed and bare SemVer, with suffixes."""
        import re
        from commit_check import DEFAULT_TAG_REGEX

        for good in [
            "v1.2.3",
            "1.2.3",
            "v0.1.0",
            "v1.2.3-rc.1",
            "v1.2.3-0",
            "v1.2.3+build.5",
            "v10.20.30-beta.2+exp.sha.5114f85",
        ]:
            assert re.match(DEFAULT_TAG_REGEX, good), good
        # Leading zeroes in version-core numbers and numeric pre-release
        # identifiers are forbidden by SemVer.
        for bad in [
            "release",
            "v1.2",
            "v1.2.3-",
            "v1.2.3+",
            "va.b.c",
            "v1.2.3 ",
            "v01.2.3",
            "v1.02.3",
            "v1.2.3-01",
        ]:
            assert not re.match(DEFAULT_TAG_REGEX, bad), bad

    @pytest.mark.benchmark
    def test_tag_rule_non_mapping_section_falls_back(self):
        """A malformed non-table [tag] value falls back to defaults."""
        from commit_check import DEFAULT_TAG_REGEX

        builder = RuleBuilder({"tag": "not-a-table"})
        tag_rule = next(r for r in builder.build_all_rules() if r.check == "tag")
        assert tag_rule.regex == DEFAULT_TAG_REGEX

    @pytest.mark.benchmark
    def test_tag_rule_non_string_regex_falls_back(self):
        """A non-string regex value falls back to the default pattern."""
        from commit_check import DEFAULT_TAG_REGEX

        builder = RuleBuilder({"tag": {"regex": 123}})
        tag_rule = next(r for r in builder.build_all_rules() if r.check == "tag")
        assert tag_rule.regex == DEFAULT_TAG_REGEX


class TestFilesRules:
    @pytest.mark.benchmark
    def test_unusable_max_size_is_reported(self, capsys):
        """A rejected value must not disable its rule in silence.

        The "nothing configured" hint only fires when no file rule builds at
        all, so a bad max_size alongside a good pattern list would otherwise
        leave the size cap absent without a word.
        """
        builder = RuleBuilder(
            {"files": {"max_size": "5M", "prohibited_patterns": ["*.pem"]}}
        )
        rules = builder.build_all_rules()
        checks = [rule.check for rule in rules]
        assert "file_size" not in checks
        assert "file_pattern" in checks
        err = capsys.readouterr().err
        assert "max_size" in err
        assert "5M" in err
        assert "CC302" in err

    @pytest.mark.benchmark
    def test_non_table_files_section_is_reported(self, capsys):
        """A scalar [files] must not vanish into an empty section in silence."""
        rules = RuleBuilder({"files": "garbage"}).build_all_rules()
        assert not [
            r for r in rules if r.check in ("file_size", "file_pattern", "path_length")
        ]
        err = capsys.readouterr().err
        assert "must be a table" in err
        assert "garbage" in err

    @pytest.mark.benchmark
    def test_unset_values_are_not_reported(self, capsys):
        """Silence is right for a section that simply does not opt in."""
        RuleBuilder({"files": {"max_size": "", "max_path_length": 0}}).build_all_rules()
        assert capsys.readouterr().err == ""

    @pytest.mark.benchmark
    def test_files_rules_off_by_default(self):
        """No [files] config, no file rules — all three are opt-in."""
        rules = RuleBuilder({}).build_all_rules()
        assert not [
            r for r in rules if r.check in ("file_size", "file_pattern", "path_length")
        ]

    @pytest.mark.benchmark
    def test_files_rules_built_from_config(self):
        builder = RuleBuilder(
            {
                "files": {
                    "max_size": "5MB",
                    "prohibited_patterns": ["*.pem", ".env"],
                    "max_path_length": 200,
                }
            }
        )
        rules = {r.check: r for r in builder.build_all_rules()}

        assert rules["file_size"].rule_id == "CC302"
        assert rules["file_size"].value == 5 * 1024**2
        assert "5 MB" in rules["file_size"].error
        assert rules["file_pattern"].rule_id == "CC303"
        assert rules["file_pattern"].value == ["*.pem", ".env"]
        assert rules["path_length"].rule_id == "CC304"
        assert rules["path_length"].value == 200
        assert "200" in rules["path_length"].error

    @pytest.mark.benchmark
    def test_files_rules_built_independently(self):
        """Each setting stands alone — configuring one enables only that one."""
        rules = RuleBuilder({"files": {"max_size": 1024}}).build_all_rules()
        checks = [
            r.check
            for r in rules
            if r.check in ("file_size", "file_pattern", "path_length")
        ]
        assert checks == ["file_size"]

    @pytest.mark.benchmark
    def test_files_rules_malformed_values_disable(self):
        """Unusable values disable a rule rather than failing every commit."""
        builder = RuleBuilder(
            {
                "files": {
                    "max_size": "not-a-size",
                    "prohibited_patterns": "not-a-list",
                    "max_path_length": True,
                }
            }
        )
        rules = builder.build_all_rules()
        assert not [
            r for r in rules if r.check in ("file_size", "file_pattern", "path_length")
        ]

    @pytest.mark.benchmark
    def test_files_non_mapping_section_disables(self):
        rules = RuleBuilder({"files": "garbage"}).build_all_rules()
        assert not [
            r for r in rules if r.check in ("file_size", "file_pattern", "path_length")
        ]
