"""Tests for the Forensic Engine using existing scenario files."""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from arzule_ingest.forensics import (
    ForensicEngine,
    Finding,
    TraceGraph,
    TraceNode,
    parse_trace_file,
    parse_trace_lines,
)
from arzule_ingest.forensics.detectors import (
    AbandonedHandoffDetector,
    CascadingErrorDetector,
    CircularDelegationDetector,
    FailedHandoffDetector,
    HandoffMismatchDetector,
    MissingOwnershipDetector,
    ToolErrorDetector,
)

# Path to scenario files
SCENARIOS_DIR = Path(__file__).parent.parent / "out" / "scenarios"


class TestTraceParser:
    """Tests for JSONL trace parsing."""

    def test_parse_empty_trace(self) -> None:
        """Empty trace returns empty graph."""
        graph = parse_trace_lines(iter([]))
        assert graph.trace_id == ""
        assert len(graph.nodes) == 0

    def test_parse_single_event(self) -> None:
        """Single event creates node."""
        event = json.dumps({
            "schema_version": "trace_event.v0_1",
            "run_id": "run-1",
            "trace_id": "trace-1",
            "span_id": "span-1",
            "parent_span_id": None,
            "seq": 1,
            "ts": "2025-12-25T00:00:00Z",
            "event_type": "run.start",
            "status": "ok",
            "summary": "run started",
            "agent": None,
            "attrs_compact": {},
            "payload": {},
        })
        
        graph = parse_trace_lines(iter([event]))
        
        assert graph.trace_id == "trace-1"
        assert graph.run_id == "run-1"
        assert len(graph.nodes) == 1
        assert "span-1" in graph.nodes

    def test_parse_handoff_proposed_no_ack(self) -> None:
        """Parse the handoff_proposed_no_ack scenario file."""
        path = SCENARIOS_DIR / "handoff_proposed_no_ack.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        
        assert len(graph.nodes) > 0
        assert len(graph.handoff_edges) == 1
        
        # Should have one abandoned handoff
        handoff_key = list(graph.handoff_edges.keys())[0]
        edge = graph.handoff_edges[handoff_key]
        
        assert edge.is_abandoned
        assert not edge.is_acknowledged
        assert edge.from_agent_role == "Researcher"
        assert edge.to_agent_role == "Writer"


class TestAbandonedHandoffDetector:
    """Tests for AbandonedHandoff detection."""

    def test_detects_abandoned_handoff(self) -> None:
        """Detector finds abandoned handoffs."""
        path = SCENARIOS_DIR / "handoff_proposed_no_ack.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = AbandonedHandoffDetector()
        
        findings = detector.detect(graph)
        
        assert len(findings) == 1
        assert findings[0].failure_type == "AbandonedHandoff"
        assert "Researcher" in findings[0].root_cause
        assert "Writer" in findings[0].root_cause

    def test_no_false_positive_on_complete_handoff(self) -> None:
        """Detector doesn't flag completed handoffs."""
        path = SCENARIOS_DIR / "live" / "simple_delegation.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = AbandonedHandoffDetector()
        
        findings = detector.detect(graph)
        
        # simple_delegation should complete successfully
        abandoned = [f for f in findings if f.failure_type == "AbandonedHandoff"]
        assert len(abandoned) == 0


