"""
Tests for insa_its.hooks.claude_code_hook
==========================================
Validates extraction, formatting, exit codes, and error handling
for the Claude Code PreToolUse / PostToolUse hook.
"""

from __future__ import annotations

import json
import subprocess
import sys
import os
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch

import pytest

# Import the functions under test
from insa_its.hooks.claude_code_hook import (
    extract_text_from_tool_result,
    format_feedback,
    main,
    MIN_CONTENT_LENGTH,
    MAX_SCAN_LENGTH,
    DEFAULT_MODEL,
    BLOCKING_SEVERITIES,
)


# ---------------------------------------------------------------------------
# extract_text_from_tool_result
# ---------------------------------------------------------------------------

class TestExtractText:
    """Tests for extract_text_from_tool_result."""

    def test_write_tool_extracts_content(self) -> None:
        data: Dict[str, Any] = {
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/hello.py",
                "content": "print('hello world')",
            },
        }
        text, ctx = extract_text_from_tool_result(data)
        assert text == "print('hello world')"
        assert "hello.py" in ctx

    def test_edit_tool_extracts_new_string(self) -> None:
        data: Dict[str, Any] = {
            "tool_name": "Edit",
            "tool_input": {
                "file_path": "/tmp/foo.py",
                "new_string": "x = 42",
            },
        }
        text, ctx = extract_text_from_tool_result(data)
        assert text == "x = 42"
        assert "foo.py" in ctx

    def test_multiedit_tool(self) -> None:
        data: Dict[str, Any] = {
            "tool_name": "MultiEdit",
            "tool_input": {
                "file_path": "/src/app.ts",
                "content": "const a = 1;\nconst b = 2;",
            },
        }
        text, ctx = extract_text_from_tool_result(data)
        assert "const a = 1" in text

    def test_bash_pretooluse_extracts_command(self) -> None:
        """PreToolUse: no tool_response, should extract command."""
        data: Dict[str, Any] = {
            "tool_name": "Bash",
            "tool_input": {"command": "npm run build"},
        }
        text, ctx = extract_text_from_tool_result(data)
        assert text == "npm run build"
        assert "Bash:" in ctx

    def test_bash_posttooluse_prefers_output(self) -> None:
        """PostToolUse: tool_response present, should prefer output."""
        data: Dict[str, Any] = {
            "tool_name": "Bash",
            "tool_input": {"command": "echo hello"},
            "tool_response": {"output": "hello"},
        }
        text, ctx = extract_text_from_tool_result(data)
        assert text == "hello"

    def test_bash_posttooluse_string_output(self) -> None:
        data: Dict[str, Any] = {
            "tool_name": "Bash",
            "tool_input": {"command": "ls"},
            "tool_response": "file1.py\nfile2.py",
        }
        text, ctx = extract_text_from_tool_result(data)
        assert "file1.py" in text

    def test_generic_content_string(self) -> None:
        data: Dict[str, Any] = {
            "content": "This is a generic response text.",
            "task": "summarize",
        }
        text, ctx = extract_text_from_tool_result(data)
        assert text == "This is a generic response text."
        assert ctx == "summarize"

    def test_generic_content_list(self) -> None:
        data: Dict[str, Any] = {
            "content": [
                {"type": "text", "text": "Line one"},
                {"type": "image", "url": "..."},
                {"type": "text", "text": "Line two"},
            ],
            "task": "multi-modal",
        }
        text, ctx = extract_text_from_tool_result(data)
        assert "Line one" in text
        assert "Line two" in text

    def test_empty_data_returns_empty(self) -> None:
        text, ctx = extract_text_from_tool_result({})
        assert text == ""
        assert ctx == ""

    def test_unknown_tool_returns_empty(self) -> None:
        data: Dict[str, Any] = {
            "tool_name": "SomeUnknownTool",
            "tool_input": {"data": "stuff"},
        }
        text, ctx = extract_text_from_tool_result(data)
        assert text == ""


# ---------------------------------------------------------------------------
# format_feedback
# ---------------------------------------------------------------------------

