from __future__ import annotations

import time
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple

from sqlalchemy import select
from sqlalchemy.orm import Session

from db.channel_exports import build_channel_product_payloads
from db.shopify_registry import resolve_registry_shop_code
from db.models import ChannelPublishState, ShopifyAttributeRegistry, ShopifyConnection
from shopify.connections import build_client, get_active_connection
from shopify.metafield_values import choices_from_metafield_definition, serialize_shopify_metafield_value

CHANNEL_CODE = "shopify"

# Canonical/channel attribute codes that map onto top-level ProductSetInput fields.
TITLE_CODES = ("title", "name", "display_name")
DESCRIPTION_CODES = ("description_html", "description", "body_html", "shopify_description", "cabinet_description")
VENDOR_CODES = ("vendor", "brand", "manufacturer")
PRODUCT_TYPE_CODES = ("product_type", "category_l1", "product_family")
HANDLE_CODES = ("handle", "product_url_slug")
SEO_TITLE_CODES = ("seo_title", "meta_title", "title_tag")
SEO_DESCRIPTION_CODES = ("seo_description", "meta_description")
TAGS_CODES = ("tags", "collection")
STATUS_CODES = ("shopify_status", "status")

RESERVED_CODES = set(
    TITLE_CODES
    + DESCRIPTION_CODES
    + VENDOR_CODES
    + PRODUCT_TYPE_CODES
    + HANDLE_CODES
    + SEO_TITLE_CODES
    + SEO_DESCRIPTION_CODES
    + TAGS_CODES
    + STATUS_CODES
)

# Codes excluded from metafields: media is a separate pass, and these are admin-only noise.
METAFIELD_SKIP_TOKENS = ("image", "thumbnail", "notes")

PRODUCT_SET_MUTATION = """
mutation productSet($input: ProductSetInput!) {
  productSet(input: $input, synchronous: true) {
    product { id }
    userErrors { field message }
  }
}
"""

VARIANT_LOOKUP_QUERY = """
query variantBySku($query: String!) {
  productVariants(first: 1, query: $query) {
    nodes { product { id } }
  }
}
"""


def map_payload_to_product_set_input(
    sku: str,
    fields: Dict[str, Any],
    price: Optional[float],
    *,
    metafield_defs: Optional[Dict[str, Dict[str, str]]] = None,
    product_id: Optional[str] = None,
    taxonomy_category_id: Optional[str] = None,
) -> Dict[str, Any]:
    """Pure mapping from a channel payload to Shopify ProductSetInput."""
    metafield_defs = metafield_defs or {}
    product: Dict[str, Any] = {}
    if product_id:
        product["id"] = product_id

    title = _first(fields, TITLE_CODES)
    if title:
        product["title"] = str(title)
    description = _first(fields, DESCRIPTION_CODES)
    if description:
        product["descriptionHtml"] = str(description)
    vendor = _first(fields, VENDOR_CODES)
    if vendor:
        product["vendor"] = str(vendor)
    product_type = _first(fields, PRODUCT_TYPE_CODES)
    if product_type:
        product["productType"] = str(product_type)
    if taxonomy_category_id:
        product["category"] = str(taxonomy_category_id)
    handle = _first(fields, HANDLE_CODES)
    if handle:
        product["handle"] = str(handle)
    product["status"] = _product_status(_first(fields, STATUS_CODES))

    tags = _first(fields, TAGS_CODES)
    if tags:
        product["tags"] = [t.strip() for t in str(tags).replace("|", ",").split(",") if t.strip()]

    seo: Dict[str, str] = {}
    seo_title = _first(fields, SEO_TITLE_CODES)
    if seo_title:
        seo["title"] = str(seo_title)
    seo_description = _first(fields, SEO_DESCRIPTION_CODES)
    if seo_description:
        seo["description"] = str(seo_description)
    if seo:
        product["seo"] = seo

    metafields = []
    for code, value in sorted(fields.items()):
        if code in RESERVED_CODES or value is None or value == "":
            continue
        if any(token in code for token in METAFIELD_SKIP_TOKENS):
            continue
        if code not in metafield_defs:
            continue
        definition = metafield_defs[code]
        namespace, key, data_type = (
            definition["namespace"],
            definition["key"],
            definition["type"],
        )
        serialized = serialize_shopify_metafield_value(
            value,
            data_type,
            choices=definition.get("choices"),
        )
        if serialized is None:
            continue
        metafields.append(
            {
                "namespace": namespace,
                "key": key,
                "type": data_type,
                "value": serialized,
            }
        )
    if metafields:
        product["metafields"] = metafields

    variant: Dict[str, Any] = {
        "optionValues": [{"optionName": "Title", "name": "Default Title"}],
        "sku": sku,
    }
    if price is not None:
        variant["price"] = str(price)
    product["productOptions"] = [{"name": "Title", "values": [{"name": "Default Title"}]}]
    product["variants"] = [variant]
    return product