class TestMissingOwnershipDetector:
    """Tests for MissingOwnership (orphan event) detection."""

    def test_detects_orphan_events(self) -> None:
        """Detector finds orphan end events without start."""
        path = SCENARIOS_DIR / "missing_start_events.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = MissingOwnershipDetector()
        
        findings = detector.detect(graph)
        
        # Should detect multiple orphan events
        assert len(findings) >= 1
        assert all(f.failure_type == "MissingOwnership" for f in findings)

    def test_no_false_negative_on_different_task_same_agent(self) -> None:
        """
        Regression test: task.complete for T2 should NOT be matched to T1's start
        just because they share the same agent_role.
        
        This tests the fix for the _has_matching_start false negative bug.
        """
        # Scenario:
        # - task.start for task-1 by AgentX
        # - task.complete for task-1 by AgentX (matched - OK)
        # - task.complete for task-2 by AgentX (NO task.start for task-2 - ORPHAN)
        events = [
            {
                "span_id": "s1", "parent_span_id": None, "seq": 1,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:00Z",
                "event_type": "run.start", "status": "ok", "summary": "start",
                "agent": None, "attrs_compact": {}, "payload": {},
            },
            {
                "span_id": "s2", "parent_span_id": None, "seq": 2,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:01Z",
                "event_type": "task.start", "status": "ok", "summary": "task1 start",
                "agent": {"id": "a1", "role": "AgentX"},
                "task_id": "task-1",
                "attrs_compact": {}, "payload": {},
            },
            {
                "span_id": "s2", "parent_span_id": None, "seq": 3,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:02Z",
                "event_type": "task.complete", "status": "ok", "summary": "task1 complete",
                "agent": {"id": "a1", "role": "AgentX"},
                "task_id": "task-1",
                "attrs_compact": {}, "payload": {},
            },
            {
                "span_id": "s3", "parent_span_id": None, "seq": 4,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:03Z",
                "event_type": "task.complete", "status": "ok", "summary": "task2 complete - ORPHAN",
                "agent": {"id": "a1", "role": "AgentX"},
                "task_id": "task-2",  # Different task, no start event
                "attrs_compact": {}, "payload": {},
            },
        ]
        
        lines = [json.dumps(e) for e in events]
        graph = parse_trace_lines(iter(lines))
        
        detector = MissingOwnershipDetector()
        findings = detector.detect(graph)
        
        # Should detect task-2's complete as orphan
        assert len(findings) == 1
        assert findings[0].failure_type == "MissingOwnership"
        assert findings[0].evidence.get("task_id") == "task-2"

    def test_span_id_matching_takes_precedence(self) -> None:
        """Events with matching span_id should be correctly paired."""
        events = [
            {
                "span_id": "s1", "parent_span_id": None, "seq": 1,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:00Z",
                "event_type": "run.start", "status": "ok", "summary": "start",
                "agent": None, "attrs_compact": {}, "payload": {},
            },
            {
                "span_id": "tool-span-1", "parent_span_id": None, "seq": 2,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:01Z",
                "event_type": "tool.call.start", "status": "ok", "summary": "tool start",
                "agent": {"id": "a1", "role": "AgentX"},
                "attrs_compact": {"tool_name": "my_tool"}, "payload": {},
            },
            {
                "span_id": "tool-span-1", "parent_span_id": None, "seq": 3,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:02Z",
                "event_type": "tool.call.end", "status": "ok", "summary": "tool end",
                "agent": {"id": "a1", "role": "AgentX"},
                "attrs_compact": {"tool_name": "my_tool"}, "payload": {},
            },
        ]
        
        lines = [json.dumps(e) for e in events]
        graph = parse_trace_lines(iter(lines))
        
        detector = MissingOwnershipDetector()
        findings = detector.detect(graph)
        
        # Should not detect any orphans - span_id matches
        tool_orphans = [f for f in findings if "tool" in f.evidence.get("event_type", "")]
        assert len(tool_orphans) == 0


class TestCircularDelegationDetector:
    """Tests for CircularDelegation detection."""

    def test_detects_no_cycle_in_normal_trace(self) -> None:
        """No false positives on normal delegation."""
        path = SCENARIOS_DIR / "live" / "simple_delegation.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = CircularDelegationDetector()
        
        findings = detector.detect(graph)
        
        circular = [f for f in findings if f.failure_type == "CircularDelegation"]
        assert len(circular) == 0

    def test_detects_cycle_in_graph(self) -> None:
        """Detector finds circular delegations when present."""
        # Create a synthetic trace with circular delegation
        events = [
            {
                "span_id": "s1", "parent_span_id": None, "seq": 1,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:00Z",
                "event_type": "handoff.proposed", "status": "ok", "summary": "A->B",
                "agent": {"id": "a", "role": "AgentA"},
                "attrs_compact": {
                    "handoff_key": "h1",
                    "from_agent_role": "AgentA",
                    "to_coworker": "AgentB",
                },
                "payload": {},
            },
            {
                "span_id": "s2", "parent_span_id": None, "seq": 2,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:01Z",
                "event_type": "handoff.proposed", "status": "ok", "summary": "B->A",
                "agent": {"id": "b", "role": "AgentB"},
                "attrs_compact": {
                    "handoff_key": "h2",
                    "from_agent_role": "AgentB",
                    "to_coworker": "AgentA",
                },
                "payload": {},
            },
        ]
        
        lines = [json.dumps(e) for e in events]
        graph = parse_trace_lines(iter(lines))
        
        detector = CircularDelegationDetector()
        findings = detector.detect(graph)
        
        assert len(findings) == 1
        assert findings[0].failure_type == "CircularDelegation"
        assert "AgentA" in str(findings[0].evidence.get("cycle", []))
        assert "AgentB" in str(findings[0].evidence.get("cycle", []))


