"""Citation + term aliases for warrant-resolver query expansion.

Implements `#000041` (citation_aliases) + `#000042` (term_aliases)
as one cohesive helper module. Both tables share schema shape
(audit-discipline fields: ``decision_at`` + ``decision_by`` +
``decision_rationale``); both are read at warrant-resolve time
when the operator opts in via ``--use-aliases``.

**Citation alias** = "use a different textbook for THIS cite"
(e.g., Hilbert-Ackermann 1928 instead of Mendelson 1997 for
pillar I logic axioms whose original cite is proprietary).

**Term alias** = "this textbook uses a different word for THIS
concept" (e.g., Hilbert 1902 says "connection" for what modern
texts call "incidence" in geometry).

The two are orthogonal: a single resolution can use both layers
(cite Hilbert-Ackermann substitute AND match its "connection"
vocabulary against modern claim-pack records that say
"incidence").

Audit discipline: every row carries ``decision_at`` +
``decision_by`` + (optional but recommended) ``decision_rationale``.
The :func:`add_citation_alias` / :func:`add_term_alias` helpers
require ``decision_by`` (raise on empty); silent fabrication of
substitutions is not allowed. The CLI surface
(``arborist alias citation add ...`` /
``arborist alias term add ...``) enforces the same.

Source: tickets #000041 + #000042.
"""

from __future__ import annotations

import json
import re
import sqlite3
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Optional


# ---------------------------------------------------------------------------
# Aliases DB resolution
# ---------------------------------------------------------------------------

# Aliases live alongside the shards-cluster main DB (or a sibling
# dedicated file). For the first iteration we put them in shard 000.db
# (where the claim-pack records live) so the resolver only needs to
# attach one DB. Future: split into a dedicated file per the design
# notes in #000041 §2.1 (TBD via mesh-sync semantics).


def _connect_rw(db_path: str | Path) -> sqlite3.Connection:
    return sqlite3.connect(str(db_path))


def _connect_ro(db_path: str | Path) -> Optional[sqlite3.Connection]:
    try:
        return sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
    except sqlite3.OperationalError:
        return None


# ---------------------------------------------------------------------------
# Citation aliases
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class CitationAlias:
    original_ref: str
    substitute_ref: str
    substitute_authors: tuple[str, ...]
    substitute_title: str
    decision_at: int
    decision_by: str
    decision_rationale: str = ""


def add_citation_alias(
    db_path: str | Path,
    *,
    original_ref: str,
    substitute_ref: str,
    substitute_authors: Iterable[str],
    substitute_title: str,
    decision_by: str,
    decision_rationale: str = "",
    decision_at: Optional[int] = None,
) -> bool:
    """Insert a citation alias row. Returns True if newly inserted,
    False if (original, substitute) already existed.

    Raises ValueError on empty `decision_by` or `original_ref` or
    `substitute_ref`. Audit discipline at the API surface — fail
    closed; silent substitution is not allowed.
    """
    if not original_ref.strip():
        raise ValueError("original_ref must be non-empty")
    if not substitute_ref.strip():
        raise ValueError("substitute_ref must be non-empty")
    if not decision_by.strip():
        raise ValueError("decision_by must be non-empty (audit discipline)")

    if decision_at is None:
        decision_at = int(time.time())
    authors_json = json.dumps(list(substitute_authors), ensure_ascii=False)
    conn = _connect_rw(db_path)
    try:
        cur = conn.execute(
            "INSERT OR IGNORE INTO citation_aliases "
            "(original_ref, substitute_ref, substitute_authors, "
            " substitute_title, decision_at, decision_by, "
            " decision_rationale) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                original_ref.strip(),
                substitute_ref.strip(),
                authors_json,
                substitute_title.strip(),
                decision_at,
                decision_by.strip(),
                decision_rationale.strip(),
            ),
        )
        conn.commit()
        return cur.rowcount > 0
    finally:
        conn.close()


def list_citation_aliases(
    db_path: str | Path,
    *,
    original_filter: Optional[str] = None,
) -> list[CitationAlias]:
    """Return all citation aliases (or those whose original_ref
    matches the optional substring filter)."""
    conn = _connect_ro(db_path)
    if conn is None:
        return []
    try:
        if original_filter:
            rows = conn.execute(
                "SELECT * FROM citation_aliases "
                "WHERE original_ref LIKE ? "
                "ORDER BY decision_at DESC",
                (f"%{original_filter}%",),
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM citation_aliases ORDER BY decision_at DESC"
            ).fetchall()
    except sqlite3.OperationalError:
        return []
    finally:
        conn.close()
    out: list[CitationAlias] = []
    for r in rows:
        try:
            authors = tuple(json.loads(r[2]) or [])
        except (TypeError, ValueError):
            authors = ()
        out.append(
            CitationAlias(
                original_ref=r[0],
                substitute_ref=r[1],
                substitute_authors=authors,
                substitute_title=r[3],
                decision_at=r[4],
                decision_by=r[5],
                decision_rationale=r[6] or "",
            )
        )
    return out


