"""Merge suggestions at every level, rules first: what the queue offers a person (Stream M).

Sources of truth: this module, `models/catalog.py` (Suggestion with `level`, `left_id`,
`right_id`), `cli.cmd_suggest`, `tests/test_suggest.py`. Rian, 12 Sep: merges at every
level are made by a person, with the machine suggesting and the person confirming in
bulk. Nothing here merges; every function is a scorer that returns pairs with a score
and one line a person can read without the code (`why`), and `generate()` writes them as
`suggestions` rows, once per pair, never touching a pair that has a decision (a
rejected pair never resurfaces). An LLM pass is a later enhancement, not this.

The rules, each pinned by a real case:
* listed_brand: a known rebrand (Paco Rabanne became Rabanne in 2023); one brand's name inside
  the other's, word for word, or one the initials of the other (CK, D&G, YSL), with line
  names in common. Two spellings of one brand already fold to one row, so a listed_brand pair
  here is two rows a person must join.
* line: under one brand, one line's words inside the other's ("Eternity For Men" inside
  "Eternity Aromatic Essence For Men"), or nearly every word shared ("7 Gran Reserva" and
  "Gran Reserva 7"), or the brand's namesake line beside a line called Original (Jameson
  and Jameson Original); only where the two lines share a size (that is where a merge
  makes a comparison), never a makeup line (shades), never where only a code differs.
* product: the groups the merge rules refused (two barcodes, two concentrations), and
  the same line at the same size where one side names no attribute and the other does:
  CDG's "1 Million 10cl" beside the 100 ml Eau de Toilette at four airports. Where only
  one known attribute exists in the group the score is higher; where several do, the
  bare row is offered against each, lower.
"""

from __future__ import annotations

import logging
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import UTC, datetime
from itertools import combinations
from typing import Any

from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models import Brand, Listing, Suggestion, ProductVariant, ProductLine
from app.services import product_lines

logger = logging.getLogger(__name__)

LEVELS = ("brand", "line", "product")

#: Houses known to have changed their name: (old slug, new slug, the year, in a word).
REBRANDS: tuple[tuple[str, str, str], ...] = (
    ("paco-rabanne", "rabanne", "renamed itself Rabanne in 2023"),
)

#: The reasons this module's rules write; a pair with one of these that a rule no longer
#: produces is withdrawn. The merge rules' own reasons (two barcodes) are not here.
RULE_REASONS = frozenset({"known_rebrand", "name_within", "initials", "same_letters",
                          "namesake_vs_original", "words_shared", "variation_unknown"})
#: What a person reads for a product pair the merge rules refused to settle.
PRODUCT_REASONS = {
    "gtin_differs": "the two rows carry different barcodes",
    "attribute_differs": "their declared concentrations differ",
    "set_vs_single": "one is a set and the other a single item",
    "quantity_unknown": "at least one of them states no quantity, and unknown never agrees with anything",
    "duplicate_match_key": "they share a key",
    "proposed": "proposed for a person's decision",
}


@dataclass
class SuggestedPair:
    level: str
    left_id: int
    right_id: int
    score: float
    reason: str
    why: str
    detail: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.left_id > self.right_id:
            self.left_id, self.right_id = self.right_id, self.left_id
        self.score = round(min(max(self.score, 0.0), 0.999), 3)


@dataclass
class BrandFacts:
    id: int
    slug: str
    name: str
    lines: frozenset[str]
    product_variants: int
    airports: int


@dataclass
class LineFacts:
    id: int
    brand_id: int
    key: str
    name: str
    product_variants: int
    airports: int
    #: The sizes its product variants come in: a line pair is only worth a person's time when the
    #: two share one, because that is where a merge creates a comparison.
    sizes: frozenset[int] = frozenset()
    #: The family most of its product variants belong to; only drinks and beauty lines are offered.
    vertical: str = "liquor"
    #: The category most of its product variants belong to; makeup is never offered.
    category: str = ""


@dataclass
class ProductFacts:
    id: int
    product_line_id: int | None
    quantity_ml: int | None
    attribute: str
    form: str
    name: str
    airports: int
    gtin: str | None = None
    #: The v4 quantity tuple (value, unit, set_contents) when stated; None when unknown.
    quantity: tuple | None = None


