"""Tests for MCP Security Audit — Watch Mode (mcpaudit.watcher).

Tests the file watcher polling logic, change detection, file collection,
and the watch mode integration with the scanner.
"""

from __future__ import annotations

import time
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from mcpaudit.scanner import MCPSecurityScanner
from mcpaudit.watcher import FileWatcher, _print_header, _print_status


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_py_file(tmp_path: Path, name: str, content: str = "x = 1\n") -> Path:
    """Create a Python file in tmp_path."""
    f = tmp_path / name
    f.write_text(content, encoding="utf-8")
    return f


def _make_ts_file(tmp_path: Path, name: str, content: str = "const x = 1;\n") -> Path:
    """Create a TypeScript file in tmp_path."""
    f = tmp_path / name
    f.write_text(content, encoding="utf-8")
    return f


# ===========================================================================
# FileWatcher — File Collection
# ===========================================================================

class TestFileCollection:
    """Tests for _collect_files()."""

    def test_collect_single_py_file(self, tmp_path: Path):
        f = _make_py_file(tmp_path, "server.py")
        watcher = FileWatcher(f, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 1
        assert files[0] == f

    def test_collect_directory_py_files(self, tmp_path: Path):
        _make_py_file(tmp_path, "a.py")
        _make_py_file(tmp_path, "b.py")
        (tmp_path / "readme.txt").write_text("not python")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 2

    def test_collect_ts_files(self, tmp_path: Path):
        _make_ts_file(tmp_path, "server.ts")
        _make_ts_file(tmp_path, "utils.js")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 2

    def test_collect_nested_directories(self, tmp_path: Path):
        _make_py_file(tmp_path, "root.py")
        sub = tmp_path / "sub"
        sub.mkdir()
        _make_py_file(sub, "nested.py")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 2

    def test_skip_node_modules(self, tmp_path: Path):
        _make_py_file(tmp_path, "app.py")
        nm = tmp_path / "node_modules"
        nm.mkdir()
        _make_ts_file(nm, "lib.js")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 1

    def test_skip_pycache(self, tmp_path: Path):
        _make_py_file(tmp_path, "app.py")
        cache = tmp_path / "__pycache__"
        cache.mkdir()
        _make_py_file(cache, "cached.py")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 1

    def test_skip_dist_directory(self, tmp_path: Path):
        _make_ts_file(tmp_path, "app.ts")
        dist = tmp_path / "dist"
        dist.mkdir()
        _make_ts_file(dist, "bundle.js")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 1

    def test_collect_empty_directory(self, tmp_path: Path):
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 0

    def test_non_source_file_ignored(self, tmp_path: Path):
        f = tmp_path / "readme.txt"
        f.write_text("hello")
        watcher = FileWatcher(f, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 0

    def test_collect_mixed_extensions(self, tmp_path: Path):
        _make_py_file(tmp_path, "a.py")
        _make_ts_file(tmp_path, "b.ts")
        _make_ts_file(tmp_path, "c.tsx")
        (tmp_path / "d.json").write_text("{}")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        files = watcher._collect_files()
        assert len(files) == 3


# ===========================================================================
# FileWatcher — Change Detection
# ===========================================================================

class TestChangeDetection:
    """Tests for mtime-based change detection."""

    def test_detect_new_file(self, tmp_path: Path):
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        watcher._mtimes = {}
        f = _make_py_file(tmp_path, "new.py")
        new_mtimes = watcher._snapshot_mtimes([f])
        changed = watcher._detect_changes(new_mtimes)
        assert len(changed) == 1

    def test_detect_modified_file(self, tmp_path: Path):
        f = _make_py_file(tmp_path, "mod.py", "x = 1\n")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        initial = watcher._snapshot_mtimes([f])
        watcher._mtimes = initial

        # Modify the file with a different mtime
        time.sleep(0.05)
        f.write_text("x = 2\n")
        new_mtimes = watcher._snapshot_mtimes([f])
        changed = watcher._detect_changes(new_mtimes)
        assert str(f) in changed

    def test_no_change_detected(self, tmp_path: Path):
        f = _make_py_file(tmp_path, "stable.py")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        mtimes = watcher._snapshot_mtimes([f])
        watcher._mtimes = mtimes
        changed = watcher._detect_changes(mtimes)
        assert len(changed) == 0

    def test_snapshot_handles_missing_file(self, tmp_path: Path):
        missing = tmp_path / "gone.py"
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        mtimes = watcher._snapshot_mtimes([missing])
        assert len(mtimes) == 0

    def test_detect_multiple_changes(self, tmp_path: Path):
        f1 = _make_py_file(tmp_path, "a.py")
        f2 = _make_py_file(tmp_path, "b.py")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        watcher._mtimes = {}
        new_mtimes = watcher._snapshot_mtimes([f1, f2])
        changed = watcher._detect_changes(new_mtimes)
        assert len(changed) == 2


# ===========================================================================
# FileWatcher — Configuration
# ===========================================================================

class TestWatcherConfig:
    """Tests for watcher configuration."""

    def test_default_interval(self, tmp_path: Path):
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        assert watcher.interval == 2.0

    def test_custom_interval(self, tmp_path: Path):
        watcher = FileWatcher(tmp_path, MCPSecurityScanner(), interval=5.0)
        assert watcher.interval == 5.0

    def test_minimum_interval_enforced(self, tmp_path: Path):
        watcher = FileWatcher(tmp_path, MCPSecurityScanner(), interval=0.1)
        assert watcher.interval == 0.5

    def test_custom_callback(self, tmp_path: Path):
        callback = MagicMock()
        watcher = FileWatcher(tmp_path, MCPSecurityScanner(), on_change=callback)
        assert watcher.on_change is callback

    def test_stop_sets_running_false(self, tmp_path: Path):
        watcher = FileWatcher(tmp_path, MCPSecurityScanner())
        watcher._running = True
        watcher.stop()
        assert watcher._running is False


# ===========================================================================
# FileWatcher — Run Loop
# ===========================================================================

class TestWatcherRun:
    """Tests for the run loop (short-lived for testing)."""

    def test_run_and_stop(self, tmp_path: Path):
        _make_py_file(tmp_path, "test.py")
        watcher = FileWatcher(tmp_path, MCPSecurityScanner(), interval=0.5)

        def stop_soon():
            time.sleep(0.3)
            watcher.stop()

        t = threading.Thread(target=stop_soon)
        t.start()
        watcher.run()
        t.join(timeout=3.0)
        assert not watcher._running

    def test_run_calls_on_change_initially(self, tmp_path: Path):
        _make_py_file(tmp_path, "test.py", 'import subprocess\nsubprocess.run("ls", shell=True)\n')
        callback = MagicMock()
        watcher = FileWatcher(
            tmp_path, MCPSecurityScanner(), interval=0.5, on_change=callback,
        )

        def stop_soon():
            time.sleep(0.3)
            watcher.stop()

        t = threading.Thread(target=stop_soon)
        t.start()
        watcher.run()
        t.join(timeout=3.0)
        assert callback.called

    def test_run_detects_file_change(self, tmp_path: Path):
        f = _make_py_file(tmp_path, "server.py", "x = 1\n")
        change_count = {"n": 0}

        def count_changes(results):
            change_count["n"] += 1

        watcher = FileWatcher(
            tmp_path, MCPSecurityScanner(), interval=0.5, on_change=count_changes,
        )

        def modify_and_stop():
            time.sleep(0.3)
            f.write_text("y = 2\n")
            time.sleep(1.0)
            watcher.stop()

        t = threading.Thread(target=modify_and_stop)
        t.start()
        watcher.run()
        t.join(timeout=5.0)
        # At least initial scan + change scan
        assert change_count["n"] >= 1

    def test_run_single_file_target(self, tmp_path: Path):
        f = _make_py_file(tmp_path, "single.py")
        watcher = FileWatcher(f, MCPSecurityScanner(), interval=0.5)

        def stop_soon():
            time.sleep(0.3)
            watcher.stop()

        t = threading.Thread(target=stop_soon)
        t.start()
        watcher.run()
        t.join(timeout=3.0)
        assert not watcher._running


# ===========================================================================
# Output Helpers
# ===========================================================================

class TestWatcherOutput:
    """Tests for output formatting helpers."""

    def test_print_header(self, tmp_path: Path, capsys):
        _print_header(tmp_path, 2.0)
        captured = capsys.readouterr()
        assert "Watch Mode" in captured.out
        assert "2.0s" in captured.out

    def test_print_status(self, capsys):
        _print_status("test message")
        captured = capsys.readouterr()
        assert "test message" in captured.out
        assert "[" in captured.out  # timestamp brackets

    def test_default_on_change_no_findings(self, tmp_path: Path, capsys):
        from mcpaudit.watcher import _default_on_change
        from mcpaudit.models import ScanResult
        results = [ScanResult(file="clean.py", lines_scanned=10)]
        _default_on_change(results)
        captured = capsys.readouterr()
        assert "No findings" in captured.out

    def test_default_on_change_with_findings(self, tmp_path: Path, capsys):
        from mcpaudit.watcher import _default_on_change
        from mcpaudit.models import Finding, ScanResult, Severity
        results = [ScanResult(file="bad.py", findings=[
            Finding(
                rule_id="CMD-001", severity=Severity.CRITICAL,
                title="Shell Injection", description="Bad",
                file="bad.py", line=1,
            ),
        ])]
        _default_on_change(results)
        captured = capsys.readouterr()
        assert "bad.py" in captured.out


# ===========================================================================
# CLI Integration
# ===========================================================================

class TestWatchCLI:
    """Tests for --watch CLI flag."""

    def test_watch_flag_registered(self):
        from mcpaudit.__main__ import build_parser
        parser = build_parser()
        args = parser.parse_args(["test.py", "--watch"])
        assert args.watch is True

    def test_watch_interval_flag(self):
        from mcpaudit.__main__ import build_parser
        parser = build_parser()
        args = parser.parse_args(["test.py", "--watch", "--watch-interval", "5.0"])
        assert args.watch_interval == 5.0

    def test_watch_interval_default(self):
        from mcpaudit.__main__ import build_parser
        parser = build_parser()
        args = parser.parse_args(["test.py"])
        assert args.watch_interval == 2.0