class TestForensicEngine:
    """Tests for the ForensicEngine orchestrator."""

    def test_analyze_single_file(self, tmp_path: Path) -> None:
        """Engine can analyze a single file."""
        path = SCENARIOS_DIR / "handoff_proposed_no_ack.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        output = tmp_path / "findings.jsonl"
        
        engine = ForensicEngine(
            output_file=output,
            console_output=False,
        )
        
        findings = engine.analyze_file(path)
        
        assert len(findings) >= 1
        assert output.exists()
        
        # Verify output format
        with output.open() as f:
            for line in f:
                finding = json.loads(line)
                assert "schema_version" in finding
                assert "failure_type" in finding
                assert "break_node" in finding

    def test_analyze_multiple_files(self, tmp_path: Path) -> None:
        """Engine can analyze multiple files."""
        files = list(SCENARIOS_DIR.glob("*.jsonl"))[:3]
        if not files:
            pytest.skip("No scenario files found")
        
        output = tmp_path / "findings.jsonl"
        
        engine = ForensicEngine(
            output_file=output,
            console_output=False,
        )
        
        findings = engine.analyze_files(files)
        
        # Should accumulate findings from all files
        assert engine.all_findings == findings


class TestFindingFormat:
    """Tests for Finding output format."""

    def test_finding_to_dict(self) -> None:
        """Finding serializes correctly."""
        finding = Finding(
            trace_id="trace-1",
            run_id="run-1",
            break_node="span-1",
            failure_type="AbandonedHandoff",
            severity="warning",
            root_cause="Test failure",
            evidence={"key": "value"},
        )
        
        d = finding.to_dict()
        
        assert d["schema_version"] == "finding.v0_1"
        assert d["trace_id"] == "trace-1"
        assert d["failure_type"] == "AbandonedHandoff"
        assert d["severity"] == "warning"
        assert d["evidence"]["key"] == "value"

    def test_finding_json_serializable(self) -> None:
        """Finding can be serialized to JSON."""
        finding = Finding(
            trace_id="trace-1",
            run_id="run-1",
            break_node="span-1",
            failure_type="Test",
            severity="info",
            root_cause="Test",
        )
        
        # Should not raise
        json_str = json.dumps(finding.to_dict())
        parsed = json.loads(json_str)
        
        assert parsed["failure_type"] == "Test"


class TestCascadingErrorDetector:
    """Tests for CascadingError detection."""

    def test_detects_cascading_errors(self) -> None:
        """Detector finds cascading errors from scenario file."""
        path = SCENARIOS_DIR / "cascading_errors.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = CascadingErrorDetector()
        
        findings = detector.detect(graph)
        
        # Should detect the cascade
        assert len(findings) >= 1
        cascade_finding = findings[0]
        assert cascade_finding.failure_type == "CascadingError"
        assert cascade_finding.severity == "error"
        
        # Should identify root cause and downstream
        evidence = cascade_finding.evidence
        assert "root_agent" in evidence
        assert "downstream_count" in evidence
        assert evidence["downstream_count"] >= 1

    def test_identifies_root_cause_agent(self) -> None:
        """Detector correctly identifies the root cause agent."""
        path = SCENARIOS_DIR / "cascading_errors.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = CascadingErrorDetector()
        
        findings = detector.detect(graph)
        
        assert len(findings) >= 1
        # From the scenario file, Primary agent is the root cause
        assert findings[0].evidence.get("root_agent") == "Primary"

    def test_no_cascade_for_single_error(self) -> None:
        """No cascade finding for a single isolated error."""
        events = [
            {
                "span_id": "s1", "parent_span_id": None, "seq": 1,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:00Z",
                "event_type": "run.start", "status": "ok", "summary": "start",
                "agent": None, "attrs_compact": {}, "payload": {},
            },
            {
                "span_id": "s2", "parent_span_id": None, "seq": 2,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:01Z",
                "event_type": "agent.execution.error", "status": "error", 
                "summary": "single error",
                "agent": {"id": "a1", "role": "SingleAgent"},
                "attrs_compact": {"error_type": "SomeError"},
                "payload": {},
            },
        ]
        
        lines = [json.dumps(e) for e in events]
        graph = parse_trace_lines(iter(lines))
        
        detector = CascadingErrorDetector()
        findings = detector.detect(graph)
        
        # Single error should not trigger cascade detection
        assert len(findings) == 0


class TestFailedHandoffDetector:
    """Tests for FailedHandoff detection."""

    def test_detects_failed_handoff(self) -> None:
        """Detector finds handoffs that completed with error."""
        path = SCENARIOS_DIR / "delegation_error_mid_execution.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = FailedHandoffDetector()
        
        findings = detector.detect(graph)
        
        assert len(findings) >= 1
        failed_finding = findings[0]
        assert failed_finding.failure_type == "FailedHandoff"
        
        # Should include handoff details
        evidence = failed_finding.evidence
        assert "from_agent" in evidence
        assert "to_agent" in evidence
        assert evidence.get("was_acknowledged") is True

    def test_detects_mid_execution_failure(self) -> None:
        """Detector identifies when failure occurred mid-execution."""
        path = SCENARIOS_DIR / "delegation_error_mid_execution.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = FailedHandoffDetector()
        
        findings = detector.detect(graph)
        
        assert len(findings) >= 1
        # The scenario shows LLM call started before crash
        evidence = findings[0].evidence
        assert evidence.get("was_mid_execution") is True

    def test_no_false_positive_on_successful_handoff(self) -> None:
        """Detector doesn't flag successful handoffs."""
        path = SCENARIOS_DIR / "live" / "simple_delegation.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = FailedHandoffDetector()
        
        findings = detector.detect(graph)
        
        failed = [f for f in findings if f.failure_type == "FailedHandoff"]
        assert len(failed) == 0


