"""
Canonical hash computation for incremental Plytix → Magento sync.

Hashes are deterministic sha256 hex. Used to decide whether to run
UPSERT_PRODUCT, UPSERT_MEDIA, or SET_RELATIONS.

DATA_HASH (compute_data_hash) — fields/attributes included:
  From row: name, description, short_description, category_ids
  From mapped_payload: status, visibility, price (if in payload)
  From extension_attributes.category_links: category IDs (when present)
  From custom_attributes: website_id, store_ids, store_id (if managed)
  From custom_attributes: all attributes EXCEPT:
    special_price, tier_price, tier_prices, url_key, category_ids,
    base_image, small_image, thumbnail_image, additional_images,
    created_at, updated_at, created_in, updated_in
  For children: axis labels from parent configurable_variations merged in
  Excludes: media fields (images_hash), relations (relations_hash), timestamps

IMAGES_HASH: Plytix image URLs + roles + position + label (canonical)
RELATIONS_HASH: children list + configurable attr codes + child→attr value mapping
"""

from __future__ import annotations

import hashlib
import json
import re
from typing import Any, Dict, List, Optional

from normalize import _is_empty, _relation_sku
from magento.configurable_requirements import resolve_magento_axis_code


def _ordered_unique_image_urls(row: Dict[str, Any]) -> List[str]:
    urls: List[str] = []
    seen: set[str] = set()
    base = row.get("base_image") or row.get("base_image_url") or ""
    addl_raw = row.get("additional_images") or row.get("additional_images_url") or ""

    for candidate in [base, *str(addl_raw).split(",")]:
        url = str(candidate or "").strip()
        if not url or url in seen:
            continue
        seen.add(url)
        urls.append(url)
    return urls


def _norm_option_label(label: str) -> str:
    """Normalize option label: lower, trim, collapse spaces. Used for consistent hashing."""
    s = str(label).strip().lower()
    return re.sub(r"\s+", " ", s) if s else ""


def _canonical_json(obj: Any) -> str:
    """Sort keys recursively, remove nulls/empty where consistent."""
    if obj is None:
        return "null"
    if isinstance(obj, bool):
        return "true" if obj else "false"
    if isinstance(obj, (int, float)):
        return json.dumps(obj)
    if isinstance(obj, str):
        return json.dumps(obj)
    if isinstance(obj, tuple):
        parts = [_canonical_json(x) for x in obj]
        return "[" + ",".join(parts) + "]"
    if isinstance(obj, list):
        parts = [_canonical_json(x) for x in obj]
        return "[" + ",".join(parts) + "]"
    if isinstance(obj, dict):
        items = []
        for k in sorted(obj.keys()):
            v = obj[k]
            if v is None:
                continue
            if isinstance(v, str) and not v.strip():
                continue
            if isinstance(v, (list, dict)) and not v:
                continue
            items.append(json.dumps(k) + ":" + _canonical_json(v))
        return "{" + ",".join(items) + "}"
    return json.dumps(obj, default=str)


# Attributes we intentionally exclude from data_hash (volatile, system-managed, or other hash)
# price is INCLUDED when in payload (we manage it); special_price/tier_prices excluded as volatile
_DATA_HASH_EXCLUDED_ATTRS = frozenset({
    "special_price", "tier_price", "tier_prices", "url_key",
    "category_ids",  # Hashed via category_links / extension_attributes
    "base_image", "small_image", "thumbnail_image", "additional_images",  # images_hash
    "created_at", "updated_at", "created_in", "updated_in",  # volatile timestamps
})


def _canonicalize_custom_attrs(attrs: List[Dict[str, Any]]) -> List[tuple[str, Any]]:
    """
    Canonicalize custom_attributes for hashing: sort by attribute_code,
    stringify values consistently, exclude volatile/system attrs.
    Returns sorted list of (code, canonical_value).
    """
    pairs: List[tuple[str, Any]] = []
    for a in attrs or []:
        if not isinstance(a, dict):
            continue
        code = str(a.get("attribute_code", "")).strip().lower()
        if not code or code in _DATA_HASH_EXCLUDED_ATTRS:
            continue
        val = a.get("value")
        if val is None:
            continue
        if isinstance(val, str):
            s = val.strip()
            if s == "" or s.lower() in ("nan", "none", "null"):
                continue
            canonical = s
        elif isinstance(val, list):
            if not val:
                continue
            canonical = sorted(str(x).strip() for x in val if str(x).strip())
        elif isinstance(val, (int, float, bool)):
            canonical = val
        else:
            canonical = str(val)
        pairs.append((code, canonical))
    return sorted(pairs, key=lambda x: x[0])