class TestFormatFeedback:
    """Tests for format_feedback."""

    def test_single_issue(self) -> None:
        issues: List[Dict[str, str]] = [
            {
                "type": "blank_response",
                "severity": "CRITICAL",
                "description": "The response was empty.",
                "intervention": "Regenerate with more context.",
            }
        ]
        result: str = format_feedback(issues)
        assert "BLANK RESPONSE" in result
        assert "CRITICAL" in result
        assert "The response was empty" in result
        assert "Regenerate" in result

    def test_multiple_issues(self) -> None:
        issues: List[Dict[str, str]] = [
            {
                "type": "incomplete_code",
                "severity": "HIGH",
                "description": "Code truncated at line 50.",
                "intervention": "Complete the function.",
            },
            {
                "type": "context_collapse",
                "severity": "MEDIUM",
                "description": "Lost project context.",
                "intervention": "Re-read the file.",
            },
        ]
        result: str = format_feedback(issues)
        assert "1." in result
        assert "2." in result
        assert "INCOMPLETE CODE" in result
        assert "CONTEXT COLLAPSE" in result

    def test_truncation_of_long_description(self) -> None:
        issues: List[Dict[str, str]] = [
            {
                "type": "test",
                "severity": "LOW",
                "description": "A" * 200,
                "intervention": "B" * 200,
            },
        ]
        result: str = format_feedback(issues)
        # Description should be truncated to 100, intervention to 120
        lines: List[str] = result.split("\n")
        desc_line: str = [l for l in lines if "What happened:" in l][0]
        fix_line: str = [l for l in lines if "Fix required:" in l][0]
        assert len(desc_line) < 130  # "   What happened: " + 100 chars
        assert len(fix_line) < 150   # "   Fix required:  " + 120 chars

    def test_footer_present(self) -> None:
        issues: List[Dict[str, str]] = [
            {"type": "t", "severity": "LOW", "description": "d", "intervention": "f"},
        ]
        result: str = format_feedback(issues)
        assert "Address ALL issues" in result
        assert "insaits_check" in result


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

class TestConstants:
    """Verify named constants exist and are reasonable."""

    def test_min_content_length(self) -> None:
        assert MIN_CONTENT_LENGTH == 10

    def test_max_scan_length(self) -> None:
        assert MAX_SCAN_LENGTH == 4000

    def test_default_model(self) -> None:
        assert DEFAULT_MODEL == "claude-opus"

    def test_blocking_severities(self) -> None:
        assert "CRITICAL" in BLOCKING_SEVERITIES
        assert "HIGH" in BLOCKING_SEVERITIES
        assert isinstance(BLOCKING_SEVERITIES, frozenset)


# ---------------------------------------------------------------------------
# main() -- integration-style tests with mocked stdin/SDK
# ---------------------------------------------------------------------------