# --------------------------------------------------------------------------- brands

def _initials(slug: str) -> str:
    return "".join(w[0] for w in slug.split("-") if w)


def _contains(short: list[str], long: list[str]) -> bool:
    """Every word of the shorter name appears in the longer, in order."""
    if not short or len(short) >= len(long):
        return False
    it = iter(long)
    return all(any(word == w for w in it) for word in short)


def _names(facts: list[BrandFacts]) -> str:
    return ", ".join(f.name for f in facts)


def brand_pairs(facts: list[BrandFacts]) -> list[SuggestedPair]:
    """Pairs of brands that may be one, with the lines they share as the evidence."""
    by_slug = {f.slug: f for f in facts}
    by_word: dict[str, list[BrandFacts]] = defaultdict(list)
    by_initials: dict[str, list[BrandFacts]] = defaultdict(list)
    by_compact: dict[str, list[BrandFacts]] = defaultdict(list)
    for f in facts:
        for word in set(f.slug.split("-")):
            by_word[word].append(f)
        by_initials[_initials(f.slug)].append(f)
        by_compact[f.slug.replace("-", "")].append(f)
    seen: set[tuple[int, int]] = set()
    out: list[SuggestedPair] = []

    def offer(a: BrandFacts, b: BrandFacts, score: float, reason: str, why: str) -> None:
        pair = (min(a.id, b.id), max(a.id, b.id))
        if pair in seen:
            return
        seen.add(pair)
        shared = sorted(a.lines & b.lines - {""})
        out.append(SuggestedPair(
            "brand", a.id, b.id, score, reason, why,
            {"left": {"id": a.id, "name": a.name, "product_variants": a.product_variants, "airports": a.airports},
             "right": {"id": b.id, "name": b.name, "product_variants": b.product_variants, "airports": b.airports},
             "shared_lines": shared[:12], "shared_count": len(shared)},
        ))

    for old, new, note in REBRANDS:
        a, b = by_slug.get(old), by_slug.get(new)
        if a and b:
            offer(a, b, 0.95, "known_rebrand", f"{a.name} {note}; the two rows are one brand")

    for f in facts:
        words = f.slug.split("-")
        partners: set[int] = set()
        for word in words:
            partners.update(p.id for p in by_word[word])
        partners.update(p.id for p in by_initials.get(f.slug.replace("-", ""), []))
        partners.update(p.id for p in by_compact.get(_initials(f.slug), []))
        partners.update(p.id for p in by_compact.get(f.slug.replace("-", ""), []))
        by_id = {p.id: p for p in facts}
        for pid in partners:
            if pid == f.id or (min(f.id, pid), max(f.id, pid)) in seen:
                continue
            other = by_id[pid]
            shared = (f.lines & other.lines) - {""}
            a_words, b_words = f.slug.split("-"), other.slug.split("-")
            short, long_ = (f, other) if len(a_words) <= len(b_words) else (other, f)
            contained = _contains(short.slug.split("-"), long_.slug.split("-"))
            initials = (_initials(long_.slug) == short.slug.replace("-", "") and len(long_.slug.split("-")) > 1)
            compact = f.slug.replace("-", "") == other.slug.replace("-", "") and f.slug != other.slug
            if compact:
                offer(f, other, 0.85, "same_letters",
                      f"{f.name} and {other.name} are the same letters spaced differently")
                continue
            if not shared:
                continue
            smaller = min(len(f.lines - {""}), len(other.lines - {""})) or 1
            share = len(shared) / smaller
            if contained:
                offer(short, long_, 0.6 + 0.35 * share, "name_within",
                      f"\"{short.name}\" is within \"{long_.name}\" and they share {len(shared)} line name(s)")
            elif initials:
                offer(short, long_, 0.55 + 0.35 * share, "initials",
                      f"{short.name} is the initials of {long_.name} and they share {len(shared)} line name(s)")
    return out


# --------------------------------------------------------------------------- lines