def _get_effective_custom_attrs_for_hash(
    row: Dict[str, Any],
    mapped_payload: Dict[str, Any],
    rows_by_sku: Optional[Dict[str, Dict[str, Any]]] = None,
) -> List[tuple[str, Any]]:
    """
    Build custom_attrs for hashing. For children, merge in axis labels from
    parent's configurable_variations (source of truth is label, not option_id).
    """
    attrs = list(mapped_payload.get("custom_attributes") or [])
    # For children: add axis labels from parent mapping (labels are source of truth)
    variant_of = _relation_sku(row.get("variant_of"))
    if variant_of and rows_by_sku:
        parent_row = rows_by_sku.get(variant_of)
        if parent_row:
            children, mappings, child_to_mapping = _parse_children_from_row(parent_row)
            child_sku = str(row.get("sku", "")).strip()
            m = child_to_mapping.get(child_sku) if child_to_mapping else None
            if m is None:
                try:
                    idx = children.index(child_sku)
                except ValueError:
                    idx = -1
                if 0 <= idx < len(mappings):
                    m = mappings[idx]
            if m:
                for attr_code, label in m.items():
                    if attr_code != "sku" and label and str(label).strip():
                        # Overwrite or add: use label for hashing (Plytix source of truth)
                        attrs = [a for a in attrs if str(a.get("attribute_code", "")).lower() != attr_code.lower()]
                        attrs.append({"attribute_code": attr_code, "value": str(label).strip()})
    return _canonicalize_custom_attrs(attrs)