def remove_citation_alias(
    db_path: str | Path, original_ref: str, substitute_ref: str
) -> bool:
    conn = _connect_rw(db_path)
    try:
        cur = conn.execute(
            "DELETE FROM citation_aliases "
            "WHERE original_ref = ? AND substitute_ref = ?",
            (original_ref.strip(), substitute_ref.strip()),
        )
        conn.commit()
        return cur.rowcount > 0
    finally:
        conn.close()


def lookup_citation_aliases(
    db_path: str | Path, original_ref: str
) -> list[CitationAlias]:
    """Return the registered substitutes for an original citation.
    Used by the warrant resolver when `--use-aliases` is set."""
    conn = _connect_ro(db_path)
    if conn is None:
        return []
    try:
        rows = conn.execute(
            "SELECT * FROM citation_aliases WHERE original_ref = ?",
            (original_ref.strip(),),
        ).fetchall()
    except sqlite3.OperationalError:
        return []
    finally:
        conn.close()
    out: list[CitationAlias] = []
    for r in rows:
        try:
            authors = tuple(json.loads(r[2]) or [])
        except (TypeError, ValueError):
            authors = ()
        out.append(
            CitationAlias(
                original_ref=r[0],
                substitute_ref=r[1],
                substitute_authors=authors,
                substitute_title=r[3],
                decision_at=r[4],
                decision_by=r[5],
                decision_rationale=r[6] or "",
            )
        )
    return out


# ---------------------------------------------------------------------------
# Term aliases
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class TermAlias:
    term: str
    alternate_term: str
    domain: str
    decision_at: int
    decision_by: str
    decision_rationale: str = ""


def add_term_alias(
    db_path: str | Path,
    *,
    term: str,
    alternate_term: str,
    domain: str,
    decision_by: str,
    decision_rationale: str = "",
    decision_at: Optional[int] = None,
) -> bool:
    """Insert a term alias row. Returns True if newly inserted, False
    on PK collision. Raises ValueError on empty audit fields."""
    if not term.strip():
        raise ValueError("term must be non-empty")
    if not alternate_term.strip():
        raise ValueError("alternate_term must be non-empty")
    if not domain.strip():
        raise ValueError("domain must be non-empty")
    if not decision_by.strip():
        raise ValueError("decision_by must be non-empty (audit discipline)")
    if decision_at is None:
        decision_at = int(time.time())
    conn = _connect_rw(db_path)
    try:
        cur = conn.execute(
            "INSERT OR IGNORE INTO term_aliases "
            "(term, alternate_term, domain, decision_at, "
            " decision_by, decision_rationale) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                term.strip().lower(),
                alternate_term.strip().lower(),
                domain.strip().lower(),
                decision_at,
                decision_by.strip(),
                decision_rationale.strip(),
            ),
        )
        conn.commit()
        return cur.rowcount > 0
    finally:
        conn.close()


def list_term_aliases(
    db_path: str | Path,
    *,
    domain: Optional[str] = None,
    term_filter: Optional[str] = None,
) -> list[TermAlias]:
    """Return all term aliases (optionally filtered by domain or
    a substring match on the term)."""
    conn = _connect_ro(db_path)
    if conn is None:
        return []
    try:
        clauses: list[str] = []
        params: list = []
        if domain:
            clauses.append("domain = ?")
            params.append(domain.strip().lower())
        if term_filter:
            clauses.append("term LIKE ?")
            params.append(f"%{term_filter.strip().lower()}%")
        where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
        rows = conn.execute(
            f"SELECT * FROM term_aliases{where} ORDER BY decision_at DESC",
            params,
        ).fetchall()
    except sqlite3.OperationalError:
        return []
    finally:
        conn.close()
    return [
        TermAlias(
            term=r[0],
            alternate_term=r[1],
            domain=r[2],
            decision_at=r[3],
            decision_by=r[4],
            decision_rationale=r[5] or "",
        )
        for r in rows
    ]


def remove_term_alias(
    db_path: str | Path, term: str, alternate_term: str, domain: str
) -> bool:
    conn = _connect_rw(db_path)
    try:
        cur = conn.execute(
            "DELETE FROM term_aliases "
            "WHERE term = ? AND alternate_term = ? AND domain = ?",
            (
                term.strip().lower(),
                alternate_term.strip().lower(),
                domain.strip().lower(),
            ),
        )
        conn.commit()
        return cur.rowcount > 0
    finally:
        conn.close()