#: What a shade, a size run or a model number looks like as a word: "230w", "112.3n", "7.0",
#: "b12". Two lines whose only differing words are these are one thing in several shades
#: or sizes, not one line spelt twice; folding them would merge the shades.
_CODE_RE = re.compile(r"^[a-z]{0,2}\d+(?:\.\d+)?[a-z]{0,2}$")
LINE_VERTICALS = frozenset({"liquor", "beauty"})
#: Categories whose lines are never offered: a makeup line is one product in many shades,
#: and no rule here can tell a shade from a spelling.
LINE_CATEGORIES_SKIPPED = frozenset({"Makeup"})


def _only_codes_differ(a: list[str], b: list[str]) -> bool:
    differing = set(a) ^ set(b)
    return bool(differing) and all(_CODE_RE.match(w) for w in differing)


def line_pairs(lines: list[LineFacts]) -> list[SuggestedPair]:
    """Pairs of lines under one brand that may be one."""
    by_brand: dict[int, list[LineFacts]] = defaultdict(list)
    for line in lines:
        if line.vertical in LINE_VERTICALS and line.category not in LINE_CATEGORIES_SKIPPED:
            by_brand[line.brand_id].append(line)
    out: list[SuggestedPair] = []
    for brand_lines in by_brand.values():
        if len(brand_lines) < 2:
            continue
        for a, b in combinations(brand_lines, 2):
            shared_sizes = sorted(a.sizes & b.sizes)
            if not shared_sizes:
                continue
            wa, wb = a.key.split(), b.key.split()
            detail = {"left": {"id": a.id, "name": a.name, "product_variants": a.product_variants, "airports": a.airports},
                      "right": {"id": b.id, "name": b.name, "product_variants": b.product_variants, "airports": b.airports},
                      "shared_sizes": shared_sizes}
            if not wa or not wb:
                named = b if not wa else a
                if named.key in ("original", "classic"):
                    out.append(SuggestedPair("line", a.id, b.id, 0.7, "namesake_vs_original",
                                          f"The brand's own line and a line called {named.name} are usually one", detail))
                continue
            if _only_codes_differ(wa, wb):
                continue
            short, long_ = (a, b) if len(wa) <= len(wb) else (b, a)
            sw, lw = short.key.split(), long_.key.split()
            if _contains(sw, lw):
                score = 0.8 if len(sw) >= 2 else 0.5
                out.append(SuggestedPair("line", a.id, b.id, score, "name_within",
                                      f"Every word of \"{short.name}\" is in \"{long_.name}\"", detail))
                continue
            shared = set(sw) & set(lw)
            overlap = len(shared) / len(set(sw) | set(lw))
            if overlap >= 0.75 and len(shared) >= 2:
                out.append(SuggestedPair("line", a.id, b.id, 0.4 + 0.3 * overlap, "words_shared",
                                      f"\"{a.name}\" and \"{b.name}\" share {len(shared)} of their {len(set(sw) | set(lw))} words", detail))
    return out


# --------------------------------------------------------------------------- product variants

def unknown_attribute_pairs(product_variants: list[ProductFacts]) -> list[SuggestedPair]:
    """The same line at the same size where one side names no attribute and the other does."""
    groups: dict[tuple, list[ProductFacts]] = defaultdict(list)
    for p in product_variants:
        # Grouped by the quantity tuple, not the millilitre figure (Stream L): a 75 g and a
        # 75 ml are never the same size, and an unknown quantity joins no group.
        q = p.quantity if p.quantity is not None else ((float(p.quantity_ml), "ml", None) if p.quantity_ml else None)
        if p.product_line_id and q is not None:
            groups[(p.product_line_id, q, p.form)].append(p)
    out: list[SuggestedPair] = []
    for members in groups.values():
        bare = [p for p in members if not p.attribute]
        known = [p for p in members if p.attribute]
        if not bare or not known:
            continue
        attribute_values = {p.attribute for p in known}
        score = 0.6 if len(attribute_values) == 1 else 0.4
        for b in bare:
            for k in known:
                if b.gtin and k.gtin and b.gtin != k.gtin:
                    continue
                out.append(SuggestedPair(
                    "product", b.id, k.id, score, "variation_unknown",
                    f"Same line and size; \"{b.name}\" names no attribute and the other is "
                    f"{product_lines.display_attribute(k.attribute)}"
                    + ("" if len(attribute_values) == 1 else f", one of {len(attribute_values)} attribute_values at this size"),
                    {"bare": {"id": b.id, "name": b.name, "airports": b.airports},
                     "known": {"id": k.id, "name": k.name, "airports": k.airports, "attribute": k.attribute},
                     "attributes_at_size": sorted(attribute_values)},
                ))
    return out


