"""Async bulk Magento product writer for UPSERT_PRODUCT.

Dependency-inverted over MagentoApiClient bulk methods. Planning/hash logic stays
in sync_planner; this module only batches PUT/POST payloads, polls bulk status,
and returns per-SKU results for the sync orchestrator to audit/hash.
"""

from __future__ import annotations

import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple

logger = logging.getLogger(__name__)

# Magento bulk operation status codes
_BULK_COMPLETE = 1
_BULK_FAILED_RETRYABLE = 2
_BULK_FAILED_FATAL = 3
_BULK_OPEN = 4
_BULK_REJECTED = 5
_TERMINAL = {_BULK_COMPLETE, _BULK_FAILED_RETRYABLE, _BULK_FAILED_FATAL, _BULK_REJECTED}


class ProductBulkApi(Protocol):
    """Port for Magento async bulk product + status endpoints."""

    def put_products_bulk_async(
        self, products: List[Dict[str, Any]]
    ) -> Tuple[int, Optional[Dict[str, Any]], Optional[str]]: ...

    def post_products_bulk_async(
        self, products: List[Dict[str, Any]]
    ) -> Tuple[int, Optional[Dict[str, Any]], Optional[str]]: ...

    def get_bulk_status(
        self, bulk_uuid: str
    ) -> Tuple[int, Optional[Dict[str, Any]], Optional[str]]: ...

    def get_bulk_detailed_status(
        self, bulk_uuid: str
    ) -> Tuple[int, Optional[Dict[str, Any]], Optional[str]]: ...

    def get_product(self, sku: str) -> Optional[Dict[str, Any]]:
        """GET /V1/products/{sku}. Used to avoid POST-create races / false missing."""
        ...


@dataclass
class BulkProductOp:
    """One SKU ready for bulk write (payloads already enriched by sync service)."""

    sku: str
    put_payload: Dict[str, Any]
    create_payload: Dict[str, Any]
    row: Dict[str, Any] = field(default_factory=dict)
    source_payload: Dict[str, Any] = field(default_factory=dict)


def _is_missing_product_error(message: str) -> bool:
    m = (message or "").lower()
    return any(
        needle in m
        for needle in (
            "does not exist",
            "couldn't find",
            "could not find",
            "no such entity",
            "invalid attribute set entity type",
            "invalid product data",
            "product not exist",
        )
    )


def _is_already_exists_error(message: str) -> bool:
    m = (message or "").lower()
    return "already exists" in m and ("sku" in m or "product" in m or "url key" in m)


def _is_ambiguous_missing_put_message(message: str) -> bool:
    m = (message or "").lower()
    return "invalid attribute set entity type" in m or "invalid product data" in m


def _parse_jsonish(value: Any) -> Any:
    if isinstance(value, (dict, list)):
        return value
    if not isinstance(value, str) or not value.strip():
        return None
    try:
        return json.loads(value)
    except Exception:
        return None


def sku_from_bulk_operation(op: Dict[str, Any]) -> Optional[str]:
    """Extract SKU from a Magento bulk detailed-status operations_list entry."""
    for key in ("serialized_data", "result_serialized_data"):
        data = _parse_jsonish(op.get(key))
        if not isinstance(data, dict):
            continue
        meta = data.get("meta_information") or data.get("meta_info") or data
        meta = _parse_jsonish(meta) if isinstance(meta, str) else meta
        if not isinstance(meta, dict):
            continue
        product = meta.get("product")
        if isinstance(product, dict) and product.get("sku"):
            return str(product["sku"]).strip()
        if meta.get("sku"):
            return str(meta["sku"]).strip()
    return None


def _result_message(op: Dict[str, Any]) -> str:
    msg = op.get("result_message")
    if msg:
        return str(msg)
    err = op.get("error_code")
    if err is not None:
        return f"bulk error_code={err}"
    return f"bulk status={op.get('status')}"


