"""Tests for semantic.py — LLM-powered shape extraction."""

import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from doorway_memory.scanner import ScanResult, scan
from doorway_memory.shape import Shape, Dimension

# Check for optional deps
try:
    import anthropic
    HAS_ANTHROPIC = True
except ImportError:
    HAS_ANTHROPIC = False

try:
    import PyPDF2
    HAS_PYPDF2 = True
except ImportError:
    HAS_PYPDF2 = False

try:
    import docx
    HAS_DOCX = True
except ImportError:
    HAS_DOCX = False

from doorway_memory.semantic import (
    _confidence_to_boundary_width,
    _claim_to_shape,
    EXTRACTION_PROMPT,
    BASE_BOUNDARY_WIDTH,
    MAX_BOUNDARY_MULTIPLIER,
    DEFAULT_CONFIDENCE,
    HAS_ANTHROPIC as SEMANTIC_HAS_ANTHROPIC,
)


# --- Helpers ---

def _make_mock_client(claims):
    """Create a mock Anthropic client that returns the given claims."""
    client = MagicMock()
    response_text = json.dumps({"claims": claims})
    block = MagicMock()
    block.text = response_text
    message = MagicMock()
    message.content = [block]
    client.messages.create.return_value = message
    return client


def _sample_claims():
    """Return a list of sample extracted claims."""
    return [
        {
            "claim": "Water boils at 100 degrees Celsius at sea level",
            "structure": "causal",
            "elements": ["water", "temperature", "sea level"],
            "constraints": ["at sea level", "standard pressure"],
            "implication": "forward",
            "confidence": 0.95,
        },
        {
            "claim": "Some researchers suggest dark matter might interact weakly",
            "structure": "relational",
            "elements": ["dark matter", "weak interaction"],
            "constraints": [],
            "implication": "bidirectional",
            "confidence": 0.3,
        },
    ]


# --- confidence_to_boundary_width ---

def test_confidence_high_narrow_boundary():
    width = _confidence_to_boundary_width(1.0)
    assert width == pytest.approx(BASE_BOUNDARY_WIDTH)


def test_confidence_low_wide_boundary():
    width = _confidence_to_boundary_width(0.0)
    assert width == pytest.approx(BASE_BOUNDARY_WIDTH * MAX_BOUNDARY_MULTIPLIER)


def test_confidence_mid_interpolates():
    width = _confidence_to_boundary_width(0.5)
    assert BASE_BOUNDARY_WIDTH < width < BASE_BOUNDARY_WIDTH * MAX_BOUNDARY_MULTIPLIER


def test_confidence_clamps_above_one():
    width = _confidence_to_boundary_width(1.5)
    assert width == pytest.approx(BASE_BOUNDARY_WIDTH)


def test_confidence_clamps_below_zero():
    width = _confidence_to_boundary_width(-0.5)
    assert width == pytest.approx(BASE_BOUNDARY_WIDTH * MAX_BOUNDARY_MULTIPLIER)


# --- claim_to_shape ---

def test_claim_to_shape_basic():
    claim = _sample_claims()[0]
    shape = _claim_to_shape(claim)
    assert isinstance(shape, Shape)
    assert "structure_type" in shape.dimensions
    assert "element_count" in shape.dimensions
    assert "constraint_count" in shape.dimensions
    assert "implication_direction" in shape.dimensions
    assert "complexity_score" in shape.dimensions
    assert shape.confidence == 0.95


def test_claim_to_shape_high_confidence_narrow():
    claim = {
        "claim": "Precise fact",
        "structure": "causal",
        "elements": ["a", "b"],
        "constraints": ["c"],
        "implication": "forward",
        "confidence": 0.99,
    }
    shape = _claim_to_shape(claim)
    struct_dim = shape.dimensions["structure_type"]
    width = struct_dim.max_value - struct_dim.min_value
    assert width < 1.0  # Narrow boundary


def test_claim_to_shape_low_confidence_wide():
    claim = {
        "claim": "Uncertain claim",
        "structure": "causal",
        "elements": ["a", "b"],
        "constraints": ["c"],
        "implication": "forward",
        "confidence": 0.1,
    }
    shape = _claim_to_shape(claim)
    struct_dim = shape.dimensions["structure_type"]
    width = struct_dim.max_value - struct_dim.min_value
    assert width > 1.0  # Wide boundary


def test_claim_to_shape_metadata():
    claim = _sample_claims()[0]
    shape = _claim_to_shape(claim)
    assert shape.metadata["scan_type"] == "semantic"
    assert shape.metadata["claim"] == claim["claim"]
    assert shape.metadata["structure"] == "causal"
    assert shape.metadata["elements"] == claim["elements"]


