"""Phrase suggestions for bottom lines and problem discussions.

`rebuild` splits every stored bottom line and problem discussion for a center
into sentences and writes them to `phrases` with the forecast's context
(problem types, likelihood, size, locations, date). `suggest` ranks sentences
for a draft by how closely their forecast matches it and by how often the
wording was reused across forecasts.

Danger-level words are masked to "[rating]" so a suggestion never sets the
rating for the forecaster. Imported archives are legacy-era text: descriptive
wording to borrow from, not guidance.

Source precedence (app/sources.py): writing-style checks (app/style.py) run on
every suggestion; Forecast Guidance findings demote a phrase more than Writing
for Busy Readers findings. Curated
guidance phrases (center descriptions, then CMAH) are returned first as
`guidance`; archive phrases follow as `items`.
"""

from __future__ import annotations

import math
import re
from datetime import date

from psycopg import Connection

from .db import fetchall, fetchone
from .retrieval import _jaccard, _season_closeness
from .sources import reference
from .style import FG, check

FIELDS = ("bottom_line", "problem")
RATING = "[rating]"
MIN_LEN, MAX_LEN, MIN_WORDS = 25, 400, 5
MAX_CANDIDATES = 20000

_LEVEL = r"(?:low|moderate|considerable|high|extreme)"
_CAPS_LEVEL = re.compile(r"\b(?:LOW|MODERATE|CONSIDERABLE|HIGH|EXTREME)\b")
# "Moderate avalanche danger", "low to moderate danger"
_LEVEL_BEFORE_DANGER = re.compile(
    rf"\b{_LEVEL}(?=(?:[\s,]+(?:to|and|or|avalanche|{_LEVEL}|\(\d\)))*[\s,]+danger\b)", re.I
)
# "danger is moderate", "danger remains at LOW", "danger will rise to Moderate", "range from Moderate"
_DANGER_VERB_LEVEL = re.compile(
    r"(\bdanger\s+(?:(?:is|are|be|remains?|will|should|could|may|stays?|also|again|back|still|rated|at|near|"
    r"mostly|generally|becomes?|increases?|rises?|climbs?|reach(?:es)?|decreases?|drops?|returns?|"
    r"ranges?|from|to|up|down|for|today|tonight|tomorrow|quickly|slowly|gradually|likely|expected)\s+)+)"
    rf"{_LEVEL}\b",
    re.I,
)
# the second level in "[rating] to Considerable"
_RATING_PAIR = re.compile(rf"(\[rating\],?\s+(?:to|and|or|then)\s+){_LEVEL}\b", re.I)
_ABBREV = re.compile(r"\b(?:mt|mtn|st|hwy|approx|e\.g|i\.e|ft|vs|jan|feb|mar|apr|jun|jul|aug|sept?|oct|nov|dec)\.$", re.I)
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[\"“(]?[A-Z0-9])")


FG_PENALTY, BUSY_PENALTY = 1.5, 0.5


def style_flags(text: str, field: str) -> list[dict]:
    """Style findings for a single suggested sentence (app/style.py), without offsets."""
    return [{"source": f["source"], "rule": f["rule"], "text": f["text"]} for f in check(text, field)]


def _penalty(flags: list[dict]) -> float:
    return sum(FG_PENALTY if f["source"] == FG else BUSY_PENALTY for f in flags)


def guidance_phrases(field: str, problem_types: set[int], q: str | None = None) -> list[dict]:
    """Curated phrases for these problem types, in source-precedence order."""
    ref = reference()
    words = re.findall(r"[a-z0-9]+", (q or "").lower())
    out = []
    for i, p in enumerate(ref["phrases"]):
        if field not in p["fields"] or p["problem_type"] not in problem_types:
            continue
        if words:
            tokens = re.findall(r"[a-z0-9]+", p["text"].lower())
            if not all(any(t.startswith(w) for t in tokens) for w in words):
                continue
        src = ref["sources"][p["source"]]
        out.append({"text": p["text"], "source": p["source"], "source_label": src["label"], "doc": src["doc"],
                    "problem_type": p["problem_type"], "flags": style_flags(p["text"], field), "_order": (src["rank"], i)})
    out.sort(key=lambda g: g.pop("_order"))
    return out


def mask_danger(text: str) -> str:
    text = _CAPS_LEVEL.sub(RATING, text)
    text = _LEVEL_BEFORE_DANGER.sub(RATING, text)
    text = _DANGER_VERB_LEVEL.sub(lambda m: m.group(1) + RATING, text)
    return _RATING_PAIR.sub(lambda m: m.group(1) + RATING, text)


def split_sentences(text: str | None) -> list[str]:
    if not text:
        return []
    out = []
    for para in re.split(r"\n\s*\n", text.replace("\r", "")):
        para = " ".join(para.split())
        buf = ""
        for part in _SENTENCE_END.split(para):
            buf = f"{buf} {part}" if buf else part
            if not _ABBREV.search(buf):
                out.append(buf)
                buf = ""
        if buf:
            out.append(buf)
    return out


def normalize(text: str) -> str:
    """Grouping key: case, digits and punctuation ignored."""
    t = re.sub(r"\d+(?:\.\d+)?", "#", text.lower())
    return " ".join(re.sub(r"[^a-z#\[\]]+", " ", t).split())


def phrases_from(text: str | None) -> list[tuple[str, str]]:
    """Usable (text, norm) sentences from one field, deduplicated."""
    seen, out = set(), []
    for s in split_sentences(text):
        if not MIN_LEN <= len(s) <= MAX_LEN or len(s.split()) < MIN_WORDS:
            continue
        s = mask_danger(s)
        n = normalize(s)
        if n and n not in seen:
            seen.add(n)
            out.append((s, n))
    return out