def compute_data_hash(
    row: Dict[str, Any],
    mapped_payload: Dict[str, Any],
    *,
    rows_by_sku: Optional[Dict[str, Dict[str, Any]]] = None,
) -> str:
    """
    A4: Hash all managed payload fields so product updates don't require force_upsert.
    Includes: name, description, short_description, status, visibility, price (if in payload),
    category_ids/category_links, custom_attributes (excluding volatile/media/relation attrs),
    website assignment if managed.
    Excludes: media fields, relations, timestamps, non-deterministic values.
    """
    obj: Dict[str, Any] = {}
    # Prefer mapped payload name (parent neutral / display-name resolution) over raw row.
    name = str(mapped_payload.get("name") or row.get("name", "")).strip()
    if name:
        obj["name"] = name
    desc = str(row.get("description", "")).strip()
    if desc:
        obj["description"] = desc
    short_desc = str(row.get("short_description", "")).strip()
    if short_desc:
        obj["short_description"] = short_desc
    cat_ids = row.get("category_ids")
    if cat_ids:
        if isinstance(cat_ids, list):
            obj["category_ids"] = sorted(int(c) for c in cat_ids if str(c).strip().isdigit())
        elif isinstance(cat_ids, str):
            ids = sorted(int(c.strip()) for c in cat_ids.split(",") if c.strip().isdigit())
            if ids:
                obj["category_ids"] = ids
    # Also include extension_attributes.category_links if present
    ext = mapped_payload.get("extension_attributes") or {}
    if ext.get("category_links"):
        links = ext["category_links"]
        if isinstance(links, list):
            cat_ids_ext = sorted(
                int(l.get("category_id", 0))
                for l in links
                if isinstance(l, dict) and l.get("category_id") is not None
            )
            if cat_ids_ext:
                obj["category_links"] = cat_ids_ext
    status = mapped_payload.get("status")
    if status is not None:
        obj["status"] = int(status)
    visibility = mapped_payload.get("visibility")
    if visibility is not None:
        obj["visibility"] = int(visibility)
    # Price if we manage it (in payload)
    price = mapped_payload.get("price")
    if price is not None:
        try:
            obj["price"] = round(float(price), 2)
        except (TypeError, ValueError):
            obj["price"] = str(price)
    # Website assignment if managed
    for attr in (mapped_payload.get("custom_attributes") or []):
        if not isinstance(attr, dict):
            continue
        code = str(attr.get("attribute_code", "")).strip().lower()
        if code in ("website_id", "store_ids", "store_id"):
            val = attr.get("value")
            if val is not None:
                if isinstance(val, list):
                    obj[code] = sorted(int(x) for x in val if str(x).strip().isdigit())
                else:
                    obj[code] = str(val).strip()
    # Custom attributes (excluding volatile/media/relation)
    pairs = _get_effective_custom_attrs_for_hash(row, mapped_payload, rows_by_sku)
    if pairs:
        obj["custom_attributes"] = [(c, v) for c, v in pairs]
    blob = _canonical_json(obj)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def build_media_payload_from_row(row: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Build canonical media list for versioning: [{url, position, roles, label}]. No base64."""
    images: List[Dict[str, Any]] = []
    urls = _ordered_unique_image_urls(row)
    pos = 0
    if urls:
        images.append({
            "url": urls[0],
            "position": pos,
            "label": row.get("name") or row.get("sku") or "Base",
            "roles": ["image", "small_image", "thumbnail"],
        })
        pos += 1
    for add_url in urls[1:]:
        images.append({"url": str(add_url).strip(), "position": pos, "label": "", "roles": []})
        pos += 1
    images.sort(key=lambda x: (x.get("position", 0), x.get("url", "")))
    return images


def _parse_configurable_attributes(attrs_raw: str) -> List[str]:
    """Parse 'width=Width (inches),color' → ['color', 'width']."""
    codes = []
    for part in attrs_raw.split(","):
        part = part.strip()
        if "=" in part:
            codes.append(part.split("=", 1)[0].strip().lower())
        elif part:
            codes.append(part.lower())
    return sorted(set(c for c in codes if c and c != "sku"))


def build_relations_payload_from_row(row: Dict[str, Any]) -> Dict[str, Any]:
    """Build canonical relations for versioning: {children, attr_codes, child_option_map}."""
    children, mappings, child_to_mapping = _parse_children_from_row(row)
    attrs = str(row.get("configurable_attributes", "")).strip()
    attr_codes = _parse_configurable_attributes(attrs) if attrs else []
    if not attr_codes and mappings:
        codes: set[str] = set()
        for m in mappings:
            for k in m:
                if k != "sku":
                    codes.add(k)
        attr_codes = sorted(codes)
    child_options: Dict[str, Dict[str, str]] = {}
    for c_sku in children:
        m = child_to_mapping.get(c_sku) if child_to_mapping else None
        if m is None:
            idx = children.index(c_sku)
            if idx < len(mappings):
                m = mappings[idx]
        if m:
            child_options[c_sku] = dict(sorted(m.items()))
    return {
        "children": sorted(children),
        "attr_codes": attr_codes,
        "child_option_map": dict(sorted(child_options.items())),
    }


def compute_images_hash(row: Dict[str, Any]) -> str:
    """
    Canonical list of images: source URL, position, label, roles, disabled.
    Sorted by (position, url). sha256 hex.
    """
    images: List[Dict[str, Any]] = []
    urls = _ordered_unique_image_urls(row)

    pos = 0
    if urls:
        images.append({
            "url": urls[0],
            "position": pos,
            "label": row.get("name") or row.get("sku") or "Base",
            "roles": ["image", "small_image", "thumbnail"],
            "disabled": False,
        })
        pos += 1
    for add_url in urls[1:]:
        images.append({
            "url": str(add_url).strip(),
            "position": pos,
            "label": "",
            "roles": [],
            "disabled": False,
        })
        pos += 1

    images.sort(key=lambda x: (x.get("position", 0), x.get("url", "")))
    blob = _canonical_json(images)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def _parse_children_from_row(row: Dict[str, Any]) -> tuple[List[str], List[Dict[str, str]], Dict[str, Dict[str, str]]]:
    """
    Extract child SKUs and child->option mapping from row.
    - variant_list: comma/pipe separated SKUs (plain SKUs).
    - configurable_variations: either
      (a) Magento format "sku=C1,attr=Y|sku=C2,attr=Z" - parses sku= tokens
      (b) Plytix raw format "C1,C2" - comma-separated plain SKUs (like variant_list)
    Returns (children_skus, variation_mappings, child_to_mapping).
    child_to_mapping: sku -> {attr: value} when cv has sku= format; use for correct SKU->mapping.
    """
    children: List[str] = []
    mappings: List[Dict[str, str]] = []
    child_to_mapping: Dict[str, Dict[str, str]] = {}
    seen: set[str] = set()

    variant_list = str(row.get("variant_list", "")).strip()
    cv = str(row.get("configurable_variations", "")).strip()

    if variant_list:
        for s in variant_list.replace("|", ",").split(","):
            sku = s.strip()
            if sku and not sku.lower().startswith("sku=") and sku not in seen:
                seen.add(sku)
                children.append(sku)

    # JSON list from master_product_relation audits / tooling:
    # [{"sku":"C1","variation_width_in":"36"}, ...]
    if cv.startswith("["):
        try:
            parsed = json.loads(cv)
        except json.JSONDecodeError:
            parsed = None
        if isinstance(parsed, list):
            for item in parsed:
                if not isinstance(item, dict):
                    continue
                segment_sku = str(item.get("sku") or "").strip()
                if segment_sku and segment_sku not in seen:
                    seen.add(segment_sku)
                    children.append(segment_sku)
                m: Dict[str, str] = {}
                for key, raw_value in item.items():
                    if str(key).strip().lower() == "sku":
                        continue
                    v_stripped = str(raw_value or "").strip()
                    if _is_empty(v_stripped):
                        continue
                    channel_key = resolve_magento_axis_code(str(key).strip().lower())
                    if channel_key:
                        m[channel_key] = v_stripped
                if m:
                    mappings.append(dict(sorted(m.items())))
                if segment_sku and m:
                    child_to_mapping[segment_sku] = dict(sorted(m.items()))
            return children, mappings, child_to_mapping

    # Plytix raw format: configurable_variations = "C1,C2" (comma-separated plain SKUs, no key=value)
    if cv and "sku=" not in cv.lower() and "=" not in cv:
        for s in cv.replace("|", ",").split(","):
            sku = s.strip()
            if sku and sku not in seen:
                seen.add(sku)
                children.append(sku)

    if cv and "sku=" in cv.lower():
        for seg in cv.split("|"):
            seg = seg.strip()
            if not seg:
                continue
            m = {}
            segment_sku = None
            for pair in seg.split(","):
                pair = pair.strip()
                if "=" in pair:
                    k, v = pair.split("=", 1)
                    k_lower = k.strip().lower()
                    v_stripped = v.strip()
                    if k_lower == "sku":
                        segment_sku = v_stripped
                        if v_stripped and v_stripped not in seen:
                            seen.add(v_stripped)
                            children.append(v_stripped)
                    else:
                        channel_key = resolve_magento_axis_code(k.strip().lower())
                        if channel_key and v_stripped and not _is_empty(v_stripped):
                            m[channel_key] = v_stripped
            if m:
                mappings.append(dict(sorted(m.items())))
            if segment_sku and m:
                child_to_mapping[segment_sku] = dict(sorted(m.items()))

    return children, mappings, child_to_mapping


def compute_relations_hash(row: Dict[str, Any]) -> str:
    """
    Hash relation data: variant_of (child), or children + option mapping (parent).
    Child rows: only (role, parent_sku, self_sku).
    Parent rows: sorted children + configurable_attributes + variation_mappings.
    """
    parts: List[Any] = []

    variant_of = _relation_sku(row.get("variant_of"))
    if variant_of:
        self_sku = str(row.get("sku", "")).strip()
        parts.append({"role": "child", "parent": variant_of, "sku": self_sku})
        blob = _canonical_json(parts)
        return hashlib.sha256(blob.encode("utf-8")).hexdigest()

    product_type = str(row.get("product_type", "")).strip().lower()
    if product_type == "configurable" or "configurable" in product_type:
        children, mappings, child_to_mapping = _parse_children_from_row(row)
        parts.append({"role": "parent", "children": sorted(children)})

        attrs = str(row.get("configurable_attributes", "")).strip()
        if attrs:
            attr_codes = _parse_configurable_attributes(attrs)
            parts.append({"configurable_attributes": attr_codes})

        if mappings:
            mappings.sort(key=lambda x: json.dumps(x, sort_keys=True))
            parts.append({"variation_mappings": mappings})

        child_to_options: Dict[str, Dict[str, str]] = {}
        for c_sku in children:
            m = child_to_mapping.get(c_sku) if child_to_mapping else None
            if m is None and len(mappings) > 0:
                idx = children.index(c_sku)
                if idx < len(mappings):
                    m = mappings[idx]
            if m:
                child_to_options[c_sku] = dict(sorted(m.items()))
        if child_to_options:
            parts.append({"child_options": dict(sorted(child_to_options.items()))})

    if not parts:
        parts.append({"role": "standalone"})

    blob = _canonical_json(parts)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()
