"""The shared field dictionary (§5.3).

The failure this exists to prevent is four projects growing four
differently-named fields that all mean the same thing. The mechanism is not
policing — it is making reuse the easiest path:

  * adding context to a project ALWAYS starts by searching the dictionary,
    and every result carries its usage count (the signal for "this is the
    canonical one");
  * creating a new definition is the secondary action, and it returns
    near-matches so the UI can warn BEFORE it commits;
  * merge is the release valve, because a dictionary without one degrades
    into exactly the mess it was meant to prevent.
"""

import difflib
import re

from sqlalchemy import func, select

from app.models import FieldDefinition, ProjectField

FIELD_TYPES = {"text", "longtext", "url", "date", "number", "select"}


class FieldError(Exception):
    def __init__(self, message, code="BAD_INPUT"):
        self.code = code
        super().__init__(message)


def slugify(label: str) -> str:
    s = re.sub(r"[^a-z0-9]+", "_", label.strip().lower()).strip("_")
    return s[:80] or "field"


def usage_counts(session) -> dict[str, int]:
    rows = session.execute(
        select(ProjectField.definition_key, func.count(ProjectField.id))
        .group_by(ProjectField.definition_key)).all()
    return {k: n for k, n in rows}


def search(session, q: str = "", *, limit: int = 20) -> list[dict]:
    """Search the dictionary. Ordered by USAGE first — the whole point is that
    the canonical definition is the one that surfaces."""
    counts = usage_counts(session)
    defs = list(session.execute(
        select(FieldDefinition).where(FieldDefinition.archived.is_(False))).scalars())
    term = (q or "").strip().lower()
    if term:
        defs = [d for d in defs if term in d.label.lower() or term in d.key.lower()]
    defs.sort(key=lambda d: (-counts.get(d.key, 0), d.label.lower()))
    return [{"key": d.key, "label": d.label, "type": d.type, "help": d.help,
             "options": d.options, "default_client_visible": d.default_client_visible,
             "used_in": counts.get(d.key, 0)}
            for d in defs[:limit]]


def near_matches(session, label: str, *, exclude: str = "") -> list[dict]:
    """Definitions whose label is close to `label` — returned on create so the
    UI can say "did you mean Staging site URL, used in 12 projects?" before it
    commits a near-duplicate."""
    counts = usage_counts(session)
    defs = [d for d in session.execute(
        select(FieldDefinition).where(FieldDefinition.archived.is_(False))).scalars()
        if d.key != exclude]
    by_label = {d.label: d for d in defs}
    hits = difflib.get_close_matches(label, list(by_label), n=3, cutoff=0.6)
    return [{"key": by_label[h].key, "label": h, "type": by_label[h].type,
             "used_in": counts.get(by_label[h].key, 0)} for h in hits]


def create(session, actor: str, *, label: str, type: str = "text",
           options: list | None = None, help: str = "",
           default_client_visible: bool = False, key: str = "") -> FieldDefinition:
    label = (label or "").strip()
    if not label:
        raise FieldError("A field needs a label.")
    if type not in FIELD_TYPES:
        raise FieldError(f"Unknown field type '{type}' (one of: {sorted(FIELD_TYPES)}).")
    if type == "select" and not options:
        raise FieldError("A select field needs options.")
    k = (key or slugify(label))
    if session.get(FieldDefinition, k):
        raise FieldError(f"A field '{k}' already exists — reuse it instead.", "EXISTS")
    row = FieldDefinition(key=k, label=label, type=type,
                          options=list(options) if options else None,
                          help=help, default_client_visible=default_client_visible,
                          created_by=actor)
    session.add(row)
    session.flush()
    return row


def merge(session, actor: str, *, loser_key: str, winner_key: str) -> dict:
    """Repoint every value onto the survivor, archive the loser, record where
    it went. Nothing is lost — that is what makes merging safe enough to use."""
    if loser_key == winner_key:
        raise FieldError("Pick two different fields.")
    loser = session.get(FieldDefinition, loser_key)
    winner = session.get(FieldDefinition, winner_key)
    if not loser or not winner:
        raise FieldError("No such field.", "NOT_FOUND")
    if winner.archived:
        raise FieldError("The surviving field is archived.", "BAD_INPUT")

    moved, dropped = 0, 0
    winner_projects = {v.project_id for v in session.execute(
        select(ProjectField).where(ProjectField.definition_key == winner_key)).scalars()}
    for val in list(session.execute(
            select(ProjectField).where(ProjectField.definition_key == loser_key)).scalars()):
        if val.project_id in winner_projects:
            # the project already answered the surviving field — keep that one
            session.delete(val)
            dropped += 1
        else:
            val.definition_key = winner_key
            val.updated_by = actor
            moved += 1
    loser.archived = True
    loser.merged_into = winner_key
    session.flush()
    return {"moved": moved, "dropped": dropped,
            "winner": winner_key, "loser": loser_key}