class TestToolErrorDetector:
    """Tests for ToolError detection."""

    def test_detects_tool_errors(self) -> None:
        """Detector finds tool call errors."""
        path = SCENARIOS_DIR / "tool_exception.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = ToolErrorDetector()
        
        findings = detector.detect(graph)
        
        assert len(findings) >= 1
        tool_finding = findings[0]
        assert tool_finding.failure_type == "ToolError"
        
        # Should include tool details
        evidence = tool_finding.evidence
        assert "tool_name" in evidence
        assert "error_type" in evidence

    def test_detects_recovery_pattern(self) -> None:
        """Detector identifies when tool error was recovered."""
        path = SCENARIOS_DIR / "tool_exception.jsonl"
        if not path.exists():
            pytest.skip(f"Scenario file not found: {path}")
        
        graph = parse_trace_file(path)
        detector = ToolErrorDetector()
        
        findings = detector.detect(graph)
        
        assert len(findings) >= 1
        # The scenario shows fallback_api being used after api_request failed
        evidence = findings[0].evidence
        assert evidence.get("recovered") is True
        assert evidence.get("recovery_tool") == "fallback_api"

    def test_tool_error_with_no_recovery(self) -> None:
        """Detector flags tool error without recovery attempt."""
        events = [
            {
                "span_id": "s1", "parent_span_id": None, "seq": 1,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:00Z",
                "event_type": "run.start", "status": "ok", "summary": "start",
                "agent": None, "attrs_compact": {}, "payload": {},
            },
            {
                "span_id": "s2", "parent_span_id": None, "seq": 2,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:01Z",
                "event_type": "tool.call.start", "status": "ok", 
                "summary": "tool start",
                "agent": {"id": "a1", "role": "ToolUser"},
                "attrs_compact": {"tool_name": "broken_tool"},
                "payload": {},
            },
            {
                "span_id": "s2", "parent_span_id": None, "seq": 3,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:02Z",
                "event_type": "tool.call.error", "status": "error", 
                "summary": "tool failed",
                "agent": {"id": "a1", "role": "ToolUser"},
                "attrs_compact": {
                    "tool_name": "broken_tool",
                    "error_type": "ConnectionError",
                    "error_message": "Connection refused",
                },
                "payload": {},
            },
            {
                "span_id": "s3", "parent_span_id": None, "seq": 4,
                "trace_id": "t1", "run_id": "r1", "ts": "2025-01-01T00:00:03Z",
                "event_type": "agent.execution.error", "status": "error", 
                "summary": "agent failed",
                "agent": {"id": "a1", "role": "ToolUser"},
                "attrs_compact": {},
                "payload": {},
            },
        ]
        
        lines = [json.dumps(e) for e in events]
        graph = parse_trace_lines(iter(lines))
        
        detector = ToolErrorDetector()
        findings = detector.detect(graph)
        
        assert len(findings) == 1
        evidence = findings[0].evidence
        assert evidence.get("recovered") is False
        assert evidence.get("tool_name") == "broken_tool"


class TestAllDetectorsIntegration:
    """Integration tests running all detectors."""

    def test_all_detectors_run_without_error(self) -> None:
        """All detectors can run on various scenario files without crashing."""
        scenario_files = list(SCENARIOS_DIR.glob("*.jsonl"))
        if not scenario_files:
            pytest.skip("No scenario files found")
        
        engine = ForensicEngine(console_output=False)
        
        # Should not raise any exceptions
        for path in scenario_files[:10]:  # Test first 10
            try:
                engine.analyze_file(path)
            except Exception as e:
                pytest.fail(f"Detector crashed on {path.name}: {e}")

    def test_new_detectors_registered(self) -> None:
        """New detectors are properly registered."""
        from arzule_ingest.forensics.detectors import get_all_detectors
        
        detectors = get_all_detectors()
        detector_names = {d.name for d in detectors}
        
        # Original 4
        assert "AbandonedHandoff" in detector_names
        assert "MissingOwnership" in detector_names
        assert "CircularDelegation" in detector_names
        assert "HandoffMismatch" in detector_names
        
        # New 3
        assert "CascadingError" in detector_names
        assert "FailedHandoff" in detector_names
        assert "ToolError" in detector_names