@dataclass
class MagentoProductBulkWriter:
    """Batch UPSERT_PRODUCT via Magento async bulk API; preserve per-SKU outcomes."""

    api: ProductBulkApi
    batch_size: int = 100
    poll_interval_s: float = 2.0
    poll_timeout_s: float = 600.0
    call_with_retry: Optional[Callable[[str, Callable[[], Any]], Any]] = None
    sleep: Callable[[float], None] = time.sleep

    def execute(self, ops: List[BulkProductOp]) -> List[Dict[str, Any]]:
        """Run bulk PUT then POST-fallback for missing products.

        Returns one result dict per input op, same order. Shape matches
        ``execute_upsert_product`` enough for sync_incremental audit/hash updates:
        ``status``, ``duration_ms``, ``http_status``, ``error``, ``is_new``,
        ``payload_sent``, ``bulk_uuid``.
        """
        if not ops:
            return []
        start = time.perf_counter()
        results: Dict[str, Dict[str, Any]] = {}
        remaining = list(ops)

        # Chunk to configured batch size
        for offset in range(0, len(remaining), self.batch_size):
            chunk = remaining[offset : offset + self.batch_size]
            chunk_results = self._execute_chunk(chunk, start)
            for sku, res in chunk_results.items():
                results[sku] = res

        ordered: List[Dict[str, Any]] = []
        for op in ops:
            res = results.get(op.sku) or {
                "status": "failed",
                "duration_ms": int((time.perf_counter() - start) * 1000),
                "error": "bulk writer produced no result for SKU",
                "payload_sent": op.put_payload,
            }
            res["sku"] = op.sku
            ordered.append(res)
        return ordered

    def _call(self, op_name: str, fn: Callable[[], Any]) -> Any:
        if self.call_with_retry:
            return self.call_with_retry(op_name, fn)
        return fn()

    def _execute_chunk(
        self, ops: List[BulkProductOp], start: float
    ) -> Dict[str, Dict[str, Any]]:
        by_sku = {op.sku: op for op in ops}
        put_products = [op.put_payload for op in ops]
        put_status, put_body, put_err = self._call(
            f"put_products_bulk:{len(ops)}",
            lambda: self.api.put_products_bulk_async(put_products),
        )
        if put_status not in (200, 202) or not isinstance(put_body, dict) or not put_body.get("bulk_uuid"):
            err = put_err or (str(put_body) if put_body else f"HTTP {put_status}")
            logger.warning("Bulk PUT submit failed (%s): %s", put_status, err[:300])
            return {
                op.sku: {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": put_status,
                    "error": f"bulk PUT submit failed: {err}"[:500],
                    "is_new": False,
                    "payload_sent": op.put_payload,
                }
                for op in ops
            }

        bulk_uuid = str(put_body["bulk_uuid"])
        put_ops = self._poll_until_done(bulk_uuid, expected_skus=[op.sku for op in ops])
        results: Dict[str, Dict[str, Any]] = {}
        need_create: List[BulkProductOp] = []

        for op in ops:
            op_status = put_ops.get(op.sku)
            if op_status is None:
                results[op.sku] = {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": put_status,
                    "error": f"bulk PUT: no status for SKU (bulk_uuid={bulk_uuid})",
                    "is_new": False,
                    "payload_sent": op.put_payload,
                    "bulk_uuid": bulk_uuid,
                }
                continue
            code = int(op_status.get("status") or 0)
            msg = _result_message(op_status)
            if code == _BULK_COMPLETE:
                results[op.sku] = {
                    "status": "success",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": 200,
                    "is_new": False,
                    "payload_sent": op.put_payload,
                    "bulk_uuid": bulk_uuid,
                }
            elif _is_missing_product_error(msg):
                existing = self._lookup_existing_product(op.sku)
                if existing is not None:
                    if _is_ambiguous_missing_put_message(msg):
                        # Existing product failed PUT validation — do not POST-create a twin.
                        results[op.sku] = {
                            "status": "failed",
                            "duration_ms": int((time.perf_counter() - start) * 1000),
                            "http_status": 400,
                            "error": (
                                f"bulk PUT failed on existing product; refusing POST create "
                                f"to avoid duplicate SKU. Detail: {msg}"
                            )[:500],
                            "is_new": False,
                            "payload_sent": op.put_payload,
                            "bulk_uuid": bulk_uuid,
                            # Single-path may still fix via narrower payload / option ensure.
                            "needs_single_fallback": True,
                        }
                    else:
                        # PUT said missing but GET found it (race) → single-path PUT.
                        results[op.sku] = {
                            "status": "failed",
                            "duration_ms": int((time.perf_counter() - start) * 1000),
                            "http_status": 404,
                            "error": (
                                f"bulk PUT reported missing but GET found product; "
                                f"retrying single-path upsert. Detail: {msg}"
                            )[:500],
                            "is_new": False,
                            "payload_sent": op.put_payload,
                            "bulk_uuid": bulk_uuid,
                            "needs_single_fallback": True,
                        }
                else:
                    need_create.append(op)
            else:
                results[op.sku] = {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": 400 if code in (_BULK_FAILED_FATAL, _BULK_REJECTED) else 500,
                    "error": msg[:500],
                    "is_new": False,
                    "payload_sent": op.put_payload,
                    "bulk_uuid": bulk_uuid,
                    "retryable": code == _BULK_FAILED_RETRYABLE,
                }

        if need_create:
            create_results = self._execute_create_chunk(need_create, start)
            results.update(create_results)

        return results

    def _lookup_existing_product(self, sku: str) -> Optional[Dict[str, Any]]:
        getter = getattr(self.api, "get_product", None)
        if not callable(getter):
            return None
        try:
            return getter(sku)
        except Exception as exc:
            logger.warning("bulk get_product before create failed for %s: %s", sku, exc)
            return None

    def _execute_create_chunk(
        self, ops: List[BulkProductOp], start: float
    ) -> Dict[str, Dict[str, Any]]:
        results: Dict[str, Dict[str, Any]] = {}
        still_create: List[BulkProductOp] = []
        # Final existence check before POST — shrinks concurrent-create race window.
        for op in ops:
            existing = self._lookup_existing_product(op.sku)
            if existing is not None:
                results[op.sku] = {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": 409,
                    "error": "product appeared before bulk POST create; retrying single-path upsert",
                    "is_new": False,
                    "payload_sent": op.put_payload,
                    "needs_single_fallback": True,
                }
            else:
                still_create.append(op)

        if not still_create:
            return results

        create_products = [op.create_payload for op in still_create]
        post_status, post_body, post_err = self._call(
            f"post_products_bulk:{len(still_create)}",
            lambda: self.api.post_products_bulk_async(create_products),
        )
        if post_status not in (200, 202) or not isinstance(post_body, dict) or not post_body.get("bulk_uuid"):
            err = post_err or (str(post_body) if post_body else f"HTTP {post_status}")
            logger.warning("Bulk POST submit failed (%s): %s", post_status, err[:300])
            for op in still_create:
                results[op.sku] = {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": post_status,
                    "error": f"bulk POST submit failed: {err}"[:500],
                    "is_new": True,
                    "payload_sent": op.create_payload,
                }
            return results

        bulk_uuid = str(post_body["bulk_uuid"])
        post_ops = self._poll_until_done(bulk_uuid, expected_skus=[op.sku for op in still_create])
        for op in still_create:
            op_status = post_ops.get(op.sku)
            if op_status is None:
                results[op.sku] = {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": post_status,
                    "error": f"bulk POST: no status for SKU (bulk_uuid={bulk_uuid})",
                    "is_new": True,
                    "payload_sent": op.create_payload,
                    "bulk_uuid": bulk_uuid,
                }
                continue
            code = int(op_status.get("status") or 0)
            msg = _result_message(op_status)
            if code == _BULK_COMPLETE:
                results[op.sku] = {
                    "status": "success",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": 201,
                    "is_new": True,
                    "payload_sent": op.create_payload,
                    "bulk_uuid": bulk_uuid,
                }
            else:
                # URL-key / already-exists: signal for single-path fallback by caller
                results[op.sku] = {
                    "status": "failed",
                    "duration_ms": int((time.perf_counter() - start) * 1000),
                    "http_status": 400 if code in (_BULK_FAILED_FATAL, _BULK_REJECTED) else 500,
                    "error": msg[:500],
                    "is_new": True,
                    "payload_sent": op.create_payload,
                    "bulk_uuid": bulk_uuid,
                    "retryable": code == _BULK_FAILED_RETRYABLE,
                    "needs_single_fallback": _is_already_exists_error(msg) or "url key" in msg.lower(),
                }
        return results

    def _poll_until_done(
        self, bulk_uuid: str, *, expected_skus: List[str]
    ) -> Dict[str, Dict[str, Any]]:
        """Poll detailed-status until all ops terminal or timeout.

        Returns map sku → operation dict.
        """
        deadline = time.perf_counter() + self.poll_timeout_s
        last_by_sku: Dict[str, Dict[str, Any]] = {}
        while time.perf_counter() < deadline:
            status_code, body, err = self._call(
                f"get_bulk_detailed_status:{bulk_uuid}",
                lambda: self.api.get_bulk_detailed_status(bulk_uuid),
            )
            if status_code != 200 or not isinstance(body, dict):
                logger.warning(
                    "Bulk status poll failed uuid=%s http=%s err=%s",
                    bulk_uuid,
                    status_code,
                    (err or "")[:200],
                )
                self.sleep(self.poll_interval_s)
                continue

            ops_list = body.get("operations_list") or []
            by_sku = self._map_operations_to_skus(ops_list, expected_skus)
            last_by_sku = by_sku or last_by_sku

            if by_sku and all(
                int((by_sku[s] or {}).get("status") or _BULK_OPEN) in _TERMINAL
                for s in expected_skus
                if s in by_sku
            ) and len(by_sku) >= len(expected_skus):
                return by_sku

            # Also accept: all listed ops terminal even if SKU map incomplete
            if ops_list and all(int(o.get("status") or _BULK_OPEN) in _TERMINAL for o in ops_list if isinstance(o, dict)):
                if by_sku:
                    return by_sku
                # Fall back to positional mapping
                positional = self._positional_map(ops_list, expected_skus)
                if positional:
                    return positional

            open_count = sum(
                1
                for o in ops_list
                if isinstance(o, dict) and int(o.get("status") or _BULK_OPEN) == _BULK_OPEN
            )
            logger.info(
                "Bulk %s progress: %d ops, %d open, mapped=%d/%d",
                bulk_uuid,
                len(ops_list),
                open_count,
                len(by_sku),
                len(expected_skus),
            )
            self.sleep(self.poll_interval_s)

        # Timeout: mark missing/open as failed
        logger.error(
            "Bulk %s timed out after %ss (mapped %d/%d)",
            bulk_uuid,
            self.poll_timeout_s,
            len(last_by_sku),
            len(expected_skus),
        )
        out = dict(last_by_sku)
        for sku in expected_skus:
            existing = out.get(sku)
            if existing and int(existing.get("status") or _BULK_OPEN) in _TERMINAL:
                continue
            out[sku] = {
                "status": _BULK_FAILED_RETRYABLE,
                "result_message": (
                    f"bulk operation timed out after {self.poll_timeout_s}s "
                    f"(bulk_uuid={bulk_uuid}; ensure async.operations.all consumer is running)"
                ),
                "error_code": None,
            }
        return out

    @staticmethod
    def _map_operations_to_skus(
        ops_list: List[Any], expected_skus: List[str]
    ) -> Dict[str, Dict[str, Any]]:
        by_sku: Dict[str, Dict[str, Any]] = {}
        for op in ops_list:
            if not isinstance(op, dict):
                continue
            sku = sku_from_bulk_operation(op)
            if sku:
                by_sku[sku] = op
        if len(by_sku) >= len(expected_skus):
            return by_sku
        # Fill gaps positionally when Magento omits serialized_data
        if len(ops_list) == len(expected_skus) and len(by_sku) < len(expected_skus):
            return MagentoProductBulkWriter._positional_map(ops_list, expected_skus) or by_sku
        return by_sku

    @staticmethod
    def _positional_map(
        ops_list: List[Any], expected_skus: List[str]
    ) -> Dict[str, Dict[str, Any]]:
        if len(ops_list) != len(expected_skus):
            return {}
        out: Dict[str, Dict[str, Any]] = {}
        for sku, op in zip(expected_skus, ops_list):
            if isinstance(op, dict):
                out[sku] = op
        return out
