"""The answer ledger: verified answers reusable while their evidence holds.

A recorded answer carries the exact source spans that justified it, each with
a hash of its current text. A later similar question may reuse the answer only
when every one of those spans is still byte-identical - reuse is safe not
because time passed but because the lines that proved the answer have not
moved. Anything else is a miss and the question takes the normal lanes.
"""

from __future__ import annotations

import hashlib
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from . import graph as graph_module
from . import store, tokens

SCHEMA_VERSION = 1
MAX_ENTRIES = 500
QUESTION_CAP = 500
ANSWER_CAP = 2000
EVIDENCE_SPAN_CAP = 8
SPAN_LINES_CAP = 400
REUSE_WINDOW_SECONDS = 7 * 24 * 3600.0
MIN_SHARED_TOKENS = 3
MIN_JACCARD = 0.6
MIN_CONTAINMENT = 0.75
QUOTE_CAP = 120


def _iso(epoch: float) -> str:
    return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z")


def _parse_date(value: Any) -> float:
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        try:
            return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
        except ValueError:
            return 0.0
    return 0.0


def _code_idents(text: str) -> list[str]:
    """The identifiers in ``text`` that read as code, lowered and sorted.

    Equality over this set is the precision backstop: LOW_COVERAGE and
    HIGH_COVERAGE differ here no matter how much filler the questions share.
    """
    found: set[str] = set()
    for identifier in tokens.identifiers(text):
        if len(identifier) < 3 or identifier.lower() in tokens.STOPWORDS:
            continue
        has_underscore = "_" in identifier
        mixed = any(ch.isupper() for ch in identifier) and any(ch.islower() for ch in identifier)
        screaming = identifier.isupper() and len(identifier) >= 4
        if has_underscore or mixed or screaming:
            found.add(identifier.lower())
    return sorted(found)


def _span_hash(lines: list[str], start: int, end: int) -> str | None:
    if start < 1 or end < start or end > len(lines) or end - start + 1 > SPAN_LINES_CAP:
        return None
    body = "\n".join(line.rstrip() for line in lines[start - 1 : end])
    return hashlib.sha256(body.encode("utf-8", errors="replace")).hexdigest()[:16]


def _current_lines(root: Path, rel: str) -> list[str] | None:
    text = graph_module.read_text(root.joinpath(*rel.split("/")))
    return None if text is None else text.split("\n")


def load(root: Path) -> list[dict]:
    data = store.read_json(store.answer_ledger_path(root))
    if data is None or data.get("schema_version") != SCHEMA_VERSION:
        return []
    entries = data.get("entries")
    if not isinstance(entries, list):
        return []
    return [
        entry
        for entry in entries
        if isinstance(entry, dict) and isinstance(entry.get("answer"), str) and isinstance(entry.get("evidence"), list)
    ]


def _write(root: Path, entries: list[dict], now: float) -> bool:
    entries.sort(key=lambda item: _parse_date(item.get("date")), reverse=True)
    del entries[MAX_ENTRIES:]
    return store.write_json(
        store.answer_ledger_path(root),
        {"schema_version": SCHEMA_VERSION, "updated_at": _iso(now), "entries": entries},
    )


def record(root: Path, question: str, answer: str, evidence: list[dict]) -> str:
    """Store a verified answer with hashed evidence. Returns the id, or ``""``.

    Unverifiable evidence - an unreadable file, an oversized span - refuses
    the whole record: a ledger entry that cannot be re-checked later is a
    liability, not a cache.
    """
    question_text = str(question or "").strip()[:QUESTION_CAP]
    answer_text = str(answer or "").strip()[:ANSWER_CAP]
    if not question_text or not answer_text or not evidence:
        return ""

    hashed: list[dict] = []
    lines_cache: dict[str, list[str] | None] = {}
    for span in evidence[:EVIDENCE_SPAN_CAP]:
        if not isinstance(span, dict):
            return ""
        rel = store.to_rel(root, str(span.get("path") or ""))
        try:
            start, end = int(span.get("start")), int(span.get("end"))
        except (TypeError, ValueError):
            return ""
        if rel not in lines_cache:
            lines_cache[rel] = _current_lines(root, rel)
        lines = lines_cache[rel]
        if lines is None:
            return ""
        digest = _span_hash(lines, start, end)
        if digest is None:
            return ""
        hashed.append({"path": rel, "start": start, "end": end, "hash": digest})
    if not hashed:
        return ""

    now = time.time()
    entry = {
        "id": uuid.uuid4().hex[:12],
        "question": question_text,
        "tokens": sorted(set(tokens.tokenize(question_text))),
        "idents": _code_idents(question_text),
        "answer": answer_text,
        "evidence": hashed,
        "date": _iso(now),
    }
    entries = load(root)
    key = frozenset(entry["tokens"])
    entries = [existing for existing in entries if frozenset(existing.get("tokens") or []) != key]
    entries.append(entry)
    return entry["id"] if _write(root, entries, now) else ""


def _miss() -> dict:
    return {"hit": False, "id": "", "question": "", "answer": "", "age_days": 0.0, "similarity": 0.0, "evidence": []}


def lookup(root: Path, question: str) -> dict:
    """Best still-valid prior answer for ``question``, or a miss.

    A hit requires the question's code identifiers to match exactly, enough
    token overlap, a fresh-enough entry, and every evidence span hashing
    identically against the current files.
    """
    question_text = str(question or "").strip()
    if not question_text:
        return _miss()
    query_tokens = set(tokens.tokenize(question_text))
    if not query_tokens:
        return _miss()
    query_idents = _code_idents(question_text)
    now = time.time()

    best: dict | None = None
    best_similarity = 0.0
    for entry in load(root):
        if sorted(entry.get("idents") or []) != query_idents:
            continue
        age = now - _parse_date(entry.get("date"))
        if age < 0 or age > REUSE_WINDOW_SECONDS:
            continue
        entry_tokens = set(entry.get("tokens") or [])
        shared = query_tokens & entry_tokens
        if len(shared) < MIN_SHARED_TOKENS:
            continue
        union = query_tokens | entry_tokens
        jaccard = len(shared) / len(union) if union else 0.0
        containment = len(shared) / min(len(query_tokens), len(entry_tokens))
        if jaccard < MIN_JACCARD and containment < MIN_CONTAINMENT:
            continue
        if jaccard > best_similarity or (best is not None and jaccard == best_similarity and _parse_date(
            entry.get("date")
        ) > _parse_date(best.get("date"))):
            best, best_similarity = entry, jaccard

    if best is None:
        return _miss()

    quoted: list[dict] = []
    lines_cache: dict[str, list[str] | None] = {}
    for span in best.get("evidence") or []:
        rel = span.get("path")
        if not isinstance(rel, str):
            return _miss()
        if rel not in lines_cache:
            lines_cache[rel] = _current_lines(root, rel)
        lines = lines_cache[rel]
        if lines is None:
            return _miss()
        try:
            start, end = int(span.get("start")), int(span.get("end"))
        except (TypeError, ValueError):
            return _miss()
        if _span_hash(lines, start, end) != span.get("hash"):
            return _miss()
        quote = lines[start - 1].rstrip()[:QUOTE_CAP] if 1 <= start <= len(lines) else ""
        quoted.append({"path": rel, "start": start, "end": end, "quote": quote})

    age_days = round((now - _parse_date(best.get("date"))) / 86400.0, 1)
    return {
        "hit": True,
        "id": best.get("id") or "",
        "question": best.get("question") or "",
        "answer": best.get("answer") or "",
        "age_days": age_days,
        "similarity": round(best_similarity, 3),
        "evidence": quoted,
    }
