"""Semantic scanning — LLM-powered shape extraction from unstructured content.

Uses an LLM client (Anthropic) to extract structured claims from natural
language text, then mints geometric shapes with confidence-scaled boundaries.

High confidence → narrow shape (precise knowledge).
Low confidence → wide shape (uncertain territory).

Optional deps: anthropic (LLM client), PyPDF2 (PDF), python-docx (DOCX).
"""

import csv
import io
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional

import numpy as np

from .shape import Shape, Dimension
from .scanner import ScanResult, Scanner

logger = logging.getLogger(__name__)

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


# --- Constants ---

# Default model for extraction
DEFAULT_MODEL = "claude-sonnet-4-20250514"

# Max tokens for extraction response
MAX_EXTRACTION_TOKENS = 4096

# Base boundary width for maximum confidence (1.0)
BASE_BOUNDARY_WIDTH = 0.1

# Boundary expansion factor for low confidence
# At confidence 0.0, width = BASE_BOUNDARY_WIDTH * MAX_BOUNDARY_MULTIPLIER
MAX_BOUNDARY_MULTIPLIER = 10.0

# Default confidence when LLM doesn't provide one
DEFAULT_CONFIDENCE = 0.5


EXTRACTION_PROMPT = """\
You are a structured knowledge extractor. Given the following text, extract \
distinct factual claims as structured coordinates.

For each claim, produce a JSON object with these fields:
- "claim": a one-sentence summary of the factual claim
- "structure": one of "causal", "compositional", "relational", "temporal", "conditional"
- "elements": list of key entities/concepts involved (strings)
- "constraints": list of conditions or limitations (strings)
- "implication": one of "forward", "reverse", "bidirectional"
- "confidence": float 0.0-1.0 indicating how confident the claim is based on \
the source text (hedging language like "might", "possibly", "some suggest" → lower; \
definitive statements with evidence → higher)

Return a JSON object with a single key "claims" containing a list of these objects.
Return ONLY valid JSON, no markdown fencing, no explanation.

TEXT:
{text}
"""


def _confidence_to_boundary_width(confidence: float) -> float:
    """Convert confidence to boundary width multiplier.

    High confidence → narrow boundaries (precise).
    Low confidence → wide boundaries (uncertain).
    """
    confidence = max(0.0, min(1.0, confidence))
    # Linear interpolation: conf=1.0 → 1x, conf=0.0 → MAX_BOUNDARY_MULTIPLIER
    multiplier = 1.0 + (1.0 - confidence) * (MAX_BOUNDARY_MULTIPLIER - 1.0)
    return BASE_BOUNDARY_WIDTH * multiplier


def _claim_to_shape(claim: Dict[str, Any]) -> Shape:
    """Convert a single extracted claim to a Shape.

    Maps structure/elements/constraints/implication to the standard
    doorway-memory coordinate system, then scales boundary width by confidence.
    """
    structure_encoding = {
        "causal": 1.0, "compositional": 2.0, "relational": 3.0,
        "temporal": 4.0, "conditional": 5.0,
    }
    implication_encoding = {
        "forward": 1.0, "reverse": -1.0, "bidirectional": 0.0,
    }

    structure_val = structure_encoding.get(claim.get("structure", ""), 0.0)
    element_count = float(len(claim.get("elements", [])))
    constraint_count = float(len(claim.get("constraints", [])))
    implication_val = implication_encoding.get(claim.get("implication", ""), 0.0)
    complexity = element_count * constraint_count if constraint_count > 0 else element_count

    confidence = float(claim.get("confidence", DEFAULT_CONFIDENCE))
    width = _confidence_to_boundary_width(confidence)

    dims = {
        "structure_type": Dimension(
            name="structure_type",
            min_value=structure_val - width,
            max_value=structure_val + width,
        ),
        "element_count": Dimension(
            name="element_count",
            min_value=max(0.0, element_count - width * element_count) if element_count > 0 else 0.0,
            max_value=element_count + width * max(element_count, 1.0),
        ),
        "constraint_count": Dimension(
            name="constraint_count",
            min_value=max(0.0, constraint_count - width * constraint_count) if constraint_count > 0 else 0.0,
            max_value=constraint_count + width * max(constraint_count, 1.0),
        ),
        "implication_direction": Dimension(
            name="implication_direction",
            min_value=implication_val - width,
            max_value=implication_val + width,
        ),
        "complexity_score": Dimension(
            name="complexity_score",
            min_value=max(0.0, complexity - width * max(complexity, 1.0)),
            max_value=complexity + width * max(complexity, 1.0),
        ),
    }

    metadata = {
        "scan_type": "semantic",
        "claim": claim.get("claim", ""),
        "structure": claim.get("structure", ""),
        "elements": claim.get("elements", []),
        "constraints": claim.get("constraints", []),
        "implication": claim.get("implication", ""),
    }

    return Shape(
        dimensions=dims,
        metadata=metadata,
        confidence=confidence,
    )


