"""Pure association discovery rules (channel-agnostic).

Related  — same collection + same category_l2 (substitutes)
Upsell   — same collection + finishing sibling categories
           (Wall/Base/Tall Cabinets → Accessories / Panels and Fillers / Mouldings)
Cross-sell — same collection + complementary structural categories
           (Base ↔ Wall ↔ Tall; finishing ↔ structural)
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Protocol, Sequence, Set

LINK_RELATED = "related"
LINK_UPSELL = "upsell"
LINK_CROSSSELL = "crosssell"
LINK_TYPES = (LINK_RELATED, LINK_UPSELL, LINK_CROSSSELL)

ORIGIN_AUTO = "auto"
ORIGIN_MANUAL = "manual"

DEFAULT_CAP = 5
MIN_CAP = 4
MAX_CAP = 6

STRUCTURAL_CATEGORIES = frozenset(
    {
        "Base Cabinets",
        "Wall Cabinets",
        "Tall Cabinets",
    }
)
FINISHING_CATEGORIES = frozenset(
    {
        "Accessories",
        "Panels and Fillers",
        "Mouldings",
    }
)

# Normalized label aliases → canonical shopping label.
_CATEGORY_ALIASES = {
    "base": "Base Cabinets",
    "base cabinets": "Base Cabinets",
    "wall": "Wall Cabinets",
    "wall cabinets": "Wall Cabinets",
    "tall": "Tall Cabinets",
    "tall cabinets": "Tall Cabinets",
    "panels": "Panels and Fillers",
    "panel": "Panels and Fillers",
    "panels and fillers": "Panels and Fillers",
    "fillers": "Panels and Fillers",
    "moulding": "Mouldings",
    "mouldings": "Mouldings",
    "molding": "Mouldings",
    "moldings": "Mouldings",
    "accessory": "Accessories",
    "accessories": "Accessories",
    "storage accessories": "Accessories",
}

UPSELL_TARGETS: Dict[str, frozenset[str]] = {
    "Base Cabinets": FINISHING_CATEGORIES,
    "Wall Cabinets": FINISHING_CATEGORIES,
    "Tall Cabinets": FINISHING_CATEGORIES,
    "Accessories": frozenset({"Panels and Fillers", "Mouldings"}),
    "Panels and Fillers": frozenset({"Accessories", "Mouldings"}),
    "Mouldings": frozenset({"Accessories", "Panels and Fillers"}),
}

CROSSSELL_TARGETS: Dict[str, frozenset[str]] = {
    "Base Cabinets": frozenset({"Wall Cabinets", "Tall Cabinets"}),
    "Wall Cabinets": frozenset({"Base Cabinets", "Tall Cabinets"}),
    "Tall Cabinets": frozenset({"Base Cabinets", "Wall Cabinets"}),
    "Accessories": STRUCTURAL_CATEGORIES,
    "Panels and Fillers": STRUCTURAL_CATEGORIES,
    "Mouldings": STRUCTURAL_CATEGORIES,
}


@dataclass(frozen=True)
class ProductSnapshot:
    sku: str
    collection: str
    category_l2: str
    product_family: str = ""
    name: str = ""
    price: Optional[float] = None


class AssociationRulePort(Protocol):
    """Port for association candidate selection (dependency inversion)."""

    def related_targets(
        self, source: ProductSnapshot, catalog: Sequence[ProductSnapshot]
    ) -> List[str]: ...

    def upsell_targets(
        self, source: ProductSnapshot, catalog: Sequence[ProductSnapshot]
    ) -> List[str]: ...

    def crosssell_targets(
        self, source: ProductSnapshot, catalog: Sequence[ProductSnapshot]
    ) -> List[str]: ...


def clamp_cap(cap: Optional[int]) -> int:
    value = DEFAULT_CAP if cap is None else int(cap)
    return max(MIN_CAP, min(MAX_CAP, value))


def normalize_category_l2(value: Optional[str]) -> str:
    raw = " ".join(str(value or "").strip().split())
    if not raw:
        return ""
    return _CATEGORY_ALIASES.get(raw.lower(), raw)


def normalize_collection(value: Optional[str]) -> str:
    return " ".join(str(value or "").strip().lower().split())


class CatalogIndex:
    """O(1) lookup of products by (collection, category_l2) for association discovery."""

    def __init__(self, catalog: Sequence[ProductSnapshot]) -> None:
        self.products: List[ProductSnapshot] = list(catalog)
        self.by_sku: Dict[str, ProductSnapshot] = {p.sku: p for p in self.products}
        self._by_coll_cat: Dict[tuple[str, str], List[ProductSnapshot]] = {}
        for product in self.products:
            key = (
                normalize_collection(product.collection),
                normalize_category_l2(product.category_l2),
            )
            if not key[0] or not key[1]:
                continue
            self._by_coll_cat.setdefault(key, []).append(product)

    def in_group(self, collection: str, category: str) -> List[ProductSnapshot]:
        key = (normalize_collection(collection), normalize_category_l2(category))
        if not key[0] or not key[1]:
            return []
        return list(self._by_coll_cat.get(key) or [])

    def in_groups(self, collection: str, categories: Iterable[str]) -> List[ProductSnapshot]:
        out: List[ProductSnapshot] = []
        seen: Set[str] = set()
        for category in categories:
            for product in self.in_group(collection, category):
                if product.sku in seen:
                    continue
                seen.add(product.sku)
                out.append(product)
        return out


def build_catalog_index(catalog: Sequence[ProductSnapshot]) -> CatalogIndex:
    return CatalogIndex(catalog)


class KitchenCabinetAssociationRules:
    """Default merchandising rules for kitchen cabinet catalogs."""

    def __init__(self, *, cap: int = DEFAULT_CAP) -> None:
        self.cap = clamp_cap(cap)

    def related_targets(
        self,
        source: ProductSnapshot,
        catalog: Sequence[ProductSnapshot] | CatalogIndex,
    ) -> List[str]:
        source_collection = normalize_collection(source.collection)
        source_category = normalize_category_l2(source.category_l2)
        if not source_collection or not source_category:
            return []
        index = catalog if isinstance(catalog, CatalogIndex) else CatalogIndex(catalog)
        candidates = [
            p for p in index.in_group(source_collection, source_category) if p.sku != source.sku
        ]
        return _rank_and_cap(candidates, self.cap)

    def upsell_targets(
        self,
        source: ProductSnapshot,
        catalog: Sequence[ProductSnapshot] | CatalogIndex,
    ) -> List[str]:
        source_collection = normalize_collection(source.collection)
        source_category = normalize_category_l2(source.category_l2)
        allowed = UPSELL_TARGETS.get(source_category)
        if not source_collection or not allowed:
            return []
        index = catalog if isinstance(catalog, CatalogIndex) else CatalogIndex(catalog)
        candidates = [
            p for p in index.in_groups(source_collection, allowed) if p.sku != source.sku
        ]
        return _rank_and_cap(candidates, self.cap)

    def crosssell_targets(
        self,
        source: ProductSnapshot,
        catalog: Sequence[ProductSnapshot] | CatalogIndex,
    ) -> List[str]:
        source_collection = normalize_collection(source.collection)
        source_category = normalize_category_l2(source.category_l2)
        allowed = CROSSSELL_TARGETS.get(source_category)
        if not source_collection or not allowed:
            return []
        index = catalog if isinstance(catalog, CatalogIndex) else CatalogIndex(catalog)
        candidates = [
            p for p in index.in_groups(source_collection, allowed) if p.sku != source.sku
        ]
        return _rank_and_cap(candidates, self.cap)

    def discover_for_source(
        self,
        source: ProductSnapshot,
        catalog: Sequence[ProductSnapshot] | CatalogIndex,
    ) -> Dict[str, List[str]]:
        return {
            LINK_RELATED: self.related_targets(source, catalog),
            LINK_UPSELL: self.upsell_targets(source, catalog),
            LINK_CROSSSELL: self.crosssell_targets(source, catalog),
        }

    def reverse_link_types(
        self, source: ProductSnapshot, peer: ProductSnapshot
    ) -> List[str]:
        """Which link types should *peer* gain with *source* as the target."""
        types: List[str] = []
        discovered = self.discover_for_source(peer, [source])
        for link_type, targets in discovered.items():
            if source.sku in targets:
                types.append(link_type)
        return types

    def reverse_peers_for_source(
        self,
        source: ProductSnapshot,
        index: CatalogIndex,
    ) -> Dict[str, List[str]]:
        """Map link_type → peer SKUs that should list *source* as a target.

        Uses collection/category indexes instead of scanning the full catalog.
        """
        source_collection = normalize_collection(source.collection)
        source_category = normalize_category_l2(source.category_l2)
        if not source_collection or not source_category:
            return {link_type: [] for link_type in LINK_TYPES}

        out: Dict[str, List[str]] = {link_type: [] for link_type in LINK_TYPES}

        # Related is symmetric within the same collection + category.
        related_peers = [
            p.sku
            for p in index.in_group(source_collection, source_category)
            if p.sku != source.sku
        ]
        out[LINK_RELATED] = related_peers

        # Upsell reverse: peers whose upsell target set includes source category.
        for peer_category, targets in UPSELL_TARGETS.items():
            if source_category not in targets:
                continue
            for peer in index.in_group(source_collection, peer_category):
                if peer.sku != source.sku:
                    out[LINK_UPSELL].append(peer.sku)

        # Cross-sell reverse: peers whose cross-sell target set includes source category.
        for peer_category, targets in CROSSSELL_TARGETS.items():
            if source_category not in targets:
                continue
            for peer in index.in_group(source_collection, peer_category):
                if peer.sku != source.sku:
                    out[LINK_CROSSSELL].append(peer.sku)

        return out


def _rank_and_cap(candidates: Sequence[ProductSnapshot], cap: int) -> List[str]:
    ranked = sorted(
        candidates,
        key=lambda p: (
            -(p.price if p.price is not None else -1.0),
            (p.name or "").lower(),
            p.sku,
        ),
    )
    return [p.sku for p in ranked[:cap]]


def parse_sku_list(value: object) -> List[str]:
    """Parse manual override CSV cell (comma/pipe/semicolon separated)."""
    if value is None:
        return []
    if isinstance(value, (list, tuple, set)):
        raw_parts = [str(v) for v in value]
    else:
        text = str(value).strip()
        if not text:
            return []
        for sep in ("|", ";", "\n"):
            text = text.replace(sep, ",")
        raw_parts = text.split(",")
    seen: Set[str] = set()
    out: List[str] = []
    for part in raw_parts:
        sku = str(part).strip()
        if not sku or sku in seen:
            continue
        seen.add(sku)
        out.append(sku)
    return out


MANUAL_OVERRIDE_COLUMNS: Dict[str, tuple[str, ...]] = {
    LINK_RELATED: (
        "related_skus",
        "related_products",
        "related",
    ),
    LINK_UPSELL: (
        "upsell_skus",
        "upsell_products",
        "upsell",
        "up_sell_skus",
    ),
    LINK_CROSSSELL: (
        "crosssell_skus",
        "cross_sell_skus",
        "crosssell_products",
        "cross_sell_products",
        "crosssell",
        "cross_sell",
    ),
}


def extract_manual_overrides_from_payload(payload: Dict[str, object]) -> Dict[str, List[str]]:
    from db.source_imports import normalize_column_key

    normalized = {normalize_column_key(k): v for k, v in (payload or {}).items()}
    out: Dict[str, List[str]] = {}
    for link_type, columns in MANUAL_OVERRIDE_COLUMNS.items():
        for column in columns:
            if column in normalized and str(normalized[column] or "").strip():
                out[link_type] = parse_sku_list(normalized[column])[:MAX_CAP]
                break
    return out


def magento_link_type(link_type: str) -> str:
    """Map canonical link type to Magento product link_type code."""
    if link_type == LINK_CROSSSELL:
        return "crosssell"
    if link_type == LINK_UPSELL:
        return "upsell"
    return "related"


def shopify_discovery_buckets(
    associations: Dict[str, Iterable[str]],
) -> Dict[str, List[str]]:
    """Collapse Magento-style links into Shopify Discovery buckets.

    related   → related_products
    upsell    → complementary_products (finishing / add-on merchandising)
    crosssell → complementary_products
    """
    related = list(associations.get(LINK_RELATED) or [])
    complementary: List[str] = []
    seen = set(related)
    for link_type in (LINK_UPSELL, LINK_CROSSSELL):
        for sku in associations.get(link_type) or []:
            if sku in seen:
                continue
            seen.add(sku)
            complementary.append(sku)
    return {
        "related_products": related[:MAX_CAP],
        "complementary_products": complementary[:MAX_CAP],
    }
