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

Sources of truth: this module, `models/catalog.py` (MergeCandidate 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
`merge_candidates` 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:
* brand: a known rebrand (Paco Rabanne became Rabanne in 2023); one house'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 house already fold to one row, so a brand pair
  here is two rows a person must join.
* line: under one house, 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 house'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 variation and the other does:
  CDG's "1 Million 10cl" beside the 100 ml Eau de Toilette at four airports. Where only
  one known variation 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.orm import Session

from app.models import Brand, Listing, MergeCandidate, Product, ProductLine
from app.services import lines as lines_service

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",
    "duplicate_match_key": "they share a key",
}


@dataclass
class Suggestion:
    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]
    products: int
    airports: int


@dataclass
class LineFacts:
    id: int
    brand_id: int
    key: str
    name: str
    products: int
    airports: int
    #: The sizes its products 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 products belong to; only drinks and beauty lines are offered.
    vertical: str = "liquor"
    #: The category most of its products belong to; makeup is never offered.
    category: str = ""


@dataclass
class ProductFacts:
    id: int
    line_id: int | None
    size_ml: int | None
    variation: str
    form: str
    name: str
    airports: int
    gtin: str | 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[Suggestion]:
    """Pairs of houses 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[Suggestion] = []

    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(Suggestion(
            "brand", a.id, b.id, score, reason, why,
            {"left": {"id": a.id, "name": a.name, "products": a.products, "airports": a.airports},
             "right": {"id": b.id, "name": b.name, "products": b.products, "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 house")

    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[Suggestion]:
    """Pairs of lines under one house that may be one."""
    by_house: 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_house[line.brand_id].append(line)
    out: list[Suggestion] = []
    for house_lines in by_house.values():
        if len(house_lines) < 2:
            continue
        for a, b in combinations(house_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, "products": a.products, "airports": a.airports},
                      "right": {"id": b.id, "name": b.name, "products": b.products, "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(Suggestion("line", a.id, b.id, 0.7, "namesake_vs_original",
                                          f"The house'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(Suggestion("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(Suggestion("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


# --------------------------------------------------------------------------- products

def unknown_variation_pairs(products: list[ProductFacts]) -> list[Suggestion]:
    """The same line at the same size where one side names no variation and the other does."""
    groups: dict[tuple[int, int, str], list[ProductFacts]] = defaultdict(list)
    for p in products:
        if p.line_id and p.size_ml:
            groups[(p.line_id, p.size_ml, p.form)].append(p)
    out: list[Suggestion] = []
    for members in groups.values():
        bare = [p for p in members if not p.variation]
        known = [p for p in members if p.variation]
        if not bare or not known:
            continue
        variations = {p.variation for p in known}
        score = 0.6 if len(variations) == 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(Suggestion(
                    "product", b.id, k.id, score, "variation_unknown",
                    f"Same line and size; \"{b.name}\" names no variation and the other is "
                    f"{lines_service.display_variation(k.variation)}"
                    + ("" if len(variations) == 1 else f", one of {len(variations)} variations at this size"),
                    {"bare": {"id": b.id, "name": b.name, "airports": b.airports},
                     "known": {"id": k.id, "name": k.name, "airports": k.airports, "variation": k.variation},
                     "variations_at_size": sorted(variations)},
                ))
    return out


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 house, line, variation and size, but " + " and ".join(parts) if parts else "Same key"


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

def _facts(db: Session) -> tuple[list[BrandFacts], list[LineFacts], list[ProductFacts]]:
    alive = Product.merged_into_id.is_(None)
    airports_of_product = dict(db.execute(
        select(Listing.product_id, func.count(func.distinct(Listing.location_id))).group_by(Listing.product_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))}
    products: list[ProductFacts] = []
    line_products: 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_products: 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(Product).where(alive)):
        line = lines_service.resolve_alias(lines_by_id, p.line_id) if p.line_id else None
        house = lines_service.resolve_alias(brands, p.brand_id) if p.brand_id else None
        n_airports = int(airports_of_product.get(p.id, 0))
        products.append(ProductFacts(
            id=p.id, line_id=line.id if line else None, size_ml=p.size_ml,
            variation=(p.attributes or {}).get("variation", "") or "", form=lines_service.form_of(p.name),
            name=p.name, airports=n_airports, gtin=p.gtin,
        ))
        if line is not None:
            line_products[line.id] += 1
            line_airports[line.id] = max(line_airports[line.id], n_airports)
            if p.size_ml:
                line_sizes[line.id].add(int(p.size_ml))
            line_verticals[line.id][p.vertical or ""] += 1
            line_categories[line.id][p.category or ""] += 1
        if house is not None:
            brand_products[house.id] += 1
            brand_airports[house.id] = max(brand_airports[house.id], n_airports)
            if line is not None:
                brand_lines[house.id].add(line.key)
    brand_facts = [
        BrandFacts(id=b.id, slug=b.slug, name=b.name, lines=frozenset(brand_lines.get(b.id, ())),
                   products=brand_products.get(b.id, 0), airports=brand_airports.get(b.id, 0))
        for b in brands.values() if not b.canonical_id
    ]
    line_facts = [
        LineFacts(id=row.id, brand_id=row.brand_id, key=row.key, name=row.name,
                  products=line_products.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.canonical_id and line_products.get(row.id, 0)
    ]
    return brand_facts, line_facts, products


def _pending_sides_alive(db: Session, level: str) -> set[int]:
    if level == "brand":
        return set(db.scalars(select(Brand.id).where(Brand.canonical_id.is_(None))))
    if level == "line":
        return set(db.scalars(select(ProductLine.id).where(ProductLine.canonical_id.is_(None))))
    return set(db.scalars(select(Product.id).where(Product.merged_into_id.is_(None))))


def generate(db: Session) -> dict[str, int]:
    """Every rule's suggestions as candidate 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[Suggestion] = brand_pairs(brand_facts) + line_pairs(line_facts) + unknown_variation_pairs(product_facts)
    existing = {(c.level, c.left_id, c.right_id): c for c in db.scalars(select(MergeCandidate)) if c.left_id and c.right_id}
    counts = {"brand": 0, "line": 0, "product": 0, "refreshed": 0, "superseded": 0, "withdrawn": 0, "legacy_filled": 0}
    # Product pairs the merge rules queued carry product_id/candidate_id from before there
    # were levels: give them their level columns and a readable reason, once.
    for c in db.scalars(select(MergeCandidate).where(MergeCandidate.left_id.is_(None))):
        if c.product_id and c.candidate_id:
            c.left_id, c.right_id = min(c.product_id, c.candidate_id), max(c.product_id, c.candidate_id)
            c.level = "product"
            detail = dict(c.detail or {})
            detail.setdefault("why", product_reason(c.reason))
            c.detail = detail
            existing[("product", c.left_id, c.right_id)] = c
            counts["legacy_filled"] += 1
    for s in wanted:
        row = existing.get((s.level, s.left_id, s.right_id))
        if row is None:
            db.add(MergeCandidate(
                level=s.level, left_id=s.left_id, right_id=s.right_id,
                product_id=s.left_id if s.level == "product" else None,
                candidate_id=s.right_id if s.level == "product" else None,
                reason=s.reason, score=s.score, detail={**s.detail, "why": s.why},
            ))
            counts[s.level] += 1
        elif row.decision is None:
            merged = {**(row.detail or {}), **s.detail, "why": s.why}
            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:
            continue
        if left not in alive[level] or right not in alive[level]:
            row.decision, row.decided_at = "superseded", datetime.now(UTC)
            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.decision, row.decided_at = "withdrawn", datetime.now(UTC)
            counts["withdrawn"] += 1
    db.flush()
    return counts