def _extract_claims(text: str, client: Any, model: str = DEFAULT_MODEL) -> List[Dict]:
    """Call the LLM to extract structured claims from text."""
    prompt = EXTRACTION_PROMPT.format(text=text)

    message = client.messages.create(
        model=model,
        max_tokens=MAX_EXTRACTION_TOKENS,
        messages=[{"role": "user", "content": prompt}],
    )

    # Extract text from response
    response_text = ""
    for block in message.content:
        if hasattr(block, "text"):
            response_text += block.text

    # Parse JSON response
    response_text = response_text.strip()
    # Strip markdown fencing if present
    if response_text.startswith("```"):
        lines = response_text.split("\n")
        # Remove first and last fence lines
        lines = [l for l in lines if not l.strip().startswith("```")]
        response_text = "\n".join(lines)

    parsed = json.loads(response_text)

    if isinstance(parsed, dict) and "claims" in parsed:
        return parsed["claims"]
    if isinstance(parsed, list):
        return parsed
    return []


def scan_text(
    text: str,
    client: Any,
    name: str = "text",
    model: str = DEFAULT_MODEL,
) -> ScanResult:
    """Scan unstructured text using an LLM to extract claims as shapes.

    Args:
        text: The text content to scan.
        client: An Anthropic client instance.
        name: Source identifier for the scan result.
        model: Model ID to use for extraction.

    Returns:
        ScanResult with shapes minted from extracted claims.

    Raises:
        ImportError: If anthropic is not installed.
    """
    if not HAS_ANTHROPIC:
        raise ImportError(
            "scan_text requires the anthropic package. "
            "Install with: pip install doorway-memory[semantic]"
        )

    if not text or not text.strip():
        return ScanResult(source=name, source_type="semantic")

    claims = _extract_claims(text, client, model=model)
    shapes = [_claim_to_shape(c) for c in claims]

    # Attach source name to each shape's metadata
    for shape in shapes:
        if shape.metadata:
            shape.metadata["source"] = name

    return ScanResult(source=name, source_type="semantic", shapes=shapes)


def _extract_text_from_file(path: Path) -> str:
    """Extract text content from a file based on extension."""
    suffix = path.suffix.lower()

    if suffix in (".txt", ".md"):
        return path.read_text(encoding="utf-8")

    if suffix == ".pdf":
        if not HAS_PYPDF2:
            raise ImportError(
                "PDF scanning requires PyPDF2. "
                "Install with: pip install doorway-memory[semantic]"
            )
        reader = PyPDF2.PdfReader(str(path))
        pages = []
        for page in reader.pages:
            text = page.extract_text()
            if text:
                pages.append(text)
        return "\n\n".join(pages)

    if suffix == ".docx":
        if not HAS_DOCX:
            raise ImportError(
                "DOCX scanning requires python-docx. "
                "Install with: pip install doorway-memory[semantic]"
            )
        doc = docx.Document(str(path))
        paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
        return "\n\n".join(paragraphs)

    raise ValueError(f"Unsupported file type: {suffix}")


def scan_document(
    path: str,
    client: Any,
    name: str = "document",
    model: str = DEFAULT_MODEL,
) -> ScanResult:
    """Scan a document file by extracting text and running LLM extraction.

    Supported file types:
        .txt, .md → read directly
        .pdf → extract via PyPDF2
        .docx → extract via python-docx
        .csv → falls through to scan_dataframe

    Args:
        path: File path to the document.
        client: An Anthropic client instance.
        name: Source identifier for the scan result.
        model: Model ID to use for extraction.

    Returns:
        ScanResult with shapes extracted from the document.

    Raises:
        ImportError: If required optional dependency is missing.
        FileNotFoundError: If the file doesn't exist.
        ValueError: If the file type is unsupported.
    """
    if not HAS_ANTHROPIC:
        raise ImportError(
            "scan_document requires the anthropic package. "
            "Install with: pip install doorway-memory[semantic]"
        )

    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"File not found: {path}")

    # CSV falls through to dataframe scanner
    if p.suffix.lower() == ".csv":
        scanner = Scanner()
        # Read CSV as dict-of-lists
        with open(p, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            columns: Dict[str, list] = {}
            for row in reader:
                for key, value in row.items():
                    columns.setdefault(key, []).append(value)
        # Try to convert to numeric
        numeric_columns: Dict[str, list] = {}
        for key, values in columns.items():
            try:
                numeric_columns[key] = [float(v) for v in values if v]
            except (ValueError, TypeError):
                continue
        if numeric_columns:
            return scanner.scan_dataframe(numeric_columns, name)
        return ScanResult(source=name, source_type="dataframe")

    text = _extract_text_from_file(p)
    return scan_text(text, client, name=name, model=model)