def lookup_term_aliases(
    db_path: str | Path, term: str, domain: str
) -> list[str]:
    """Return the registered alternate-terms for `(term, domain)`.
    Used by the warrant resolver to OR-expand FTS5 queries.
    Bidirectional: also searches by alternate_term so a registered
    `(incidence, connection, geometry)` row also returns
    "incidence" when looking up `("connection", "geometry")`.
    """
    conn = _connect_ro(db_path)
    if conn is None:
        return []
    try:
        rows = conn.execute(
            "SELECT alternate_term FROM term_aliases "
            "WHERE term = ? AND domain = ? "
            "UNION "
            "SELECT term FROM term_aliases "
            "WHERE alternate_term = ? AND domain = ?",
            (
                term.strip().lower(),
                domain.strip().lower(),
                term.strip().lower(),
                domain.strip().lower(),
            ),
        ).fetchall()
    except sqlite3.OperationalError:
        return []
    finally:
        conn.close()
    return [r[0] for r in rows if r[0]]


# ---------------------------------------------------------------------------
# Pillar → domain mapping
# ---------------------------------------------------------------------------


_PILLAR_DOMAIN = {
    "I": "logic",
    "II": "set-theory",
    "III": "arithmetic",
    "IV": "geometry",
    "V": "probability",
    "VI": "classical-physics",
    "VII": "combinatorics",
    "IX": "lambda-calculus",
}


def domain_for_pillar(pillar: str) -> str:
    """Map a Roman-numeral pillar identifier to its domain string."""
    return _PILLAR_DOMAIN.get(pillar.upper().strip(), "")


# ---------------------------------------------------------------------------
# Query expansion (used by warrant resolver)
# ---------------------------------------------------------------------------


def expand_query_with_term_aliases(
    fts5_query: str, domain: str, db_path: str | Path
) -> str:
    """Replace each significant token in a FTS5 query with its
    `(token OR alt1 OR alt2)` expansion when (token, domain) has
    registered aliases.

    Preserves phrase syntax (`"foo bar"` becomes
    `("foo bar" OR "alt1 alt2")` — the alias for the multi-word
    phrase is looked up separately if registered). Operators on
    the query (`AND`, `OR`, `NOT`, parens) pass through unchanged.

    Returns the original query unchanged when no aliases apply or
    when the aliases DB is unreachable.
    """
    if not fts5_query.strip() or not domain.strip():
        return fts5_query

    # Split on whitespace, preserving quoted phrases as single tokens.
    parts = _tokenize_fts_query(fts5_query)
    if not parts:
        return fts5_query

    out: list[str] = []
    for part in parts:
        # Preserve operators / parens unchanged.
        if part.upper() in ("AND", "OR", "NOT") or part in ("(", ")"):
            out.append(part)
            continue
        # Phrase tokens (`"foo bar"`) — lookup the whole phrase;
        # otherwise treat as a single bare token.
        bare = part.strip().lower().strip('"')
        if not bare or len(bare) < 3:
            out.append(part)
            continue
        alts = lookup_term_aliases(db_path, bare, domain)
        if not alts:
            out.append(part)
            continue
        # Build the OR-expansion. Quote the parts so multi-word
        # phrases survive FTS5 parsing.
        expanded = [_quote_for_fts(part)] + [
            _quote_for_fts(_match_quoting(part, a)) for a in alts
        ]
        out.append("(" + " OR ".join(expanded) + ")")
    return " ".join(out)


_QUOTED_PHRASE_RE = re.compile(r'"[^"]*"')


def _tokenize_fts_query(q: str) -> list[str]:
    """Naive tokenizer that preserves quoted phrases as single tokens.
    Whitespace-split everything else."""
    out: list[str] = []
    i = 0
    while i < len(q):
        c = q[i]
        if c == '"':
            end = q.find('"', i + 1)
            if end == -1:
                # unterminated quote — fall back to treating the rest
                # as one token so we don't corrupt the user's query.
                out.append(q[i:])
                return out
            out.append(q[i : end + 1])
            i = end + 1
        elif c.isspace():
            i += 1
        elif c in "()":
            out.append(c)
            i += 1
        else:
            j = i
            while j < len(q) and not q[j].isspace() and q[j] not in '()"':
                j += 1
            out.append(q[i:j])
            i = j
    return out


def _quote_for_fts(token: str) -> str:
    """Pass through already-quoted phrases; quote bare multi-word
    tokens (which shouldn't exist in our tokenizer's output, but
    defense in depth)."""
    if token.startswith('"') and token.endswith('"'):
        return token
    if " " in token:
        return f'"{token}"'
    return token


def _match_quoting(reference: str, alt: str) -> str:
    """If `reference` is a quoted phrase, return alt as a quoted
    phrase; otherwise return alt bare. Preserves the original
    query's matching style."""
    if reference.startswith('"') and reference.endswith('"'):
        return f'"{alt}"'
    return alt