PRODUCT_REASONS.setdefault("kept_separate", "a person kept two of them separate")
PRODUCT_REASONS.setdefault("decided_member", "one carries a person's decision, which a machine never folds away")


def product_reason(reason: str) -> str:
    """One line for a product pair the merge rules refused, from its reason codes."""
    codes = [c for c in reason.split(",") if c]
    parts = [PRODUCT_REASONS.get(code, code.replace("_", " ")) for code in codes]
    return "Same brand, line, attribute and quantity, but " + " and ".join(parts) if parts else "Same key"


# --------------------------------------------------------------------------- the database

def _facts(db: Session) -> tuple[list[BrandFacts], list[LineFacts], list[ProductFacts]]:
    alive = ProductVariant.merged_into_id.is_(None)
    airports_of_product = dict(db.execute(
        select(Listing.variant_id, func.count(func.distinct(Listing.shop_id)))
        .where(Listing.ignored_at.is_(None)).group_by(Listing.variant_id)
    ).all())
    lines_by_id = {row.id: row for row in db.scalars(select(ProductLine))}
    brands = {b.id: b for b in db.scalars(select(Brand))}
    product_variants: list[ProductFacts] = []
    line_product_variants: dict[int, int] = defaultdict(int)
    line_airports: dict[int, int] = defaultdict(int)
    line_sizes: dict[int, set[int]] = defaultdict(set)
    line_verticals: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    line_categories: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    brand_product_variants: dict[int, int] = defaultdict(int)
    brand_airports: dict[int, int] = defaultdict(int)
    brand_lines: dict[int, set[str]] = defaultdict(set)
    for p in db.scalars(select(ProductVariant).where(alive)):
        line = product_lines.resolve_alias(lines_by_id, p.product_line_id) if p.product_line_id else None
        brand = product_lines.resolve_alias(brands, p.brand_id) if p.brand_id else None
        n_airports = int(airports_of_product.get(p.id, 0))
        stated = (p.quantity_state or "") == "stated"
        product_variants.append(ProductFacts(
            id=p.id, product_line_id=line.id if line else None, quantity_ml=p.quantity_ml,
            attribute=(p.attributes or {}).get("attribute", "") or "",
            form=(p.form if p.form and p.form != "single" else "") if p.quantity_state else product_lines.form_of(p.name),
            name=p.name, airports=n_airports, gtin=p.gtin,
            quantity=((float(p.quantity_value) if p.quantity_value is not None else None, p.quantity_unit, p.set_contents)
                      if stated else None),
        ))
        if line is not None:
            line_product_variants[line.id] += 1
            line_airports[line.id] = max(line_airports[line.id], n_airports)
            if p.quantity_ml:
                line_sizes[line.id].add(int(p.quantity_ml))
            line_verticals[line.id][p.vertical or ""] += 1
            line_categories[line.id][p.category or ""] += 1
        if brand is not None:
            brand_product_variants[brand.id] += 1
            brand_airports[brand.id] = max(brand_airports[brand.id], n_airports)
            if line is not None:
                brand_lines[brand.id].add(line.key)
    brand_facts = [
        BrandFacts(id=b.id, slug=b.slug, name=b.name, lines=frozenset(brand_lines.get(b.id, ())),
                   product_variants=brand_product_variants.get(b.id, 0), airports=brand_airports.get(b.id, 0))
        for b in brands.values() if not b.alias_of_id
    ]
    line_facts = [
        LineFacts(id=row.id, brand_id=row.brand_id, key=row.key, name=row.name,
                  product_variants=line_product_variants.get(row.id, 0), airports=line_airports.get(row.id, 0),
                  sizes=frozenset(line_sizes.get(row.id, ())),
                  vertical=max(line_verticals[row.id].items(), key=lambda kv: kv[1])[0] if line_verticals.get(row.id) else "",
                  category=max(line_categories[row.id].items(), key=lambda kv: kv[1])[0] if line_categories.get(row.id) else "")
        for row in lines_by_id.values() if not row.alias_of_id and line_product_variants.get(row.id, 0)
    ]
    return brand_facts, line_facts, product_variants


