"""Red Queen adversarial citation verifier.

Cross-references resolved citations against independent data sources to detect
fabricated references and field-level inaccuracies.  Motivated by
`Walters & Wilder (2023) <https://doi.org/10.1038/s41598-023-41032-5>`_ which
found that 30–55 % of LLM-generated citations are fabricated and 24–43 % of
non-fabricated citations contain substantive errors in author names, titles,
dates, journal titles, volume/issue/page numbers, and publisher names.

When ``strands-agents`` is installed and ``use_strands=True``, verification is
delegated to :class:`StrandsVerifier`.  On any Strands error the verifier falls
back to deterministic field-similarity comparison.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import structlog

if TYPE_CHECKING:
    from alice_cli.cite.models import CitationRecord, IdentifierType
    from alice_cli.cite.sources import DataSourceProtocol
    from alice_cli.cite.strands_verifier import StrandsVerifier

logger = structlog.get_logger(__name__)

# ── Confidence thresholds ─────────────────────────────────────────────────────

_VERIFICATION_THRESHOLD = 0.6  # overall confidence >= this → "verified"

# Field weights for compute_confidence.  Numeric fields are weighted more
# heavily per Walters & Wilder (2023) findings that year, volume, issue, and
# page numbers are the most error-prone in LLM-generated citations.
_FIELD_WEIGHTS: dict[str, float] = {
    "title": 0.15,
    "authors": 0.10,
    "year": 0.15,
    "doi": 0.10,
    "volume": 0.10,
    "number": 0.10,  # issue
    "pages": 0.10,
    "journal": 0.10,
    "publisher": 0.10,
}

# Threshold below which a text field is considered discrepant for auto-correct
_TEXT_DISCREPANCY_THRESHOLD = 0.85

# Length threshold: strings longer than this use token-based (Jaccard) similarity
_TOKEN_LENGTH_THRESHOLD = 40


# ── Field similarity ──────────────────────────────────────────────────────────


def _levenshtein_distance(a: str, b: str) -> int:
    """Compute the Levenshtein edit distance between two strings."""
    if len(a) < len(b):
        return _levenshtein_distance(b, a)
    if not b:
        return len(a)

    prev_row = list(range(len(b) + 1))
    for i, ca in enumerate(a):
        curr_row = [i + 1]
        for j, cb in enumerate(b):
            cost = 0 if ca == cb else 1
            curr_row.append(
                min(
                    curr_row[j] + 1,  # insert
                    prev_row[j + 1] + 1,  # delete
                    prev_row[j] + cost,  # substitute
                )
            )
        prev_row = curr_row
    return prev_row[-1]


def compute_field_similarity(value_a: str, value_b: str) -> float:
    """Compute similarity between two field value strings.

    Returns a score in ``[0.0, 1.0]``.

    * **Token-based (Jaccard-like)** similarity is used for longer strings
      (titles, abstracts) where word overlap is more meaningful than character
      edits.
    * **Normalized Levenshtein** is used for shorter strings (author names,
      journal abbreviations) where character-level comparison is more
      appropriate.

    The function is symmetric: ``sim(a, b) == sim(b, a)`` and
    ``sim(a, a) == 1.0`` for any non-empty *a*.
    """
    if not value_a and not value_b:
        return 1.0
    if not value_a or not value_b:
        return 0.0

    a_lower = value_a.strip().lower()
    b_lower = value_b.strip().lower()

    if a_lower == b_lower:
        return 1.0

    # Choose strategy based on the *longer* of the two strings
    if max(len(a_lower), len(b_lower)) >= _TOKEN_LENGTH_THRESHOLD:
        return _jaccard_similarity(a_lower, b_lower)
    return _normalized_levenshtein_similarity(a_lower, b_lower)


def _jaccard_similarity(a: str, b: str) -> float:
    """Token-overlap (Jaccard) similarity for longer text fields."""
    tokens_a = set(a.split())
    tokens_b = set(b.split())
    if not tokens_a and not tokens_b:
        return 1.0
    if not tokens_a or not tokens_b:
        return 0.0
    intersection = tokens_a & tokens_b
    union = tokens_a | tokens_b
    return len(intersection) / len(union)


def _normalized_levenshtein_similarity(a: str, b: str) -> float:
    """Normalized Levenshtein similarity for shorter fields."""
    max_len = max(len(a), len(b))
    if max_len == 0:
        return 1.0
    dist = _levenshtein_distance(a, b)
    return 1.0 - (dist / max_len)


# ── Confidence scoring ────────────────────────────────────────────────────────

_NUMERIC_FIELDS = {"year", "volume", "number", "pages"}


def _get_field_value(record: CitationRecord, field: str) -> str:
    """Extract a string value for a field from a CitationRecord."""
    if field == "authors":
        return ", ".join(record.authors)
    return str(getattr(record, field, "") or "")


def compute_confidence(
    record: CitationRecord,
    independent_record: CitationRecord,
) -> float:
    """Compute an overall confidence score in ``[0.0, 1.0]``.

    Compares key bibliographic fields between the resolved *record* and the
    *independent_record* retrieved from an independent data source.

    * **Numeric fields** (year, volume, issue, pages) use **exact match**
      (1.0 or 0.0) and are weighted more heavily, per Walters & Wilder (2023).
    * **Text fields** (title, authors, journal, publisher, DOI) use fuzzy
      similarity via :func:`compute_field_similarity`.

    Only fields where *both* records have non-empty values contribute to the
    score.  If no fields overlap, returns 0.0.
    """
    weighted_sum = 0.0
    total_weight = 0.0

    for field, weight in _FIELD_WEIGHTS.items():
        val_a = _get_field_value(record, field)
        val_b = _get_field_value(independent_record, field)

        # Skip fields where either record has no value
        if not val_a.strip() or not val_b.strip():
            continue

        if field in _NUMERIC_FIELDS:
            # Exact match for numeric fields
            score = 1.0 if val_a.strip() == val_b.strip() else 0.0
        else:
            score = compute_field_similarity(val_a, val_b)

        weighted_sum += score * weight
        total_weight += weight

    if total_weight == 0.0:
        return 0.0

    return weighted_sum / total_weight


# ── Auto-correction ───────────────────────────────────────────────────────────

# Fields eligible for auto-correction (the error categories from Walters &
# Wilder 2023: author names, titles, dates, journal titles, volume/issue/page
# numbers, and publisher names).
_CORRECTABLE_FIELDS = [
    "title",
    "authors",
    "year",
    "journal",
    "volume",
    "number",
    "pages",
    "publisher",
]


def auto_correct_fields(
    record: CitationRecord,
    independent_record: CitationRecord,
) -> CitationRecord:
    """Compare field values and replace discrepant fields with independent source values.

    For each correctable field, if both records have non-empty values and the
    similarity is below the discrepancy threshold, the record's value is
    replaced with the independent source's value and a warning annotation is
    added listing the original value.

    Returns the (mutated) *record* for convenience.
    """
    for field in _CORRECTABLE_FIELDS:
        if field == "authors":
            orig_val = ", ".join(record.authors)
            indep_val = ", ".join(independent_record.authors)
        else:
            orig_val = str(getattr(record, field, "") or "")
            indep_val = str(getattr(independent_record, field, "") or "")

        # Skip if either side is empty
        if not orig_val.strip() or not indep_val.strip():
            continue

        # Check for discrepancy
        if field in _NUMERIC_FIELDS:
            is_discrepant = orig_val.strip() != indep_val.strip()
        else:
            sim = compute_field_similarity(orig_val, indep_val)
            is_discrepant = sim < _TEXT_DISCREPANCY_THRESHOLD

        if is_discrepant:
            # Replace with independent source value
            if field == "authors":
                record.authors = list(independent_record.authors)
            else:
                setattr(record, field, getattr(independent_record, field))

            record.warnings.append(f"Corrected '{field}' from '{orig_val}' to '{indep_val}'")

    return record


# ── RedQueenVerifier ──────────────────────────────────────────────────────────


class RedQueenVerifier:
    """Adversarial citation verifier using independent data source cross-referencing.

    Parameters
    ----------
    sources:
        Mapping from ``IdentifierType`` to data-source clients.
    default_source:
        Fallback source (typically CrossRef) used when no other independent
        source is available.
    use_strands:
        When ``True`` and ``strands-agents`` is importable, delegate
        verification to :class:`StrandsVerifier`.
    strands_model_provider:
        LLM provider for the Strands agent (default ``"bedrock"``).
    """

    def __init__(
        self,
        sources: dict[IdentifierType, DataSourceProtocol],
        default_source: DataSourceProtocol,
        use_strands: bool = False,
        strands_model_provider: str = "bedrock",
    ) -> None:
        self._sources = sources
        self._default_source = default_source
        self._use_strands = use_strands
        self._strands_model_provider = strands_model_provider
        self._strands_verifier: StrandsVerifier | None = None

        if use_strands:
            try:
                from alice_cli.cite.strands_verifier import (
                    StrandsVerifier,
                    is_strands_available,
                )

                if is_strands_available():
                    self._strands_verifier = StrandsVerifier(
                        model_provider=strands_model_provider,
                    )
                    logger.info("Strands verifier initialised")
                else:
                    logger.info("strands-agents not available, using deterministic verification")
            except Exception:
                logger.warning(
                    "Failed to initialise Strands verifier, "
                    "falling back to deterministic verification",
                    exc_info=True,
                )

    # ── Independent source selection ──────────────────────────────────

    def _select_independent_source(
        self,
        resolved_source: IdentifierType | None,
    ) -> DataSourceProtocol:
        """Pick a data source different from the one used for original resolution.

        Falls back to CrossRef (the default source) if no other source is
        available or if the resolved source is unknown.
        """
        if resolved_source is None:
            return self._default_source

        # Try to find any source that is NOT the resolved source
        for id_type, source in self._sources.items():
            if id_type != resolved_source:
                return source

        # All sources are the same type or only one source — use default
        return self._default_source

    # ── Verification ──────────────────────────────────────────────────

    def verify(
        self,
        record: CitationRecord,
        resolved_source: IdentifierType | None = None,
    ) -> CitationRecord:
        """Cross-reference *record* against an independent data source.

        When Strands is configured and available, delegates to the Strands
        verifier.  On any Strands error, falls back to deterministic
        verification.

        Annotates the record with ``verification_status`` ("verified" or
        "unverified") and ``confidence_score`` in ``[0.0, 1.0]``.  For
        verified records with field-level discrepancies, auto-corrects fields
        using independent source values.
        """
        # ── Try Strands verification first ────────────────────────────
        if self._strands_verifier is not None:
            try:
                status, confidence = self._strands_verifier.verify(record)
                record.verification_status = status
                record.confidence_score = confidence
                return record
            except Exception:
                logger.warning(
                    "Strands verification failed, falling back to deterministic",
                    exc_info=True,
                )

        # ── Deterministic verification ────────────────────────────────
        return self._deterministic_verify(record, resolved_source)

    def _deterministic_verify(
        self,
        record: CitationRecord,
        resolved_source: IdentifierType | None,
    ) -> CitationRecord:
        """Deterministic verification via field similarity comparison."""
        independent_source = self._select_independent_source(resolved_source)

        # Build a search query from the record's title (most distinctive field)
        query = record.title or record.doi or ""
        if not query.strip():
            logger.info("No searchable field for verification, marking unverified")
            record.verification_status = "unverified"
            record.confidence_score = 0.0
            return record

        try:
            candidates = independent_source.search(query)
        except Exception:
            logger.warning(
                "Independent source query failed, marking unverified",
                exc_info=True,
            )
            record.verification_status = "unverified"
            record.confidence_score = 0.0
            return record

        if not candidates:
            record.verification_status = "unverified"
            record.confidence_score = 0.0
            return record

        # Pick the best matching candidate
        best_candidate: CitationRecord | None = None
        best_confidence = 0.0

        for candidate in candidates:
            confidence = compute_confidence(record, candidate)
            if confidence > best_confidence:
                best_confidence = confidence
                best_candidate = candidate

        record.confidence_score = best_confidence

        if best_confidence >= _VERIFICATION_THRESHOLD and best_candidate is not None:
            record.verification_status = "verified"
            # Auto-correct field-level discrepancies
            auto_correct_fields(record, best_candidate)
        else:
            record.verification_status = "unverified"

        return record