class TestMain:
    """Tests for the main() entry point."""

    def test_empty_stdin_exits_0(self) -> None:
        """Empty stdin should exit cleanly."""
        with patch("sys.stdin") as mock_stdin:
            mock_stdin.read.return_value = ""
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0

    def test_short_content_exits_0(self) -> None:
        """Content shorter than MIN_CONTENT_LENGTH should exit cleanly."""
        payload: str = json.dumps({
            "tool_name": "Bash",
            "tool_input": {"command": "ls"},
        })
        with patch("sys.stdin") as mock_stdin:
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0

    def test_sdk_not_available_exits_0(self) -> None:
        """When SDK is not available, should log warning and exit 0."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "x = 1\ny = 2\nz = 3\nprint(x + y + z)",
            },
        })
        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", False):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0

    def test_clean_result_exits_0(self) -> None:
        """When AIMonitor.inspect returns clean, should exit 0."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "def hello():\n    return 'Hello, World!'\n",
            },
        })
        mock_result = MagicMock()
        mock_result.clean = True
        mock_result.anomalies = []

        mock_monitor = MagicMock()
        mock_monitor.inspect.return_value = mock_result

        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", return_value=mock_monitor):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0
            mock_monitor.inspect.assert_called_once()

    def test_critical_anomaly_exits_2(self) -> None:
        """When a CRITICAL/HIGH anomaly is found, should exit 2 (block)."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "def broken():\n    # this function is incomplete\n    pass\n    # more lines to exceed threshold",
            },
        })

        mock_anomaly = MagicMock()
        mock_anomaly.anomaly_type.value = "blank_response"
        mock_anomaly.severity.value = "CRITICAL"
        mock_anomaly.description = "Response was blank."
        mock_anomaly.intervention = "Retry with context."

        mock_result = MagicMock()
        mock_result.clean = False
        mock_result.anomalies = [mock_anomaly]

        mock_monitor = MagicMock()
        mock_monitor.inspect.return_value = mock_result

        with patch("sys.stdin") as mock_stdin, \
             patch("sys.stdout") as mock_stdout, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", return_value=mock_monitor):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 2
            # Feedback should be written to stdout
            mock_stdout.write.assert_called()

    def test_medium_anomaly_exits_0(self) -> None:
        """Non-critical anomalies should warn (stderr) but exit 0."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "def something():\n    x = 1\n    return x * 2\n    # padding to exceed min length",
            },
        })

        mock_anomaly = MagicMock()
        mock_anomaly.anomaly_type.value = "semantic_drift"
        mock_anomaly.severity.value = "MEDIUM"
        mock_anomaly.description = "Slight drift detected."
        mock_anomaly.intervention = "Review context."

        mock_result = MagicMock()
        mock_result.clean = False
        mock_result.anomalies = [mock_anomaly]

        mock_monitor = MagicMock()
        mock_monitor.inspect.return_value = mock_result

        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", return_value=mock_monitor):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0

    def test_sdk_exception_exits_0_fail_open(self) -> None:
        """If SDK raises an exception, should fail-open (exit 0) by default."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "x = 1\ny = 2\nz = 3\nprint(x + y + z)\n# enough content",
            },
        })

        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", side_effect=RuntimeError("SDK broken")):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0

    def test_sdk_exception_exits_2_fail_closed(self) -> None:
        """With INSAITS_FAIL_MODE=closed, SDK exception should block (exit 2)."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "x = 1\ny = 2\nz = 3\nprint(x + y + z)\n# enough content",
            },
        })

        with patch("sys.stdin") as mock_stdin, \
             patch("sys.stdout") as mock_stdout, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", side_effect=RuntimeError("SDK broken")), \
             patch.dict(os.environ, {"INSAITS_FAIL_MODE": "closed"}):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 2
            # Should include exception type name in output
            written: str = mock_stdout.write.call_args[0][0]
            assert "RuntimeError" in written

    def test_invalid_json_falls_back_to_content(self) -> None:
        """Non-JSON stdin should be wrapped as content and inspected."""
        raw_text: str = "This is not JSON but is long enough to pass minimum check easily"

        mock_result = MagicMock()
        mock_result.clean = True
        mock_result.anomalies = []

        mock_monitor = MagicMock()
        mock_monitor.inspect.return_value = mock_result

        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", return_value=mock_monitor):
            mock_stdin.read.return_value = raw_text
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0
            # Should have been called with the raw text as content
            call_args = mock_monitor.inspect.call_args
            assert raw_text[:MAX_SCAN_LENGTH] in call_args[0][0]

    def test_max_scan_length_truncation(self) -> None:
        """Content longer than MAX_SCAN_LENGTH should be truncated."""
        long_content: str = "x" * 10000
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/big.py",
                "content": long_content,
            },
        })

        mock_result = MagicMock()
        mock_result.clean = True
        mock_result.anomalies = []

        mock_monitor = MagicMock()
        mock_monitor.inspect.return_value = mock_result

        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", return_value=mock_monitor):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit) as exc_info:
                main()
            assert exc_info.value.code == 0
            call_args = mock_monitor.inspect.call_args
            # First arg should be truncated to MAX_SCAN_LENGTH
            assert len(call_args[0][0]) == MAX_SCAN_LENGTH

    def test_model_env_var_used(self) -> None:
        """INSAITS_MODEL env var should be passed to AIMonitor."""
        payload: str = json.dumps({
            "tool_name": "Write",
            "tool_input": {
                "file_path": "/tmp/test.py",
                "content": "enough content here to pass the minimum length check OK",
            },
        })

        mock_result = MagicMock()
        mock_result.clean = True
        mock_result.anomalies = []

        mock_monitor_instance = MagicMock()
        mock_monitor_instance.inspect.return_value = mock_result

        mock_monitor_cls = MagicMock(return_value=mock_monitor_instance)

        with patch("sys.stdin") as mock_stdin, \
             patch("insa_its.hooks.claude_code_hook.AVAILABLE", True), \
             patch("insa_its.hooks.claude_code_hook.AIMonitor", mock_monitor_cls), \
             patch.dict(os.environ, {"INSAITS_MODEL": "gpt-4o"}):
            mock_stdin.read.return_value = payload
            with pytest.raises(SystemExit):
                main()
            mock_monitor_cls.assert_called_once_with(model_id="gpt-4o")


# ---------------------------------------------------------------------------
# Docstring / setup instruction checks
# ---------------------------------------------------------------------------

class TestDocstrings:
    """Verify docstrings reference PreToolUse and python3."""

    def test_module_docstring_mentions_pretooluse(self) -> None:
        import insa_its.hooks.claude_code_hook as mod
        assert "PreToolUse" in (mod.__doc__ or "")

    def test_module_docstring_uses_python3(self) -> None:
        import insa_its.hooks.claude_code_hook as mod
        doc: str = mod.__doc__ or ""
        # Should use python3, not bare python
        assert "python3 -m" in doc

    def test_init_docstring_mentions_pretooluse(self) -> None:
        import insa_its.hooks as hooks_pkg
        assert "PreToolUse" in (hooks_pkg.__doc__ or "")

    def test_main_docstring_no_bare_python(self) -> None:
        # Cannot import __main__ directly as it calls main() on import.
        # Read the file source instead.
        import insa_its.hooks as hooks_pkg
        hooks_dir: str = os.path.dirname(hooks_pkg.__file__)
        main_path: str = os.path.join(hooks_dir, "__main__.py")
        with open(main_path, "r", encoding="utf-8") as f:
            source: str = f.read()
        assert "python3" in source
