"""Tests for the plugin redaction-pattern registry.

Covers ``agent.redact.register_redaction_patterns`` (validation,
dedupe, additive semantics, matcher rebuild), the
``PluginContext.register_redaction_patterns`` wiring, and a synthetic
plugin written at test time for the ``register()`` end-to-end path.

All tests call ``redact_sensitive_text(..., force=True)`` so results
don't depend on the HERMES_REDACT_SECRETS environment of the test run,
and reset the plugin registry around each test so module-global state
never leaks between tests.
"""

import importlib.util

import pytest

import agent.redact as redact_mod
from agent.redact import (
    _reset_plugin_redaction_patterns,
    redact_sensitive_text,
    register_redaction_patterns,
)


NVAPI_KEY = "nvapi-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdEFGH"
NVAPI_PATTERN = r"nvapi-[A-Za-z0-9_-]{20,}"


@pytest.fixture(autouse=True)
def _clean_registry():
    _reset_plugin_redaction_patterns()
    yield
    _reset_plugin_redaction_patterns()


# ── Baseline ────────────────────────────────────────────────────────────


def test_unregistered_format_passes_through():
    # Documents the gap the registry closes: an nvapi- key is not a
    # built-in prefix, so without a plugin it survives redaction.
    out = redact_sensitive_text(f"connect failed: {NVAPI_KEY}", force=True)
    assert NVAPI_KEY in out


# ── Core registry semantics ─────────────────────────────────────────────


def test_registered_pattern_masks_token():
    assert register_redaction_patterns([NVAPI_PATTERN], source="test") == 1
    out = redact_sensitive_text(f"connect failed: {NVAPI_KEY}", force=True)
    assert NVAPI_KEY not in out
    # Head/tail mask preserved for debuggability (same rule as built-ins).
    assert "nvapi-" in out and "..." in out


def test_prescreen_tuple_rebuilt_not_bypassed():
    # The cheap pre-screen gate (_has_known_prefix_substring) consults
    # _PREFIX_SUBSTRINGS. Registration after module load must REBUILD that
    # tuple so plugin patterns flow through the same fast path as built-ins
    # — never around it.
    assert "nvapi-" not in redact_mod._PREFIX_SUBSTRINGS
    assert not redact_mod._has_known_prefix_substring(f"x {NVAPI_KEY}")
    register_redaction_patterns([NVAPI_PATTERN], source="test")
    assert "nvapi-" in redact_mod._PREFIX_SUBSTRINGS
    assert redact_mod._has_known_prefix_substring(f"x {NVAPI_KEY}")


def test_patterns_attributed_per_source():
    # Patterns are stored keyed by registration source — the seam the
    # #64229 lifecycle/ledger path needs to drop one plugin's patterns on
    # unload. No public removal API exists; additive-only stands.
    register_redaction_patterns([NVAPI_PATTERN], source="plugin:alpha")
    register_redaction_patterns([r"zk-[A-Za-z0-9]{24,}"], source="plugin:beta")
    assert redact_mod._PLUGIN_PREFIX_PATTERNS["plugin:alpha"] == [NVAPI_PATTERN]
    assert redact_mod._PLUGIN_PREFIX_PATTERNS["plugin:beta"] == [r"zk-[A-Za-z0-9]{24,}"]


def test_builtins_unaffected_by_registration():
    register_redaction_patterns([NVAPI_PATTERN], source="test")
    sk = "sk-proj-AbCdEf1234567890GhIjKl"
    out = redact_sensitive_text(f"key={sk}", force=True)
    assert sk not in out


def test_invalid_regex_rejected():
    assert register_redaction_patterns([r"nvapi-[unclosed"], source="test") == 0
    out = redact_sensitive_text(f"x {NVAPI_KEY}", force=True)
    assert NVAPI_KEY in out  # nothing registered


def test_pattern_without_literal_prefix_rejected():
    # No literal anchor -> would defeat the pre-screen gate and could
    # match everything. Must be rejected.
    assert register_redaction_patterns([r".*secret.*"], source="test") == 0
    assert register_redaction_patterns([r"[A-Za-z0-9]{30,}"], source="test") == 0
    # One literal char is still too short.
    assert register_redaction_patterns([r"x[A-Za-z0-9]{30,}"], source="test") == 0


def test_duplicate_and_builtin_patterns_deduped():
    assert register_redaction_patterns([NVAPI_PATTERN], source="test") == 1
    assert register_redaction_patterns([NVAPI_PATTERN], source="test") == 0
    # A pattern already shipped in core is skipped too.
    builtin = redact_mod._PREFIX_PATTERNS[0]
    assert register_redaction_patterns([builtin], source="test") == 0
    # Same pattern twice in one call counts once.
    _reset_plugin_redaction_patterns()
    assert register_redaction_patterns([NVAPI_PATTERN, NVAPI_PATTERN], source="test") == 1


def test_non_string_and_empty_entries_skipped():
    assert register_redaction_patterns([None, "", "   ", 42], source="test") == 0
    assert register_redaction_patterns(None, source="test") == 0


def test_file_read_sentinel_uses_plugin_prefix_label():
    register_redaction_patterns([NVAPI_PATTERN], source="test")
    out = redact_sensitive_text(
        f"api_base_key: {NVAPI_KEY}", force=True, file_read=True,
    )
    assert NVAPI_KEY not in out
    # Non-reusable sentinel carries the vendor label, no secret bytes.
    assert "«redacted:nvapi-…»" in out