def test_claim_to_shape_empty_constraints():
    claim = _sample_claims()[1]  # No constraints
    shape = _claim_to_shape(claim)
    assert shape.dimensions["constraint_count"].min_value == 0.0


def test_claim_to_shape_default_confidence():
    claim = {"claim": "test", "structure": "causal", "elements": ["a"]}
    shape = _claim_to_shape(claim)
    assert shape.confidence == DEFAULT_CONFIDENCE


# --- EXTRACTION_PROMPT ---

def test_extraction_prompt_has_placeholder():
    assert "{text}" in EXTRACTION_PROMPT


def test_extraction_prompt_mentions_fields():
    for field_name in ["structure", "elements", "constraints", "implication", "confidence"]:
        assert field_name in EXTRACTION_PROMPT


# --- scan_text (with mock client) ---

def test_scan_text_with_mock():
    from doorway_memory.semantic import scan_text
    claims = _sample_claims()
    client = _make_mock_client(claims)

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        result = scan_text("Water boils at 100C at sea level.", client, name="test")

    assert isinstance(result, ScanResult)
    assert result.source_type == "semantic"
    assert len(result.shapes) == 2
    assert result.shapes[0].confidence == 0.95
    assert result.shapes[1].confidence == 0.3


def test_scan_text_empty_text():
    from doorway_memory.semantic import scan_text
    client = MagicMock()

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        result = scan_text("", client, name="empty")

    assert result.shapes == []
    client.messages.create.assert_not_called()


def test_scan_text_source_name_in_metadata():
    from doorway_memory.semantic import scan_text
    claims = [_sample_claims()[0]]
    client = _make_mock_client(claims)

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        result = scan_text("test text", client, name="my_source")

    assert result.shapes[0].metadata["source"] == "my_source"


def test_scan_text_no_anthropic_raises():
    from doorway_memory.semantic import scan_text

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", False):
        with pytest.raises(ImportError, match="anthropic"):
            scan_text("hello", MagicMock())


def test_scan_text_handles_markdown_fenced_json():
    """LLM sometimes wraps response in markdown code fencing."""
    from doorway_memory.semantic import scan_text
    claims = _sample_claims()[:1]
    fenced = "```json\n" + json.dumps({"claims": claims}) + "\n```"

    client = MagicMock()
    block = MagicMock()
    block.text = fenced
    message = MagicMock()
    message.content = [block]
    client.messages.create.return_value = message

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        result = scan_text("some text", client)

    assert len(result.shapes) == 1


def test_scan_text_handles_bare_list_response():
    """LLM might return a bare list instead of {claims: [...]}."""
    from doorway_memory.semantic import scan_text
    claims = _sample_claims()[:1]

    client = MagicMock()
    block = MagicMock()
    block.text = json.dumps(claims)
    message = MagicMock()
    message.content = [block]
    client.messages.create.return_value = message

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        result = scan_text("some text", client)

    assert len(result.shapes) == 1


# --- scan_document (with mock client) ---

def test_scan_document_txt_file():
    from doorway_memory.semantic import scan_document
    claims = _sample_claims()[:1]
    client = _make_mock_client(claims)

    with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
        f.write("Water boils at 100 degrees Celsius.")
        f.flush()
        tmp_path = f.name

    try:
        with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
            result = scan_document(tmp_path, client, name="test_doc")
        assert len(result.shapes) == 1
        assert result.source_type == "semantic"
    finally:
        os.unlink(tmp_path)


def test_scan_document_md_file():
    from doorway_memory.semantic import scan_document
    claims = _sample_claims()[:1]
    client = _make_mock_client(claims)

    with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as f:
        f.write("# Heading\n\nSome content about physics.")
        f.flush()
        tmp_path = f.name

    try:
        with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
            result = scan_document(tmp_path, client, name="md_doc")
        assert len(result.shapes) == 1
    finally:
        os.unlink(tmp_path)


def test_scan_document_csv_falls_to_dataframe():
    from doorway_memory.semantic import scan_document
    client = MagicMock()

    with tempfile.NamedTemporaryFile(suffix=".csv", mode="w", delete=False) as f:
        f.write("temp,pressure\n20.0,1.0\n25.0,1.1\n30.0,1.2\n35.0,0.9\n")
        f.flush()
        tmp_path = f.name

    try:
        with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
            result = scan_document(tmp_path, client, name="csv_doc")
        assert result.source_type == "dataframe"
        # Client should NOT have been called for CSV
        client.messages.create.assert_not_called()
    finally:
        os.unlink(tmp_path)


def test_scan_document_file_not_found():
    from doorway_memory.semantic import scan_document

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        with pytest.raises(FileNotFoundError):
            scan_document("/nonexistent/path.txt", MagicMock())