def push_products(
    session: Session,
    *,
    dry_run: bool = True,
    limit: Optional[int] = None,
    skus: Optional[List[str]] = None,
    shop_code: Optional[str] = None,
    connection_id: Optional[int] = None,
    force: bool = False,
    on_progress: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> Dict[str, Any]:
    """Push assigned master products to Shopify, skipping SKUs whose payload hash is unchanged."""
    connection = None
    compat_connection_id = connection_id
    if connection_id:
        from db.compat_connections import compat_connection_id as make_compat_id
        from db.compat_connections import decode_compat_connection_id

        channel_type, native_id = decode_compat_connection_id(connection_id)
        if channel_type == "shopify":
            connection = session.get(ShopifyConnection, native_id)
            compat_connection_id = make_compat_id("shopify", native_id)
    if connection is None:
        connection = get_active_connection(session, shop_code=shop_code)
        if connection is not None and compat_connection_id is None:
            from db.compat_connections import compat_connection_id as make_compat_id

            compat_connection_id = make_compat_id("shopify", connection.id)
    has_rows = session.scalar(select(ShopifyConnection.id).limit(1)) is not None
    if connection is None and has_rows:
        return {"status": "disabled", "detail": "No active Shopify connection (kill switch engaged)"}

    if not dry_run:
        from db.channel_attribute_provision import provision_channel_attributes

        provision_channel_attributes(
            session,
            CHANNEL_CODE,
            shop_code=connection.shop_code if connection else shop_code,
            dry_run=False,
        )

    if on_progress is not None:
        on_progress(
            {
                "total": len(skus) if skus else None,
                "completed": 0,
                "stage": "building_payloads",
            }
        )

    payloads = build_channel_product_payloads(
        session,
        CHANNEL_CODE,
        skus=skus,
        limit=limit,
        connection_id=compat_connection_id,
    )
    states = _publish_states(session, [p["sku"] for p in payloads])
    metafield_defs = _metafield_definitions(
        session,
        resolve_registry_shop_code(session, connection=connection, shop_code=shop_code),
    )

    from db.channel_sku_mapping import remote_ids_by_master

    linked_remote_ids = remote_ids_by_master(
        session,
        CHANNEL_CODE,
        [p["sku"] for p in payloads],
        connection_id=compat_connection_id,
    )

    summary: Dict[str, Any] = {
        "status": "ok",
        "dry_run": dry_run,
        "total": len(payloads),
        "pushed": 0,
        "created": 0,
        "updated": 0,
        "skipped_unchanged": 0,
        "failed": 0,
        "errors": [],
        "planned": [],
    }
    if not payloads:
        return summary

    processed = 0

    def _emit_progress(*, stage: str = "pushing") -> None:
        if on_progress is None:
            return
        on_progress(
            {
                "total": summary["total"],
                "completed": processed,
                "pushed": summary["pushed"],
                "failed": summary["failed"],
                "skipped_unchanged": summary["skipped_unchanged"],
                "stage": stage,
            }
        )

    _emit_progress(stage="pushing")

    client = None
    if not dry_run:
        client = build_client(connection)

    for payload in payloads:
        master_sku = payload["sku"]
        channel_sku = str(payload.get("channel_sku") or master_sku).strip()
        state = states.get(master_sku)
        unchanged = (
            state is not None
            and state.payload_hash == payload["payload_hash"]
            and state.last_status == "success"
        )
        if unchanged and not force:
            summary["skipped_unchanged"] += 1
            processed += 1
            if processed % 10 == 0 or processed == summary["total"]:
                _emit_progress()
            continue

        # Publish state remembers what we pushed; SKU-mapping remote_id covers
        # pre-linked products that were reconciled but never pushed by us.
        remote_id = (state.remote_id if state else None) or linked_remote_ids.get(master_sku)
        if dry_run:
            summary["pushed"] += 1
            summary["created" if not remote_id else "updated"] += 1
            summary["planned"].append(
                {
                    "sku": master_sku,
                    "channel_sku": channel_sku,
                    "action": "update" if remote_id else "create_or_match",
                    "associations": payload.get("association_fields") or {},
                }
            )
            processed += 1
            if processed % 10 == 0 or processed == summary["total"]:
                _emit_progress()
            continue

        try:
            if not remote_id:
                remote_id = _lookup_product_id(client, channel_sku)
            from db.product_channel_taxonomy import resolve_shopify_product_category_id

            taxonomy_category_id = resolve_shopify_product_category_id(
                session,
                master_sku=master_sku,
                canonical_fields=payload["fields"],
                connection_id=connection.id if connection else None,
            )
            product_input = map_payload_to_product_set_input(
                channel_sku,
                payload["fields"],
                payload["price"],
                metafield_defs=metafield_defs,
                product_id=remote_id,
                taxonomy_category_id=taxonomy_category_id,
            )
            result_id = _product_set(client, product_input)
            collection_ids = []
            if result_id or remote_id:
                from shopify.collection_sync import assign_product_to_collections

                native_connection_id = connection.id if connection else None
                collection_result = assign_product_to_collections(
                    session,
                    client,
                    product_id=result_id or remote_id,
                    master_sku=master_sku,
                    fields=payload["fields"],
                    connection_id=native_connection_id,
                    product_input=product_input,
                    taxonomy_category_id=taxonomy_category_id,
                    price=payload.get("price"),
                )
                collection_ids = collection_result.collection_ids
                if collection_result.warnings:
                    summary.setdefault("collection_warnings", []).extend(collection_result.warnings)

            product_gid = result_id or remote_id
            association_fields = (
                payload.get("association_fields_master") or payload.get("association_fields") or {}
            )
            if product_gid and any(association_fields.get(k) for k in ("related", "upsell", "crosssell")):
                from shopify.association_sync import push_discovery_associations

                master_assoc = payload.get("association_fields_master") or {}
                channel_assoc = payload.get("association_fields") or {}
                master_to_channel: Dict[str, str] = {}
                for link_type in ("related", "upsell", "crosssell"):
                    masters = master_assoc.get(link_type) or []
                    channels = channel_assoc.get(link_type) or []
                    for master_target, channel_target in zip(masters, channels):
                        if master_target and channel_target:
                            master_to_channel[str(master_target)] = str(channel_target)

                target_remote_ids = dict(linked_remote_ids)
                target_skus = sorted(
                    {
                        str(sku).strip()
                        for targets in association_fields.values()
                        for sku in (targets or [])
                        if str(sku).strip()
                    }
                )
                for target_sku in target_skus:
                    if target_remote_ids.get(target_sku):
                        continue
                    target_state = states.get(target_sku)
                    if target_state and target_state.remote_id:
                        target_remote_ids[target_sku] = target_state.remote_id
                        continue
                    lookup_sku = master_to_channel.get(target_sku, target_sku)
                    mapped = _lookup_product_id(client, lookup_sku)
                    if mapped:
                        target_remote_ids[target_sku] = mapped

                push_discovery_associations(
                    client,
                    product_id=product_gid,
                    associations=association_fields,
                    remote_ids_by_sku=target_remote_ids,
                )

            state = _write_state(
                session,
                state,
                sku=master_sku,
                payload_hash=payload["payload_hash"],
                last_payload=product_input,
                remote_id=result_id or remote_id,
                status="success",
                error=None,
            )
            states[master_sku] = state
            summary["pushed"] += 1
            summary["updated" if remote_id else "created"] += 1
        except Exception as exc:
            state = _write_state(
                session,
                state,
                sku=master_sku,
                payload_hash=payload["payload_hash"],
                last_payload=None,
                remote_id=remote_id,
                status="error",
                error=str(exc),
            )
            states[master_sku] = state
            summary["failed"] += 1
            if len(summary["errors"]) < 50:
                summary["errors"].append(
                    {"sku": master_sku, "channel_sku": channel_sku, "error": str(exc)}
                )
            try:
                from db.channel_projection_invalidation import invalidate_skus

                invalidate_skus(
                    session,
                    channel_code=CHANNEL_CODE,
                    connection_id=compat_connection_id,
                    skus=[master_sku],
                    reason=f"shopify_push_failure:{exc}",
                )
            except Exception:
                pass
        if not dry_run:
            session.commit()

        processed += 1
        if processed % 10 == 0 or processed == summary["total"]:
            _emit_progress()

    summary["planned"] = summary["planned"][:200]
    _emit_progress(stage="done")
    return summary


def _product_set(client, product_input: Dict[str, Any], *, max_attempts: int = 4) -> Optional[str]:
    last_error: Optional[Exception] = None
    for attempt in range(max_attempts):
        try:
            data = client.graphql(PRODUCT_SET_MUTATION, {"input": product_input})
            result = data.get("productSet") or {}
            errors = result.get("userErrors") or []
            if errors:
                raise RuntimeError(f"productSet userErrors: {errors}")
            product = result.get("product") or {}
            return product.get("id")
        except Exception as exc:
            last_error = exc
            if _is_throttled(exc) and attempt < max_attempts - 1:
                time.sleep(2.0 * (attempt + 1))
                continue
            raise
    raise last_error  # pragma: no cover - loop always raises or returns


def _lookup_product_id(client, sku: str) -> Optional[str]:
    data = client.graphql(VARIANT_LOOKUP_QUERY, {"query": f'sku:"{sku}"'})
    nodes = ((data.get("productVariants") or {}).get("nodes")) or []
    if nodes:
        return ((nodes[0] or {}).get("product") or {}).get("id")
    return None


def _is_throttled(exc: Exception) -> bool:
    text = str(exc).upper()
    return "THROTTLED" in text or "429" in text


def _publish_states(session: Session, skus: List[str]) -> Dict[str, ChannelPublishState]:
    if not skus:
        return {}
    rows = session.scalars(
        select(ChannelPublishState)
        .where(ChannelPublishState.channel_code == CHANNEL_CODE)
        .where(ChannelPublishState.sku.in_(skus))
    ).all()
    return {row.sku: row for row in rows}


def _metafield_definitions(session: Session, shop_code: Optional[str]) -> Dict[str, Dict[str, Any]]:
    """attribute_code → {namespace, key, type, choices?} from the Shopify attribute registry."""
    canonical_shop_code = str(shop_code or "").strip()
    stmt = select(ShopifyAttributeRegistry).where(ShopifyAttributeRegistry.namespace.is_not(None))
    if canonical_shop_code:
        stmt = stmt.where(ShopifyAttributeRegistry.shop_code == canonical_shop_code)

    defs: Dict[str, Dict[str, Any]] = {}
    for row in session.scalars(stmt).all():
        if not row.key:
            continue
        defs[row.attribute_code] = _registry_metafield_entry(row)
    return defs


def _registry_metafield_entry(row: ShopifyAttributeRegistry) -> Dict[str, Any]:
    raw = row.raw_json if isinstance(row.raw_json, dict) else {}
    type_obj = raw.get("type")
    resolved_type = (
        str(type_obj.get("name")).strip()
        if isinstance(type_obj, dict) and type_obj.get("name")
        else (row.data_type or "single_line_text_field")
    )
    entry: Dict[str, Any] = {
        "namespace": row.namespace,
        "key": row.key,
        "type": resolved_type,
    }
    choices = choices_from_metafield_definition(raw)
    if choices:
        entry["choices"] = choices
    return entry


def _ensure_publish_state(
    session: Session,
    state: Optional[ChannelPublishState],
    *,
    sku: str,
) -> ChannelPublishState:
    if state is not None:
        return state
    existing = session.scalar(
        select(ChannelPublishState)
        .where(ChannelPublishState.sku == sku)
        .where(ChannelPublishState.channel_code == CHANNEL_CODE)
    )
    if existing is not None:
        return existing
    state = ChannelPublishState(sku=sku, channel_code=CHANNEL_CODE)
    session.add(state)
    return state


def _write_state(
    session: Session,
    state: Optional[ChannelPublishState],
    *,
    sku: str,
    payload_hash: str,
    last_payload: Optional[Dict[str, Any]],
    remote_id: Optional[str],
    status: str,
    error: Optional[str],
) -> ChannelPublishState:
    state = _ensure_publish_state(session, state, sku=sku)
    state.payload_hash = payload_hash if status == "success" else state.payload_hash
    if last_payload is not None:
        state.last_payload = last_payload
    state.remote_id = remote_id
    state.last_status = status
    state.last_error = error
    if status == "success":
        state.last_published_at = datetime.utcnow()
    return state


def _first(fields: Dict[str, Any], codes: Tuple[str, ...]) -> Optional[Any]:
    for code in codes:
        value = fields.get(code)
        if value is not None and value != "":
            return value
    return None


def _product_status(value: Optional[Any]) -> str:
    text = str(value or "").strip().lower()
    if text in {"draft", "disabled", "0", "false", "inactive"}:
        return "DRAFT"
    if text == "archived":
        return "ARCHIVED"
    return "ACTIVE"