def rebuild(conn: Connection, center_id: int) -> dict:
    """Replace the center's phrase library from its stored forecasts."""
    conn.execute("DELETE FROM phrases WHERE center_id = %s", (center_id,))
    rows = []
    for f in fetchall(
        conn,
        """SELECT f.id, f.valid_date, f.source, f.guidance_era, f.bottom_line,
                  coalesce(array_agg(DISTINCT p.problem_type) FILTER (WHERE p.id IS NOT NULL), '{}') AS types
           FROM forecasts f LEFT JOIN forecast_problems p ON p.forecast_id = f.id
           WHERE f.center_id = %s GROUP BY f.id""",
        (center_id,),
    ):
        for text, norm in phrases_from(f["bottom_line"]):
            rows.append((center_id, f["id"], "bottom_line", f["types"], None, None, [], text, norm,
                         f["valid_date"], f["source"], f["guidance_era"]))
    for p in fetchall(
        conn,
        """SELECT p.forecast_id, p.problem_type, p.likelihood, p.size_max, p.locations, p.discussion,
                  f.valid_date, f.source, f.guidance_era
           FROM forecast_problems p JOIN forecasts f ON f.id = p.forecast_id WHERE f.center_id = %s""",
        (center_id,),
    ):
        for text, norm in phrases_from(p["discussion"]):
            rows.append((center_id, p["forecast_id"], "problem", [p["problem_type"]], p["likelihood"], p["size_max"],
                         p["locations"], text, norm, p["valid_date"], p["source"], p["guidance_era"]))
    with conn.cursor() as cur:
        cur.executemany(
            """INSERT INTO phrases (center_id, forecast_id, field, problem_types, likelihood, size_max, locations,
                   text, norm, valid_date, source, guidance_era)
               VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
            rows,
        )
    return {"phrases": len(rows), "distinct": len({(r[2], r[8]) for r in rows})}


def _prefix_tsquery(q: str | None) -> str | None:
    words = re.findall(r"[a-z0-9]+", (q or "").lower())[:8]
    return " & ".join(f"{w}:*" for w in words) or None


def _context(row: dict, field: str, types: set[int], likelihood, size_max, locations: set[str], valid_date: date | None) -> float:
    s = 1.5 * _season_closeness(valid_date, row["valid_date"]) if valid_date else 0.0
    if field == "bottom_line":
        return s + (2.0 * _jaccard(types, set(row["problem_types"])) if types else 0.0)
    if locations:
        s += _jaccard(locations, set(row["locations"] or []))
    if likelihood and row["likelihood"]:
        s -= 0.4 * abs(int(likelihood) - int(row["likelihood"]))
    if size_max is not None and row["size_max"] is not None:
        s -= 0.4 * abs(float(size_max) - float(row["size_max"]))
    return s


def suggest(conn: Connection, center_id: int, field: str, *, problem_type: int | None = None,
            problem_types: list[int] | None = None, likelihood: int | None = None, size_max: float | None = None,
            locations: list[str] | None = None, valid_date: date | None = None, q: str | None = None,
            limit: int = 12) -> dict:
    where, params = ["center_id = %s", "field = %s"], [center_id, field]
    types = set(problem_types or [])
    if field == "problem":
        types = {problem_type}
        where.append("problem_types @> ARRAY[%s]::smallint[]")
        params.append(problem_type)
    elif types:
        where.append("problem_types && %s::smallint[]")
        params.append(sorted(types))
    tsq = _prefix_tsquery(q)
    if tsq:
        where.append("tsv @@ to_tsquery('english', %s)")
        params.append(tsq)
    rows = fetchall(
        conn,
        f"""SELECT forecast_id, problem_types, likelihood, size_max, locations, text, norm, valid_date, guidance_era
            FROM phrases WHERE {' AND '.join(where)} ORDER BY valid_date DESC LIMIT {MAX_CANDIDATES}""",
        params,
    )
    locs = set(locations or [])
    groups: dict[str, dict] = {}
    for r in rows:
        ctx = _context(r, field, types, likelihood, size_max, locs, valid_date)
        g = groups.get(r["norm"])
        if g is None:  # rows are newest first, so the first variant is the latest wording
            groups[r["norm"]] = g = {"text": r["text"], "forecast_ids": set(), "last_used": r["valid_date"],
                                     "example_forecast_id": r["forecast_id"], "eras": set(), "context": ctx}
        g["forecast_ids"].add(r["forecast_id"])
        g["eras"].add(r["guidance_era"])
        g["context"] = max(g["context"], ctx)
    items = []
    for g in groups.values():
        n = len(g["forecast_ids"])
        flags = style_flags(g["text"], field)
        items.append({
            "text": g["text"], "uses": n, "last_used": g["last_used"], "example_forecast_id": g["example_forecast_id"],
            "legacy": g["eras"] == {"legacy"}, "has_rating": RATING in g["text"], "flags": flags,
            "score": round(g["context"] + 0.8 * math.log1p(n) - _penalty(flags), 4),
        })
    items.sort(key=lambda i: (-i["score"], -i["uses"]))
    empty = not rows and not fetchone(conn, "SELECT 1 FROM phrases WHERE center_id = %s LIMIT 1", (center_id,))
    return {"guidance": guidance_phrases(field, types, q), "items": items[: max(1, min(limit, 50))],
            "matched": len(groups), "library_empty": empty}