def test_scan_document_unsupported_extension():
    from doorway_memory.semantic import scan_document

    with tempfile.NamedTemporaryFile(suffix=".xyz", delete=False) as f:
        f.write(b"some data")
        tmp_path = f.name

    try:
        with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
            with pytest.raises(ValueError, match="Unsupported file type"):
                scan_document(tmp_path, MagicMock())
    finally:
        os.unlink(tmp_path)


def test_scan_document_no_anthropic_raises():
    from doorway_memory.semantic import scan_document

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", False):
        with pytest.raises(ImportError, match="anthropic"):
            scan_document("some.txt", MagicMock())


# --- scanner.scan() routing ---

def test_scan_routes_string_to_scan_text():
    """Non-path string + client → scan_text."""
    claims = _sample_claims()[:1]
    client = _make_mock_client(claims)

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        result = scan("This is a plain text paragraph about physics.", name="auto", client=client)

    assert result.source_type == "semantic"
    assert len(result.shapes) == 1


def test_scan_routes_txt_file_to_scan_document():
    """Existing .txt file + client → scan_document."""
    claims = _sample_claims()[:1]
    client = _make_mock_client(claims)

    with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
        f.write("Some factual content.")
        tmp_path = f.name

    try:
        with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
            result = scan(tmp_path, name="auto", client=client)
        assert result.source_type == "semantic"
    finally:
        os.unlink(tmp_path)


def test_scan_string_without_client_returns_unknown():
    """Non-path string without client → unknown (no semantic fallback)."""
    result = scan("Just some text without a client")
    assert result.source_type == "unknown"


def test_scan_py_file_still_routes_to_codebase():
    """A .py file should still route to codebase scanner, not semantic."""
    with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
        f.write("def foo(x: int, y: float):\n    return x + y\n")
        tmp_path = f.name

    try:
        result = scan(tmp_path, name="code")
        assert result.source_type == "codebase"
    finally:
        os.unlink(tmp_path)


# --- Memory.ingest_text / ingest_document ---

def test_memory_ingest_text():
    from doorway_memory.memory import Memory
    claims = _sample_claims()
    client = _make_mock_client(claims)
    mem = Memory(namespace="test", anchor=False)

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        count = mem.ingest_text("Water boils at 100C.", client)

    assert count == 2
    assert mem.count() == 2


def test_memory_ingest_text_with_trajectory():
    from doorway_memory.memory import Memory
    claims = _sample_claims()[:1]
    client = _make_mock_client(claims)
    mem = Memory(namespace="test", anchor=False)

    with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
        count = mem.ingest_text("Fact.", client, trajectory_id="traj1")

    assert count == 1
    assert "traj1" in mem._trajectories


def test_memory_ingest_document():
    from doorway_memory.memory import Memory
    claims = _sample_claims()[:1]
    client = _make_mock_client(claims)
    mem = Memory(namespace="test", anchor=False)

    with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
        f.write("A factual document.")
        tmp_path = f.name

    try:
        with patch("doorway_memory.semantic.HAS_ANTHROPIC", True):
            count = mem.ingest_document(tmp_path, client)
        assert count == 1
        assert mem.count() == 1
    finally:
        os.unlink(tmp_path)


# --- Tests requiring real optional deps (skipped if missing) ---

@pytest.mark.skipif(not HAS_ANTHROPIC, reason="anthropic not installed")
def test_scan_text_import_available():
    """Verify that scan_text can be imported when anthropic is installed."""
    from doorway_memory.semantic import scan_text
    assert callable(scan_text)


@pytest.mark.skipif(not HAS_PYPDF2, reason="PyPDF2 not installed")
def test_pdf_extraction():
    """Verify PDF text extraction works when PyPDF2 is installed."""
    from doorway_memory.semantic import _extract_text_from_file
    # Create a minimal PDF
    writer = PyPDF2.PdfWriter()
    writer.add_blank_page(width=72, height=72)
    with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
        writer.write(f)
        tmp_path = f.name

    try:
        text = _extract_text_from_file(Path(tmp_path))
        assert isinstance(text, str)
    finally:
        os.unlink(tmp_path)


@pytest.mark.skipif(not HAS_DOCX, reason="python-docx not installed")
def test_docx_extraction():
    """Verify DOCX text extraction works when python-docx is installed."""
    from doorway_memory.semantic import _extract_text_from_file
    doc = docx.Document()
    doc.add_paragraph("Test paragraph")
    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as f:
        doc.save(f.name)
        tmp_path = f.name

    try:
        text = _extract_text_from_file(Path(tmp_path))
        assert "Test paragraph" in text
    finally:
        os.unlink(tmp_path)
