#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""跨電商商品身份比對工具。

這裡處理「是否為同一個商品」；價格只作為 sanity check，不能主導配對。
"""

from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Iterable, Optional


NOISE_PHRASES = (
    "momo",
    "pchome",
    "24h",
    "官方直營",
    "官方",
    "公司貨",
    "台灣公司貨",
    "專櫃公司貨",
    "正貨",
    "原廠",
    "限時",
    "特惠",
    "優惠",
    "超值",
    "加贈",
    "贈品",
    "送禮",
    "送",
    "買一送一",
    "買1送1",
    "任選",
    "即期品",
    "福利品",
    "預購",
    "免運",
    "熱銷",
    "人氣",
    "必買",
    "推薦",
    "新品",
    "升級版",
    "經典",
    "獨家",
    "囤貨組",
    "超值組",
    "優惠組",
    "分享包",
    "組合",
)

GENERIC_TOKENS = {
    "官方",
    "直營",
    "公司貨",
    "專櫃",
    "正貨",
    "原廠",
    "限時",
    "特惠",
    "優惠",
    "超值",
    "加贈",
    "贈品",
    "送禮",
    "即期品",
    "新品",
    "升級版",
    "經典",
    "人氣",
    "熱銷",
    "必買",
    "推薦",
    "組",
    "入",
    "瓶",
    "盒",
    "包",
    "片",
    "支",
    "條",
    "ml",
    "g",
    "la",
    "paris",
}

PRODUCT_TYPES = {
    "精華": ("精華", "精華液", "essence", "serum", "安瓶"),
    "化妝水": ("化妝水", "機能水", "toner", "lotion"),
    "乳液": ("乳液", "emulsion", "milk"),
    "面霜": ("面霜", "乳霜", "霜", "cream"),
    "防曬": ("防曬", "spf", "uv", "sunscreen"),
    "洗面乳": ("洗面乳", "洗顏", "潔面", "cleanser", "foam"),
    "面膜": ("面膜", "mask"),
    "眼霜": ("眼霜", "眼部", "眼膜", "eye"),
    "卸妝": ("卸妝", "cleansing", "remover"),
    "粉底": ("粉底", "粉霜", "粉凝露", "foundation"),
    "蜜粉": ("蜜粉", "powder"),
    "精油": ("精油", "香氛", "擴香"),
    "保健": ("錠", "膠囊", "粉", "飲", "包", "健康食品"),
}

COUNT_UNITS = {"入", "組", "瓶", "支", "條", "盒", "包", "片", "顆", "錠", "枚"}
PIECE_UNITS = {"包", "片", "顆", "錠", "枚"}
CONTAINER_UNITS = {"入", "組", "盒"}
CHINESE_COUNT = {
    "一": 1,
    "二": 2,
    "兩": 2,
    "雙": 2,
    "三": 3,
    "四": 4,
    "五": 5,
    "六": 6,
    "七": 7,
    "八": 8,
    "九": 9,
    "十": 10,
}


@dataclass(frozen=True)
class ProductIdentity:
    original_name: str
    normalized_name: str
    searchable_name: str
    brand_tokens: frozenset[str]
    product_type: Optional[str]
    tokens: frozenset[str]
    core_tokens: frozenset[str]
    volumes_ml: tuple[float, ...]
    weights_g: tuple[float, ...]
    counts: tuple[tuple[int, str], ...]
    total_piece_count: Optional[int]


@dataclass(frozen=True)
class MatchDiagnostics:
    score: float
    brand_score: float
    token_score: float
    spec_score: float
    sequence_score: float
    type_score: float
    price_penalty: float
    hard_veto: bool
    reasons: tuple[str, ...]

    @property
    def tags(self) -> list[str]:
        tags: list[str] = ["identity_v2"]
        if self.brand_score >= 0.95:
            tags.append("brand_match")
        if self.spec_score >= 0.85:
            tags.append("spec_match")
        if self.hard_veto:
            tags.append("identity_veto")
        return tags


def normalize_product_text(value: str) -> str:
    text = unicodedata.normalize("NFKC", value or "")
    text = "".join(
        char for char in unicodedata.normalize("NFKD", text)
        if not unicodedata.combining(char)
    )
    text = text.replace("×", "x").replace("＊", "x").replace("*", "x")
    text = text.replace("／", "/").replace("＆", "&")
    text = re.sub(r"[\u3000\r\n\t]+", " ", text)
    text = text.lower()
    text = re.sub(r"[?？]+", " ", text)
    text = re.sub(r"[【】\[\]{}「」『』]", " ", text)
    text = re.sub(r"[()（）]", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _strip_noise(value: str) -> str:
    text = value
    for phrase in sorted(NOISE_PHRASES, key=len, reverse=True):
        text = text.replace(phrase.lower(), " ")
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _tokenize(value: str) -> list[str]:
    raw_tokens = re.findall(r"[a-z0-9]+|[\u4e00-\u9fff]+", value)
    tokens: list[str] = []
    for token in raw_tokens:
        if len(token) <= 1 and not token.isdigit():
            continue
        tokens.append(token)
    return tokens


def _known_brand_tokens(text: str) -> set[str]:
    tokens: set[str] = set()
    try:
        from services.price_comparison import BRAND_ALIASES, BRAND_NORMALIZE_MAP
    except Exception:
        BRAND_ALIASES = {}
        BRAND_NORMALIZE_MAP = {}

    for alias, canonical in BRAND_NORMALIZE_MAP.items():
        alias_norm = normalize_product_text(alias)
        if alias_norm and alias_norm in text:
            tokens.add(canonical)
            tokens.update(
                token for token in _tokenize(alias_norm)
                if not re.fullmatch(r"[a-z]{1,2}", token)
            )
            for related in BRAND_ALIASES.get(canonical, []):
                tokens.update(
                    token for token in _tokenize(normalize_product_text(related))
                    if not re.fullmatch(r"[a-z]{1,2}", token)
                )

    return {token for token in tokens if token and token not in GENERIC_TOKENS}


def _leading_brand_tokens(original: str, normalized: str) -> set[str]:
    tokens: set[str] = set()
    bracket_match = re.match(r"\s*[【\[]([^】\]]{2,40})[】\]]", original or "")
    if bracket_match:
        content = normalize_product_text(bracket_match.group(1))
        for token in _tokenize(_strip_noise(content)):
            if token not in GENERIC_TOKENS:
                tokens.add(token)

    leading = normalized[:48]
    for token in _tokenize(leading):
        if re.fullmatch(r"[a-z][a-z0-9\-']{2,}", token):
            tokens.add(token)
    return tokens


def _extract_product_type(text: str) -> Optional[str]:
    for product_type, aliases in PRODUCT_TYPES.items():
        if any(alias.lower() in text for alias in aliases):
            return product_type
    return None


def _convert_volume(value: str, unit: str) -> Optional[tuple[str, float]]:
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    unit = unit.lower()
    if unit in {"ml", "毫升"}:
        return ("ml", number)
    if unit == "l":
        return ("ml", number * 1000)
    if unit in {"g", "公克"}:
        return ("g", number)
    if unit == "kg":
        return ("g", number * 1000)
    return None


def _extract_specs(text: str) -> tuple[tuple[float, ...], tuple[float, ...], tuple[tuple[int, str], ...], Optional[int]]:
    volumes_ml: list[float] = []
    weights_g: list[float] = []
    for match in re.finditer(r"(\d+(?:\.\d+)?)\s*(ml|毫升|l|g|公克|kg)", text, re.I):
        converted = _convert_volume(match.group(1), match.group(2))
        if not converted:
            continue
        unit, number = converted
        if unit == "ml":
            volumes_ml.append(number)
        else:
            weights_g.append(number)

    counts: list[tuple[int, str]] = []
    for match in re.finditer(r"(\d+)\s*([入組瓶支條盒包片顆錠枚])", text):
        counts.append((int(match.group(1)), match.group(2)))
    for match in re.finditer(r"([一二兩雙三四五六七八九十])\s*([入組瓶支條盒包片顆錠枚])", text):
        counts.append((CHINESE_COUNT[match.group(1)], match.group(2)))
    for match in re.finditer(r"(?:x|乘)\s*(\d+)\s*([入組瓶支條盒包片顆錠枚])?", text, re.I):
        unit = match.group(2) or "入"
        counts.append((int(match.group(1)), unit))

    total_piece_count = None
    explicit_total = re.search(r"共\s*(\d+)\s*([包片顆錠枚])", text)
    if explicit_total:
        total_piece_count = int(explicit_total.group(1))
    else:
        piece_counts = [count for count, unit in counts if unit in PIECE_UNITS]
        container_counts = [count for count, unit in counts if unit in CONTAINER_UNITS]
        if piece_counts and container_counts:
            total_piece_count = max(piece_counts) * max(container_counts)
        elif piece_counts:
            total_piece_count = max(piece_counts)

    unique_counts = tuple(sorted(set(counts)))
    return (
        tuple(sorted(set(volumes_ml))),
        tuple(sorted(set(weights_g))),
        unique_counts,
        total_piece_count,
    )


def parse_product_identity(name: str) -> ProductIdentity:
    normalized = normalize_product_text(name)
    searchable = _strip_noise(normalized)
    tokens = set(_tokenize(searchable))
    product_type = _extract_product_type(searchable)
    brand_tokens = _known_brand_tokens(searchable) | _leading_brand_tokens(name, normalized)

    core_tokens = {
        token
        for token in tokens
        if token not in GENERIC_TOKENS
        and not token.isdigit()
        and not re.fullmatch(r"\d+(ml|g|kg|l)?", token)
    }
    core_tokens -= brand_tokens

    volumes_ml, weights_g, counts, total_piece_count = _extract_specs(searchable)
    return ProductIdentity(
        original_name=name or "",
        normalized_name=normalized,
        searchable_name=searchable,
        brand_tokens=frozenset(brand_tokens),
        product_type=product_type,
        tokens=frozenset(tokens),
        core_tokens=frozenset(core_tokens),
        volumes_ml=volumes_ml,
        weights_g=weights_g,
        counts=counts,
        total_piece_count=total_piece_count,
    )


def _weighted_token_score(left: ProductIdentity, right: ProductIdentity) -> float:
    left_tokens = left.brand_tokens | left.core_tokens
    right_tokens = right.brand_tokens | right.core_tokens
    if not left_tokens or not right_tokens:
        return SequenceMatcher(None, left.searchable_name, right.searchable_name).ratio() * 0.6

    def weight(token: str) -> float:
        if token in left.brand_tokens or token in right.brand_tokens:
            return 1.4
        if re.search(r"\d", token):
            return 1.2
        if len(token) >= 4:
            return 1.25
        return 1.0

    overlap = left_tokens & right_tokens
    overlap_weight = sum(weight(token) for token in overlap)
    total_weight = sum(weight(token) for token in left_tokens) + sum(weight(token) for token in right_tokens)
    dice = (2 * overlap_weight / total_weight) if total_weight else 0
    sequence = SequenceMatcher(None, " ".join(sorted(left_tokens)), " ".join(sorted(right_tokens))).ratio()
    return min(1.0, dice * 0.72 + sequence * 0.28)


def _brand_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool, str | None]:
    if not left.brand_tokens or not right.brand_tokens:
        return 0.55, False, None
    if left.brand_tokens & right.brand_tokens:
        return 1.0, False, None
    return 0.0, True, "brand_conflict"


def _close_number(left: float, right: float, tolerance: float = 0.04) -> bool:
    denominator = max(abs(left), abs(right), 1.0)
    return abs(left - right) / denominator <= tolerance


def _spec_component(left_values: Iterable[float], right_values: Iterable[float]) -> tuple[float, bool]:
    left_tuple = tuple(left_values)
    right_tuple = tuple(right_values)
    if not left_tuple and not right_tuple:
        return 0.55, False
    if not left_tuple or not right_tuple:
        return 0.45, False
    for left_value in left_tuple:
        if any(_close_number(left_value, right_value) for right_value in right_tuple):
            return 1.0, False
    return 0.0, True


def _count_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool]:
    left_counts = [count for count, _unit in left.counts]
    right_counts = [count for count, _unit in right.counts]
    if left.total_piece_count and right.total_piece_count:
        if left.total_piece_count == right.total_piece_count:
            return 1.0, False
        ratio = max(left.total_piece_count, right.total_piece_count) / max(min(left.total_piece_count, right.total_piece_count), 1)
        return (0.0, True) if ratio >= 1.5 else (0.45, False)
    if left.counts and right.counts:
        if set(left.counts) & set(right.counts):
            return 0.85, False
        if left_counts and right_counts:
            ratio = max(max(left_counts), max(right_counts)) / max(min(max(left_counts), max(right_counts)), 1)
            if ratio >= 1.5:
                return 0.0, True
        return 0.35, False
    if (left_counts and max(left_counts) > 1) or (right_counts and max(right_counts) > 1):
        return 0.0, True
    return 0.5, False


def _spec_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool, tuple[str, ...]]:
    volume_score, volume_conflict = _spec_component(left.volumes_ml, right.volumes_ml)
    weight_score, weight_conflict = _spec_component(left.weights_g, right.weights_g)
    count_score, count_conflict = _count_score(left, right)

    available = []
    if left.volumes_ml or right.volumes_ml:
        available.append(volume_score)
    if left.weights_g or right.weights_g:
        available.append(weight_score)
    if left.counts or right.counts:
        available.append(count_score)
    if not available:
        return 0.55, False, ()

    score = sum(available) / len(available)
    conflicts = []
    if volume_conflict:
        conflicts.append("volume_conflict")
    if weight_conflict:
        conflicts.append("weight_conflict")
    if count_conflict:
        conflicts.append("count_conflict")
    return score, bool(conflicts), tuple(conflicts)


def _has_bundle_offer(identity: ProductIdentity) -> bool:
    text = identity.normalized_name
    return bool(
        re.search(r"買\s*\d+\s*送\s*\d+", text)
        or re.search(r"買\s*[一二兩雙三四五六七八九十]\s*送\s*[一二兩雙三四五六七八九十]", text)
        or "買一送一" in text
        or "囤貨組" in text
        or "超值組" in text
        or "特惠組" in text
        or "優惠套組" in text
        or "禮盒組" in text
        or "加大組" in text
        or "套組" in text
    )


def _has_multi_component(identity: ProductIdentity) -> bool:
    text = identity.normalized_name
    return bool(
        "+" in text
        or "＋" in text
        or re.search(r"\d+\s*(?:ml|g|毫升|公克)\s*x\s*\d+", text, re.I)
    )


def _spec_mention_count(identity: ProductIdentity) -> int:
    return len(re.findall(r"\d+(?:\.\d+)?\s*(?:ml|毫升|l|g|公克|kg)", identity.normalized_name, re.I))


def _chinese_bigram_score(left: ProductIdentity, right: ProductIdentity) -> float:
    def signature(identity: ProductIdentity) -> set[str]:
        text = identity.searchable_name
        for token in sorted(identity.brand_tokens, key=len, reverse=True):
            text = text.replace(token, " ")
        text = re.sub(r"[a-z0-9]+", " ", text)
        text = "".join(char for char in text if "\u4e00" <= char <= "\u9fff")
        for phrase in (
            "官方", "直營", "公司貨", "專櫃", "正貨", "原廠", "限定", "獨家",
            "期間", "超值", "特惠", "優惠", "新品", "經典", "人氣", "熱銷",
            "必買", "推薦", "任選", "禮盒", "母親節", "超品日", "多款",
            "組", "入", "瓶", "盒", "包", "片", "支", "條",
        ):
            text = text.replace(phrase, "")
        return {text[i:i + 2] for i in range(max(0, len(text) - 1))}

    left_signature = signature(left)
    right_signature = signature(right)
    if not left_signature or not right_signature:
        return 0.55
    return 2 * len(left_signature & right_signature) / (len(left_signature) + len(right_signature))


def score_marketplace_match(
    momo_name: str,
    competitor_name: str,
    momo_price: Optional[float] = None,
    competitor_price: Optional[float] = None,
) -> MatchDiagnostics:
    left = parse_product_identity(momo_name)
    right = parse_product_identity(competitor_name)

    brand_score, brand_conflict, brand_reason = _brand_score(left, right)
    token_score = _weighted_token_score(left, right)
    spec_score, spec_conflict, spec_reasons = _spec_score(left, right)
    sequence_score = SequenceMatcher(None, left.searchable_name, right.searchable_name).ratio()
    chinese_name_score = _chinese_bigram_score(left, right)
    if left.product_type and right.product_type:
        type_score = 1.0 if left.product_type == right.product_type else 0.0
    else:
        type_score = 0.55

    reasons = []
    if brand_reason:
        reasons.append(brand_reason)
    reasons.extend(spec_reasons)
    if left.product_type and right.product_type and left.product_type != right.product_type:
        reasons.append("type_conflict")
    bundle_offer_conflict = (
        _has_bundle_offer(left) != _has_bundle_offer(right)
        and not (
            left.total_piece_count
            and right.total_piece_count
            and left.total_piece_count == right.total_piece_count
        )
    )
    if bundle_offer_conflict:
        reasons.append("bundle_offer_conflict")
    if _has_multi_component(left) != _has_multi_component(right):
        reasons.append("multi_component_conflict")
    left_spec_mentions = _spec_mention_count(left)
    right_spec_mentions = _spec_mention_count(right)
    if left_spec_mentions and right_spec_mentions and left_spec_mentions != right_spec_mentions:
        reasons.append("component_count_conflict")
    if chinese_name_score < 0.16:
        reasons.append("product_line_conflict")

    hard_veto = brand_conflict or spec_conflict
    if bundle_offer_conflict:
        hard_veto = True
    if _has_multi_component(left) != _has_multi_component(right):
        hard_veto = True
    if left_spec_mentions and right_spec_mentions and left_spec_mentions != right_spec_mentions:
        hard_veto = True
    if chinese_name_score < 0.16 and token_score < 0.72:
        hard_veto = True
    if left.product_type and right.product_type and left.product_type != right.product_type and token_score < 0.55:
        hard_veto = True

    price_penalty = 0.0
    try:
        if momo_price and competitor_price:
            ratio = float(competitor_price) / max(float(momo_price), 1.0)
            if (ratio < 0.3 or ratio > 3.2) and token_score < 0.78:
                price_penalty = 0.12
                reasons.append("price_ratio_extreme")
            elif (ratio < 0.48 or ratio > 2.2) and token_score < 0.68:
                price_penalty = 0.06
                reasons.append("price_ratio_wide")
    except (TypeError, ValueError, ZeroDivisionError):
        price_penalty = 0.0

    score = (
        brand_score * 0.20
        + token_score * 0.36
        + spec_score * 0.25
        + sequence_score * 0.12
        + type_score * 0.07
        - price_penalty
    )

    if token_score >= 0.72 and spec_score >= 0.82 and not brand_conflict:
        score += 0.08
    if hard_veto:
        score = min(score, 0.32)
    score = max(0.0, min(1.0, score))

    return MatchDiagnostics(
        score=round(score, 3),
        brand_score=round(brand_score, 3),
        token_score=round(token_score, 3),
        spec_score=round(spec_score, 3),
        sequence_score=round(sequence_score, 3),
        type_score=round(type_score, 3),
        price_penalty=round(price_penalty, 3),
        hard_veto=hard_veto,
        reasons=tuple(reasons),
    )


def build_search_terms(name: str, max_terms: int = 3) -> list[str]:
    identity = parse_product_identity(name)
    terms: list[str] = []

    brand_part = " ".join(sorted(identity.brand_tokens))[:24]
    core = " ".join(sorted(identity.core_tokens, key=lambda token: (-len(token), token))[:4])
    specs = []
    if identity.volumes_ml:
        specs.append(f"{int(identity.volumes_ml[0])}ml")
    if identity.weights_g:
        specs.append(f"{int(identity.weights_g[0])}g")
    if identity.total_piece_count:
        specs.append(f"{identity.total_piece_count}包")

    for value in (
        " ".join(part for part in (brand_part, core, " ".join(specs)) if part),
        " ".join(part for part in (brand_part, core) if part),
        identity.searchable_name,
    ):
        cleaned = re.sub(r"[^\w\u4e00-\u9fff]+", " ", value)
        cleaned = re.sub(r"\s+", " ", cleaned).strip()
        if cleaned and cleaned not in terms:
            terms.append(cleaned[:42])
        if len(terms) >= max_terms:
            break

    return terms
