"""Tests for hook runner detectors: surrender, NPC, blame, frustration, loop, sensitive file, unauthorized write.

These test the detection functions in scripts/insaits_hook_runner.py by importing
the module via importlib (since it's a script, not a package module).
"""

from __future__ import annotations

import importlib.util
import json  # noqa: F401
import os  # noqa: F401
import sys  # noqa: F401
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Import hook_runner via importlib (script, not package)
# ---------------------------------------------------------------------------
_HOOK_RUNNER_PATH = str(
    Path(__file__).resolve().parent.parent / "scripts" / "insaits_hook_runner.py"
)

spec = importlib.util.spec_from_file_location("hook_runner", _HOOK_RUNNER_PATH)
assert spec is not None and spec.loader is not None, (
    f"Could not find hook runner at {_HOOK_RUNNER_PATH}"
)
hook = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hook)

# Convenience aliases
_check_surrender_phrases = hook._check_surrender_phrases
_check_user_frustration = hook._check_user_frustration
_check_tool_loop = hook._check_tool_loop
_check_sensitive_file_access = hook._check_sensitive_file_access
_check_unauthorized_write = hook._check_unauthorized_write
write_audit_entry = hook.write_audit_entry

SESSION = "test-session-001"
TOOL = "Bash"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _collect_audit_calls(mock_write: MagicMock) -> List[Dict[str, Any]]:
    """Return list of anomaly dicts from all write_audit_entry calls."""
    results: List[Dict[str, Any]] = []
    for call in mock_write.call_args_list:
        # positional: tool_name, model, anomalies
        anomalies = call[0][2] if len(call[0]) >= 3 else call[1].get("anomalies", [])
        results.extend(anomalies)
    return results