def _pending_sides_alive(db: Session, level: str) -> set[int]:
    if level == "brand":
        return set(db.scalars(select(Brand.id).where(Brand.alias_of_id.is_(None))))
    if level == "line":
        # A line the rules emptied (identity rules v5 moved every Makeup shade's product variants to
        # a shadeless line) is gone for the queue, whatever its `alias_of_id` says.
        with_product_variants = select(ProductVariant.product_line_id).where(ProductVariant.merged_into_id.is_(None), ProductVariant.product_line_id.isnot(None))
        return set(db.scalars(select(ProductLine.id).where(ProductLine.alias_of_id.is_(None), ProductLine.id.in_(with_product_variants))))
    return set(db.scalars(select(ProductVariant.id).where(ProductVariant.merged_into_id.is_(None))))


def generate(db: Session) -> dict[str, int]:
    """Every rule's suggestions as suggestion rows: a new pair is inserted, an undecided pair
    has its score and reason refreshed, a decided pair is left alone (a rejection never
    resurfaces), and an undecided pair one of whose sides has since been merged or aliased
    is closed as superseded by the algorithm. Safe to repeat."""
    brand_facts, line_facts, product_facts = _facts(db)
    wanted: list[SuggestedPair] = brand_pairs(brand_facts) + line_pairs(line_facts) + unknown_attribute_pairs(product_facts)
    existing = {(c.level, c.left_id, c.right_id): c for c in db.scalars(select(Suggestion)) if c.left_id and c.right_id}
    counts = {"brand": 0, "line": 0, "product": 0, "refreshed": 0, "superseded": 0, "withdrawn": 0}
    counts["reopened"] = 0
    for s in wanted:
        row = existing.get((s.level, s.left_id, s.right_id))
        if row is None:
            # A savepoint per row: the writer mints a `decided` pair for the same sides when a
            # person rules on a pair no rule offered, and the two run concurrently (spec §5).
            savepoint = db.begin_nested()
            try:
                row = Suggestion(level=s.level, left_id=s.left_id, right_id=s.right_id,
                                 reason=s.reason, score=s.score, detail={**s.detail, "why": s.why})
                db.add(row)
                savepoint.commit()
                existing[(s.level, s.left_id, s.right_id)] = row
                counts[s.level] += 1
            except IntegrityError:
                savepoint.rollback()
                row = db.scalar(select(Suggestion).where(Suggestion.level == s.level, Suggestion.left_id == s.left_id,
                                                         Suggestion.right_id == s.right_id))
                if row is not None:
                    existing[(s.level, s.left_id, s.right_id)] = row
        elif row.decision is None:
            merged = {**(row.detail or {}), **s.detail, "why": s.why}
            if row.closed_reason is not None:
                row.closed_reason = None  # the rule offers it again
                counts["reopened"] += 1
            if float(row.score or 0) != s.score or row.reason != s.reason or merged != (row.detail or {}):
                row.score, row.reason, row.detail = s.score, s.reason, merged
                counts["refreshed"] += 1
    alive = {level: _pending_sides_alive(db, level) for level in LEVELS}
    produced = {(s.level, s.left_id, s.right_id) for s in wanted}
    for (level, left, right), row in existing.items():
        if row.decision is not None or row.closed_reason is not None:
            continue  # a person's ruling stands; a closed pair stays closed until a rule offers it
        if left not in alive[level] or right not in alive[level]:
            row.closed_reason = "superseded"  # the algorithm's closure, never a decision (spec §5)
            counts["superseded"] += 1
        elif row.reason in RULE_REASONS and (level, left, right) not in produced:
            # A rule that no longer offers the pair withdraws it; the pairs the merge rules
            # queued (two barcodes) are theirs to keep until a person decides.
            row.closed_reason = "withdrawn"
            counts["withdrawn"] += 1
    db.flush()
    return counts