def test_reset_restores_baseline():
    register_redaction_patterns([NVAPI_PATTERN], source="test")
    _reset_plugin_redaction_patterns()
    out = redact_sensitive_text(f"x {NVAPI_KEY}", force=True)
    assert NVAPI_KEY in out
    # Built-ins still intact after reset.
    sk = "sk-proj-AbCdEf1234567890GhIjKl"
    assert sk not in redact_sensitive_text(sk, force=True)


# ── PluginContext wiring ────────────────────────────────────────────────


def test_plugin_context_method_registers():
    import hermes_cli.plugins as plugins_mod

    manager = plugins_mod.PluginManager()
    manifest = plugins_mod.PluginManifest(name="test-redactor")
    ctx = plugins_mod.PluginContext(manifest, manager)

    assert ctx.register_redaction_patterns([NVAPI_PATTERN]) == 1
    out = redact_sensitive_text(f"boom {NVAPI_KEY}", force=True)
    assert NVAPI_KEY not in out


def test_plugin_context_method_never_raises(monkeypatch):
    import hermes_cli.plugins as plugins_mod

    def _boom(patterns, source=""):
        raise RuntimeError("registry exploded")

    monkeypatch.setattr("agent.redact.register_redaction_patterns", _boom)

    manager = plugins_mod.PluginManager()
    manifest = plugins_mod.PluginManifest(name="test-redactor")
    ctx = plugins_mod.PluginContext(manifest, manager)
    assert ctx.register_redaction_patterns([NVAPI_PATTERN]) == 0


# ── Top-level alternation guard ─────────────────────────────────────────


def test_top_level_alternation_rejected():
    # 'ab|.*' compiles and has the accepted 'ab' literal prefix, but the
    # '.*' branch is unprefixed — accepting it would redact everything.
    assert register_redaction_patterns([r"ab|.*"], source="test") == 0
    assert register_redaction_patterns([r"ab|cd"], source="test") == 0
    clean = "nothing here resembles a credential"
    assert redact_sensitive_text(clean, force=True) == clean


def test_grouped_alternation_and_literal_pipe_accepted():
    # Alternation inside a group after the prefix keeps the guarantee.
    assert register_redaction_patterns(
        [r"zq(?:tok|key)-[A-Za-z0-9]{20,}"], source="test"
    ) == 1
    # Escaped pipes and character-class pipes are literals, not branches.
    assert register_redaction_patterns([r"xy\|[A-Za-z0-9]{20,}"], source="test") == 1
    assert register_redaction_patterns([r"wv[|][A-Za-z0-9]{20,}"], source="test") == 1


def test_nested_unbounded_quantifiers_rejected():
    # (a+)+ backtracks catastrophically; registered patterns run on every
    # log line and tool output, so ReDoS shapes are rejected up front.
    assert register_redaction_patterns([r"ab(a+)+"], source="test") == 0
    assert register_redaction_patterns([r"ab(?:x*)*"], source="test") == 0
    assert register_redaction_patterns([r"ab(x{2,})+"], source="test") == 0
    # Nesting through an intermediate group is still nesting.
    assert register_redaction_patterns([r"ab((x+)y)*"], source="test") == 0
    clean = "nothing here resembles a credential"
    assert redact_sensitive_text(clean, force=True) == clean


def test_bounded_and_sibling_quantifiers_accepted():
    # A single unbounded quantifier is fine, as are siblings and a
    # bounded quantifier inside an unbounded group.
    assert register_redaction_patterns([r"zr(?:tok)?-[A-Za-z0-9]{20,}"], source="test") == 1
    assert register_redaction_patterns([r"qm-[a-z]+[0-9]+"], source="test") == 1
    assert register_redaction_patterns([r"pv(?:x{1,5}y)+"], source="test") == 1
    # Quantifier chars inside a character class are literals, not repeats.
    assert register_redaction_patterns([r"tw[+*][A-Za-z0-9]{20,}"], source="test") == 1


# ── Plugin register() end-to-end (synthetic, written at test time) ──────


_SYNTHETIC_PLUGIN = f'''
NVAPI_PATTERN = r"{NVAPI_PATTERN}"


def register(ctx):
    ctx.register_redaction_patterns([NVAPI_PATTERN])
'''


def _load_synthetic_plugin(tmp_path):
    plugin_init = tmp_path / "synthetic_redactor.py"
    plugin_init.write_text(_SYNTHETIC_PLUGIN, encoding="utf-8")
    spec = importlib.util.spec_from_file_location("synthetic_redactor", plugin_init)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def test_plugin_register_end_to_end(tmp_path):
    import hermes_cli.plugins as plugins_mod

    demo = _load_synthetic_plugin(tmp_path)
    manager = plugins_mod.PluginManager()
    manifest = plugins_mod.PluginManifest(name="synthetic-redactor")
    demo.register(plugins_mod.PluginContext(manifest, manager))

    out = redact_sensitive_text(
        f"NIM request failed: 401 for key {NVAPI_KEY}", force=True,
    )
    assert NVAPI_KEY not in out
    assert "nvapi-" in out  # label survives for debuggability


def test_registered_pattern_no_prose_false_positive(tmp_path):
    demo = _load_synthetic_plugin(tmp_path)
    register_redaction_patterns([demo.NVAPI_PATTERN], source="test")
    prose = "the nvapi-endpoint docs describe rate limits"
    assert redact_sensitive_text(prose, force=True) == prose