# ===================================================================
# 1. Surrender detection (_check_surrender_phrases)
# ===================================================================
class TestSurrenderDetection:
    """Test that surrender phrases in agent responses trigger audit entries."""

    @pytest.mark.parametrize("phrase", [
        "i cannot complete this task because of permissions",
        "i give up trying to fix the build, it keeps failing repeatedly",
        "i'm unable to resolve this error after multiple attempts at debugging",
        "i am unable to find a solution to this complex problem right now",
        "this is impossible to solve without access to the production database",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_surrender_phrases_detected(self, mock_write: MagicMock, phrase: str) -> None:
        # Pad to exceed 50-char minimum
        text = phrase + " " * max(0, 51 - len(phrase))
        _check_surrender_phrases(text, TOOL, SESSION)
        assert mock_write.called, f"Expected surrender detection for: {phrase}"
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "agent_surrender" for a in anomalies)

    @patch.object(hook, "write_audit_entry")
    def test_normal_text_no_trigger(self, mock_write: MagicMock) -> None:
        text = (
            "I have successfully completed the requested task. The build passes "
            "all tests and the output matches the expected format perfectly."
        )
        _check_surrender_phrases(text, TOOL, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_short_text_ignored(self, mock_write: MagicMock) -> None:
        """Text shorter than 50 chars should be skipped entirely."""
        text = "i cannot do this"
        assert len(text) < 50
        _check_surrender_phrases(text, TOOL, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_exactly_50_chars_processed(self, mock_write: MagicMock) -> None:
        """Boundary: text of exactly 50 chars IS processed (guard is < 50, not <=)."""
        text = "i cannot do this task" + "x" * (50 - len("i cannot do this task"))
        assert len(text) == 50
        _check_surrender_phrases(text, TOOL, SESSION)
        assert mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_49_chars_ignored(self, mock_write: MagicMock) -> None:
        """Boundary: text of 49 chars should be skipped (< 50)."""
        text = "i cannot do this task" + "x" * (49 - len("i cannot do this task"))
        assert len(text) == 49
        _check_surrender_phrases(text, TOOL, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_51_chars_processes(self, mock_write: MagicMock) -> None:
        """Boundary: text of 51 chars should be processed."""
        text = "i cannot do this task" + "x" * (51 - len("i cannot do this task"))
        assert len(text) == 51
        _check_surrender_phrases(text, TOOL, SESSION)
        assert mock_write.called


# ===================================================================
# 2. NPC detection (inside _check_surrender_phrases)
# ===================================================================
class TestNPCDetection:
    """NPC behavior requires 2+ deflection phrases."""

    @patch.object(hook, "write_audit_entry")
    def test_two_npc_phrases_trigger(self, mock_write: MagicMock) -> None:
        text = (
            "Well, you could try restarting the server. Also, one option would be "
            "to clear the cache and rebuild the project from scratch entirely."
        )
        _check_surrender_phrases(text, TOOL, SESSION)
        assert mock_write.called
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "npc_behavior" for a in anomalies)

    @patch.object(hook, "write_audit_entry")
    def test_three_npc_phrases_trigger(self, mock_write: MagicMock) -> None:
        text = (
            "You could try X. One option would be Y. You might want to also "
            "consider doing Z to make sure it all works properly together."
        )
        _check_surrender_phrases(text, TOOL, SESSION)
        assert mock_write.called
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "npc_behavior" for a in anomalies)

    @patch.object(hook, "write_audit_entry")
    def test_one_npc_phrase_no_trigger(self, mock_write: MagicMock) -> None:
        text = (
            "You could try restarting the server. I've already checked the logs "
            "and everything else seems to be in order for the deployment."
        )
        _check_surrender_phrases(text, TOOL, SESSION)
        assert not mock_write.called


# ===================================================================
# 3. Blame detection (inside _check_surrender_phrases)
# ===================================================================
class TestBlameDetection:
    """Blame-shifting phrases trigger audit entries."""

    @pytest.mark.parametrize("phrase", [
        "this seems like a bug in the framework that prevents correct operation",
        "unfortunately the api is returning unexpected results for this endpoint",
        "this is outside my control and requires administrator intervention soon",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_blame_phrases_detected(self, mock_write: MagicMock, phrase: str) -> None:
        text = phrase + " " * max(0, 51 - len(phrase))
        _check_surrender_phrases(text, TOOL, SESSION)
        assert mock_write.called
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "blame_shift" for a in anomalies)

    @patch.object(hook, "write_audit_entry")
    def test_normal_text_no_blame(self, mock_write: MagicMock) -> None:
        text = (
            "The function works correctly after applying the patch. All tests "
            "pass and the output matches the expected specification fully."
        )
        _check_surrender_phrases(text, TOOL, SESSION)
        assert not mock_write.called


# ===================================================================
# 4. User frustration detection (_check_user_frustration)
# ===================================================================
class TestUserFrustrationDetection:
    """Frustration phrases in tool input trigger audit entries."""

    @pytest.mark.parametrize("phrase", [
        "still broken after all your changes",
        "same error keeps showing up every time",
        "why isn't this working at all",
        "nothing works, tried everything already",
        "wrong again, that's not what i asked for",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_frustration_phrases_detected(self, mock_write: MagicMock, phrase: str) -> None:
        # Frustration check looks in prompt/command/content/description fields
        tool_input = {"prompt": phrase}
        _check_user_frustration(tool_input, TOOL, SESSION)
        assert mock_write.called
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "user_frustration" for a in anomalies)

    @patch.object(hook, "write_audit_entry")
    def test_normal_prompt_no_trigger(self, mock_write: MagicMock) -> None:
        tool_input = {"prompt": "Please refactor the authentication module to use JWT tokens"}
        _check_user_frustration(tool_input, TOOL, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_short_input_ignored(self, mock_write: MagicMock) -> None:
        """Input text < 20 chars is skipped."""
        tool_input = {"prompt": "still broken"}
        # The leading space from concatenation gives " still broken" = 14 chars
        _check_user_frustration(tool_input, TOOL, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_frustration_in_command_field(self, mock_write: MagicMock) -> None:
        tool_input = {"command": "same error keeps happening every single time I run this"}
        _check_user_frustration(tool_input, TOOL, SESSION)
        assert mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_frustration_in_description_field(self, mock_write: MagicMock) -> None:
        tool_input = {"description": "still not working after all the changes you made"}
        _check_user_frustration(tool_input, TOOL, SESSION)
        assert mock_write.called


# ===================================================================
# 5. Loop detection (_check_tool_loop)
# ===================================================================
class TestLoopDetection:
    """Loop detection fires when same tool+params appear LOOP_THRESHOLD (3) times."""

    @patch.object(hook, "write_audit_entry")
    def test_three_identical_calls_trigger(self, mock_write: MagicMock, tmp_path: Path) -> None:
        history_path = str(tmp_path / "tool_history.json")
        with patch.object(hook, "TOOL_HISTORY_PATH", history_path):
            tool_input = {"command": "git status"}
            # Call 3 times with identical input
            _check_tool_loop("Bash", tool_input, SESSION)
            _check_tool_loop("Bash", tool_input, SESSION)
            _check_tool_loop("Bash", tool_input, SESSION)

            assert mock_write.called
            anomalies = _collect_audit_calls(mock_write)
            assert any(a["type"] == "repetition_loop" for a in anomalies)

    @patch.object(hook, "write_audit_entry")
    def test_two_identical_calls_no_trigger(self, mock_write: MagicMock, tmp_path: Path) -> None:
        history_path = str(tmp_path / "tool_history.json")
        with patch.object(hook, "TOOL_HISTORY_PATH", history_path):
            tool_input = {"command": "git status"}
            _check_tool_loop("Bash", tool_input, SESSION)
            _check_tool_loop("Bash", tool_input, SESSION)

            assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_different_calls_no_trigger(self, mock_write: MagicMock, tmp_path: Path) -> None:
        history_path = str(tmp_path / "tool_history.json")
        with patch.object(hook, "TOOL_HISTORY_PATH", history_path):
            _check_tool_loop("Bash", {"command": "git status"}, SESSION)
            _check_tool_loop("Bash", {"command": "git diff"}, SESSION)
            _check_tool_loop("Bash", {"command": "git log"}, SESSION)

            assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_interleaved_calls_no_trigger(self, mock_write: MagicMock, tmp_path: Path) -> None:
        """A-B-A-B-A should NOT trigger because consecutive count never reaches 3."""
        history_path = str(tmp_path / "tool_history.json")
        with patch.object(hook, "TOOL_HISTORY_PATH", history_path):
            a = {"command": "git status"}
            b = {"command": "git diff"}
            _check_tool_loop("Bash", a, SESSION)
            _check_tool_loop("Bash", b, SESSION)
            _check_tool_loop("Bash", a, SESSION)
            _check_tool_loop("Bash", b, SESSION)
            _check_tool_loop("Bash", a, SESSION)

            assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_fourth_call_no_double_alert(self, mock_write: MagicMock, tmp_path: Path) -> None:
        """Alert fires only at exactly LOOP_THRESHOLD, not on subsequent calls."""
        history_path = str(tmp_path / "tool_history.json")
        with patch.object(hook, "TOOL_HISTORY_PATH", history_path):
            tool_input = {"command": "git status"}
            for _ in range(4):
                _check_tool_loop("Bash", tool_input, SESSION)

            # Should have been called exactly once (at the 3rd call)
            assert mock_write.call_count == 1


# ===================================================================
# 6. Sensitive file detection (_check_sensitive_file_access)
# ===================================================================
class TestSensitiveFileDetection:
    """Sensitive file patterns trigger audit on Read/Glob/Grep."""

    @pytest.mark.parametrize("path", [
        ".env",
        ".env.local",
        ".env.production",
        "config/credentials.json",
        "keys/id_rsa",
        "certs/server.pem",
        "deploy/master.key",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_sensitive_files_detected(self, mock_write: MagicMock, path: str) -> None:
        tool_input = {"file_path": path}
        _check_sensitive_file_access("Read", tool_input, SESSION)
        assert mock_write.called, f"Expected sensitive detection for: {path}"
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "sensitive_file_access" for a in anomalies)

    @pytest.mark.parametrize("path", [
        "src/main.py",
        "tests/test_app.py",
        "README.md",
        "package.json",
        "config/settings.py",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_normal_files_no_trigger(self, mock_write: MagicMock, path: str) -> None:
        tool_input = {"file_path": path}
        _check_sensitive_file_access("Read", tool_input, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_only_read_glob_grep_checked(self, mock_write: MagicMock) -> None:
        """Non-Read/Glob/Grep tools should be ignored."""
        tool_input = {"file_path": ".env"}
        _check_sensitive_file_access("Bash", tool_input, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_glob_pattern_checked(self, mock_write: MagicMock) -> None:
        tool_input = {"pattern": "**/.env*"}
        _check_sensitive_file_access("Glob", tool_input, SESSION)
        assert mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_grep_path_checked(self, mock_write: MagicMock) -> None:
        tool_input = {"path": "secrets.json"}
        _check_sensitive_file_access("Grep", tool_input, SESSION)
        assert mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_windows_backslash_normalized(self, mock_write: MagicMock) -> None:
        """Windows paths with backslashes should still match."""
        tool_input = {"file_path": "C:\\Users\\admin\\.env.local"}
        _check_sensitive_file_access("Read", tool_input, SESSION)
        assert mock_write.called


# ===================================================================
# 7. Unauthorized write detection (_check_unauthorized_write)
# ===================================================================
class TestUnauthorizedWriteDetection:
    """Bash commands that write files should trigger audit."""

    @pytest.mark.parametrize("command", [
        "echo foo > output.txt",
        "echo bar >> logfile.log",
        "cat input.txt > output.txt",
        "tee /tmp/myfile.txt",
        "sed -i 's/old/new/g' config.ini",
        "dd if=/dev/zero of=/tmp/test bs=1M count=1",
        "mv old_name.txt new_name.txt",
        "cp source.txt dest.txt",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_write_commands_detected(self, mock_write: MagicMock, command: str) -> None:
        tool_input = {"command": command}
        _check_unauthorized_write("Bash", tool_input, SESSION)
        assert mock_write.called, f"Expected unauthorized write detection for: {command}"
        anomalies = _collect_audit_calls(mock_write)
        assert any(a["type"] == "unauthorized_write" for a in anomalies)

    @pytest.mark.parametrize("command", [
        "git status",
        "ls -la",
        "python -m pytest tests/",
        "pip install requests",
        "cat README.md",
        "grep -r TODO src/",
        "python -m pytest tests/ --tb=auto -q 2>&1 | tail -30",
        "git log --oneline 2>&1",
        "npm install > /dev/null 2>&1",
        "ruff check . --fix",
        "curl -s https://example.com",
    ])
    @patch.object(hook, "write_audit_entry")
    def test_read_only_commands_no_trigger(self, mock_write: MagicMock, command: str) -> None:
        tool_input = {"command": command}
        _check_unauthorized_write("Bash", tool_input, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_only_bash_tool_checked(self, mock_write: MagicMock) -> None:
        """Non-Bash tools should be ignored entirely."""
        tool_input = {"command": "echo foo > bar.txt"}
        _check_unauthorized_write("Read", tool_input, SESSION)
        assert not mock_write.called

    @patch.object(hook, "write_audit_entry")
    def test_empty_command_no_trigger(self, mock_write: MagicMock) -> None:
        tool_input = {"command": ""}
        _check_unauthorized_write("Bash", tool_input, SESSION)
        assert not mock_write.called


# ---------------------------------------------------------------------------
# PARALLEL SUBAGENT ATTRIBUTION TESTS
# ---------------------------------------------------------------------------
_start_active_agent = hook._start_active_agent
_end_active_agent = hook._end_active_agent
_get_active_agent = hook._get_active_agent
_read_active_agents = hook._read_active_agents
AGENTS_PATH = hook.ACTIVE_AGENTS_PATH


class TestParallelSubagentAttribution:
    """Tests for multi-agent concurrent tracking."""

    def setup_method(self) -> None:
        """Clean up state file before each test."""
        try:
            os.remove(AGENTS_PATH)
        except FileNotFoundError:
            pass

    def teardown_method(self) -> None:
        """Clean up state file after each test."""
        try:
            os.remove(AGENTS_PATH)
        except FileNotFoundError:
            pass

    def test_single_agent_start_and_get(self) -> None:
        _start_active_agent("toolu_A", "Explore", "search for files", "sess1")
        agent = _get_active_agent()
        assert agent is not None
        assert agent["tool_use_id"] == "toolu_A"
        assert agent["subagent_type"] == "Explore"

    def test_single_agent_end_removes(self) -> None:
        _start_active_agent("toolu_A", "Explore", "search", "sess1")
        _end_active_agent("toolu_A")
        agent = _get_active_agent()
        assert agent is None

    def test_two_parallel_agents_both_tracked(self) -> None:
        _start_active_agent("toolu_A", "Explore", "search", "sess1")
        _start_active_agent("toolu_B", "Plan", "plan impl", "sess1")
        agents = _read_active_agents()
        assert len(agents) == 2
        assert "toolu_A" in agents
        assert "toolu_B" in agents

    def test_get_returns_most_recent(self) -> None:
        """With parallel agents, _get_active_agent returns the latest started."""
        _start_active_agent("toolu_A", "Explore", "first", "sess1")
        import time
        time.sleep(0.05)
        _start_active_agent("toolu_B", "Plan", "second", "sess1")
        agent = _get_active_agent()
        assert agent is not None
        assert agent["tool_use_id"] == "toolu_B"
        assert agent["subagent_type"] == "Plan"

    def test_end_specific_agent_keeps_others(self) -> None:
        _start_active_agent("toolu_A", "Explore", "first", "sess1")
        _start_active_agent("toolu_B", "Plan", "second", "sess1")
        _end_active_agent("toolu_A")
        agents = _read_active_agents()
        assert len(agents) == 1
        assert "toolu_B" in agents

    def test_end_all_agents_removes_file(self) -> None:
        _start_active_agent("toolu_A", "Explore", "first", "sess1")
        _end_active_agent("toolu_A")
        assert not os.path.exists(AGENTS_PATH)

    def test_three_parallel_agents_correct_attribution(self) -> None:
        """Simulate 3 parallel subagents — end middle one, verify others remain."""
        _start_active_agent("toolu_A", "Explore", "search code", "sess1")
        _start_active_agent("toolu_B", "Plan", "plan implementation", "sess1")
        _start_active_agent("toolu_C", "Explore", "find tests", "sess1")
        agents = _read_active_agents()
        assert len(agents) == 3

        _end_active_agent("toolu_B")
        agents = _read_active_agents()
        assert len(agents) == 2
        assert "toolu_A" in agents
        assert "toolu_C" in agents
        assert "toolu_B" not in agents

    def test_end_without_id_removes_latest(self) -> None:
        """Backwards compat: _end_active_agent('') removes most recent."""
        _start_active_agent("toolu_A", "Explore", "first", "sess1")
        import time
        time.sleep(0.05)
        _start_active_agent("toolu_B", "Plan", "second", "sess1")
        _end_active_agent("")
        agents = _read_active_agents()
        assert len(agents) == 1
        assert "toolu_A" in agents

    def test_expired_agents_cleaned_up(self) -> None:
        """Agents older than max_age are auto-expired on read."""
        _start_active_agent("toolu_OLD", "Explore", "stale", "sess1")
        # Manually backdate the timestamp
        agents = _read_active_agents()
        agents["toolu_OLD"]["start_ts"] = 0  # epoch = very old
        hook._write_active_agents(agents)

        _start_active_agent("toolu_NEW", "Plan", "fresh", "sess1")
        agent = _get_active_agent()
        assert agent is not None
        assert agent["tool_use_id"] == "toolu_NEW"
        # Old agent should be cleaned up
        agents = _read_active_agents()
        assert "toolu_OLD" not in agents
