from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from sqlalchemy import select
from sqlalchemy.orm import Session

from db.models import ShopifyCollectionRegistry
from shopify.smart_collection import (
    build_smart_collection_product_context,
    evaluate_smart_collection_rules,
    smart_collection_guidance,
)

logger = logging.getLogger(__name__)


@dataclass
class CollectionAssignmentResult:
    collection_ids: List[str] = field(default_factory=list)
    warnings: List[Dict[str, Any]] = field(default_factory=list)


COLLECTIONS_PULL_QUERY = """
query shopifyCollectionsPull($first: Int!, $after: String) {
  collections(first: $first, after: $after) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      handle
      title
      sortOrder
      ruleSet {
        appliedDisjunctively
        rules { column relation condition }
      }
      productsCount { count }
    }
  }
}
"""

COLLECTION_BY_HANDLE_QUERY = """
query collectionByHandle($handle: String!) {
  collectionByHandle(handle: $handle) {
    id
    handle
    title
    ruleSet {
      appliedDisjunctively
      rules { column relation condition }
    }
    productsCount { count }
  }
}
"""

COLLECTION_BY_ID_QUERY = """
query collectionById($id: ID!) {
  collection(id: $id) {
    id
    handle
    title
    ruleSet {
      appliedDisjunctively
      rules { column relation condition }
    }
    productsCount { count }
  }
}
"""

COLLECTION_CREATE_MUTATION = """
mutation collectionCreate($input: CollectionInput!) {
  collectionCreate(input: $input) {
    collection { id handle title }
    userErrors { field message }
  }
}
"""

COLLECTION_ADD_PRODUCTS_MUTATION = """
mutation collectionAddProducts($id: ID!, $productIds: [ID!]!) {
  collectionAddProducts(id: $id, productIds: $productIds) {
    collection { id }
    userErrors { field message }
  }
}
"""

PRODUCT_COLLECTIONS_QUERY = """
query productCollections($id: ID!) {
  product(id: $id) {
    collections(first: 250) { nodes { id } }
  }
}
"""

COLLECTION_REMOVE_PRODUCTS_MUTATION = """
mutation collectionRemoveProducts($id: ID!, $productIds: [ID!]!) {
  collectionRemoveProducts(id: $id, productIds: $productIds) {
    userErrors { field message }
  }
}
"""

COLLECTION_DELETE_MUTATION = """
mutation collectionDelete($input: CollectionDeleteInput!) {
  collectionDelete(input: $input) {
    deletedCollectionId
    userErrors { field message }
  }
}
"""


def pull_shopify_collections(
    client: Any,
    *,
    shop_code: str,
    connection_id: Optional[int],
    session: Session,
) -> Dict[str, Any]:
    fetched_at = datetime.now(timezone.utc)
    rows: List[Dict[str, Any]] = []
    after: Optional[str] = None
    while True:
        data = client.graphql(COLLECTIONS_PULL_QUERY, {"first": 100, "after": after})
        connection = data.get("collections") or {}
        for node in connection.get("nodes") or []:
            if not isinstance(node, dict):
                continue
            rule_set = node.get("ruleSet")
            collection_type = "smart" if rule_set else "manual"
            rows.append(
                {
                    "collection_id": node.get("id"),
                    "handle": node.get("handle"),
                    "title": node.get("title") or node.get("handle") or "",
                    "collection_type": collection_type,
                    "rules_json": rule_set,
                    "product_count": ((node.get("productsCount") or {}).get("count")),
                    "raw_json": node,
                }
            )
        page_info = connection.get("pageInfo") or {}
        if not page_info.get("hasNextPage"):
            break
        after = page_info.get("endCursor")
        if not after:
            break

    _replace_registry(session, shop_code=shop_code, connection_id=connection_id, rows=rows, fetched_at=fetched_at)
    return {"status": "success", "collections": len(rows), "shop_code": shop_code}


def list_shopify_collections(
    session: Session,
    *,
    connection_id: Optional[int] = None,
    search: Optional[str] = None,
    limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
    stmt = select(ShopifyCollectionRegistry).order_by(ShopifyCollectionRegistry.title)
    if connection_id is not None:
        stmt = stmt.where(ShopifyCollectionRegistry.connection_id == connection_id)
    if search:
        pattern = f"%{search.strip()}%"
        stmt = stmt.where(
            (ShopifyCollectionRegistry.title.ilike(pattern))
            | (ShopifyCollectionRegistry.handle.ilike(pattern))
        )
    if limit:
        stmt = stmt.limit(limit)
    return [
        {
            "collection_id": row.collection_id,
            "handle": row.handle,
            "title": row.title,
            "collection_type": row.collection_type,
            "product_count": row.product_count,
            "connection_id": row.connection_id,
            "shop_code": row.shop_code,
        }
        for row in session.scalars(stmt).all()
    ]


def fetch_shopify_collection_by_handle(client: Any, handle: str) -> Optional[Dict[str, Any]]:
    """Return a single collection from Shopify Admin API by handle, if it exists."""
    handle = str(handle or "").strip()
    if not handle:
        return None
    data = client.graphql(COLLECTION_BY_HANDLE_QUERY, {"handle": handle})
    node = data.get("collectionByHandle")
    if not isinstance(node, dict) or not node.get("id"):
        return None
    return _collection_payload_from_node(node)


def fetch_shopify_collection_by_id(client: Any, collection_id: str) -> Optional[Dict[str, Any]]:
    """Return a single collection from Shopify Admin API by GID."""
    collection_id = str(collection_id or "").strip()
    if not collection_id:
        return None
    data = client.graphql(COLLECTION_BY_ID_QUERY, {"id": collection_id})
    node = data.get("collection")
    if not isinstance(node, dict) or not node.get("id"):
        return None
    return _collection_payload_from_node(node)


def _collection_payload_from_node(node: Dict[str, Any]) -> Dict[str, Any]:
    rule_set = node.get("ruleSet")
    return {
        "collection_id": str(node.get("id")),
        "handle": node.get("handle"),
        "title": node.get("title"),
        "collection_type": "smart" if rule_set else "manual",
        "rules_json": rule_set,
        "product_count": ((node.get("productsCount") or {}).get("count")),
        "raw_json": node,
    }


def ensure_collection_registry_row(
    session: Session,
    client: Any,
    collection_id: str,
    *,
    connection_id: Optional[int] = None,
    shop_code: Optional[str] = None,
) -> Optional[ShopifyCollectionRegistry]:
    """Load a collection registry row, fetching from Shopify when missing locally."""
    collection_id = str(collection_id or "").strip()
    if not collection_id:
        return None

    row = session.scalar(
        select(ShopifyCollectionRegistry)
        .where(ShopifyCollectionRegistry.collection_id == collection_id)
        .limit(1)
    )
    if row is not None:
        return row

    live = fetch_shopify_collection_by_id(client, collection_id)
    if live is None:
        return None

    effective_shop_code = shop_code or _shop_code_for_connection(session, connection_id)
    fetched_at = datetime.now(timezone.utc)
    row = ShopifyCollectionRegistry(
        shop_code=effective_shop_code,
        connection_id=connection_id,
        collection_id=collection_id,
        fetched_at=fetched_at,
    )
    session.add(row)
    row.handle = live.get("handle")
    row.title = live.get("title") or live.get("handle") or collection_id
    row.collection_type = live.get("collection_type") or "manual"
    row.rules_json = live.get("rules_json")
    row.product_count = live.get("product_count")
    row.raw_json = live.get("raw_json")
    row.fetched_at = fetched_at
    session.flush()
    return row


def find_shopify_collection_candidates(
    session: Session,
    connection_id: Optional[int],
    *,
    handle: str,
    title: Optional[str] = None,
    client: Optional[Any] = None,
) -> List[Dict[str, Any]]:
    """Find Shopify collections that may already exist for a taxonomy handle/title."""
    handle = str(handle or "").strip()
    if not handle:
        return []

    candidates: List[Dict[str, Any]] = []
    seen: set[str] = set()

    def add(row: Dict[str, Any], source: str) -> None:
        collection_id = str(row.get("collection_id") or row.get("remote_id") or "").strip()
        if not collection_id or collection_id in seen:
            return
        seen.add(collection_id)
        candidates.append(
            {
                "remote_id": collection_id,
                "handle": row.get("handle") or handle,
                "title": row.get("title") or title or handle,
                "collection_type": row.get("collection_type"),
                "product_count": row.get("product_count"),
                "source": source,
            }
        )

    stmt = select(ShopifyCollectionRegistry).where(ShopifyCollectionRegistry.handle == handle)
    if connection_id is not None:
        stmt = stmt.where(ShopifyCollectionRegistry.connection_id == connection_id)
    for row in session.scalars(stmt).all():
        add(
            {
                "collection_id": row.collection_id,
                "handle": row.handle,
                "title": row.title,
                "collection_type": row.collection_type,
                "product_count": row.product_count,
            },
            "registry",
        )

    if client is not None:
        try:
            live = fetch_shopify_collection_by_handle(client, handle)
        except Exception:
            live = None
        if live:
            add(live, "shopify_api")

    title_key = (title or "").strip().lower()
    if title_key and not any(c.get("title", "").strip().lower() == title_key for c in candidates):
        title_stmt = select(ShopifyCollectionRegistry).where(
            ShopifyCollectionRegistry.title.ilike(title_key)
        )
        if connection_id is not None:
            title_stmt = title_stmt.where(ShopifyCollectionRegistry.connection_id == connection_id)
        for row in session.scalars(title_stmt.limit(5)).all():
            add(
                {
                    "collection_id": row.collection_id,
                    "handle": row.handle,
                    "title": row.title,
                    "collection_type": row.collection_type,
                    "product_count": row.product_count,
                },
                "registry_title",
            )

    return candidates


def create_shopify_collection(
    session: Session,
    client: Any,
    *,
    shop_code: str,
    connection_id: Optional[int],
    title: str,
    handle: Optional[str] = None,
    description_html: Optional[str] = None,
) -> Dict[str, Any]:
    """Create a manual Shopify collection and register it locally."""
    title = str(title or "").strip()
    if not title:
        raise ValueError("title is required")
    collection_input: Dict[str, Any] = {"title": title}
    if handle:
        collection_input["handle"] = str(handle).strip()
    if description_html:
        collection_input["descriptionHtml"] = str(description_html).strip()

    data = client.graphql(COLLECTION_CREATE_MUTATION, {"input": collection_input})
    result = data.get("collectionCreate") or {}
    errors = result.get("userErrors") or []
    if errors:
        raise RuntimeError(f"collectionCreate userErrors: {errors}")
    collection = result.get("collection") or {}
    collection_id = str(collection.get("id") or "").strip()
    if not collection_id:
        raise RuntimeError("collectionCreate did not return collection id")

    fetched_at = datetime.now(timezone.utc)
    row = session.scalar(
        select(ShopifyCollectionRegistry)
        .where(ShopifyCollectionRegistry.collection_id == collection_id)
        .limit(1)
    )
    if row is None:
        row = ShopifyCollectionRegistry(
            shop_code=shop_code,
            connection_id=connection_id,
            collection_id=collection_id,
            fetched_at=fetched_at,
        )
        session.add(row)
    row.handle = collection.get("handle")
    row.title = collection.get("title") or title
    row.collection_type = "manual"
    row.rules_json = None
    row.product_count = 0
    row.raw_json = collection
    row.fetched_at = fetched_at
    session.flush()
    return {
        "collection_id": collection_id,
        "handle": row.handle,
        "title": row.title,
        "collection_type": "manual",
    }


def delete_shopify_collection(client: Any, *, collection_id: str) -> None:
    """Delete a Shopify collection by GID."""
    collection_id = str(collection_id or "").strip()
    if not collection_id:
        raise ValueError("collection_id is required")
    data = client.graphql(COLLECTION_DELETE_MUTATION, {"input": {"id": collection_id}})
    result = data.get("collectionDelete") or {}
    errors = result.get("userErrors") or []
    if errors:
        raise RuntimeError(f"collectionDelete userErrors: {errors}")


def assign_product_to_collections(
    session: Session,
    client: Any,
    *,
    product_id: str,
    master_sku: str,
    fields: Dict[str, Any],
    connection_id: Optional[int] = None,
    product_input: Optional[Dict[str, Any]] = None,
    taxonomy_category_id: Optional[str] = None,
    price: Optional[float] = None,
) -> CollectionAssignmentResult:
    """Reconcile a product to its canonical, locally managed Shopify collections."""
    if not product_id:
        return CollectionAssignmentResult()

    from db.product_channel_taxonomy import resolve_shopify_collection_ids

    desired: List[str] = []
    seen: set[str] = set()

    for collection_id in resolve_shopify_collection_ids(
        session,
        master_sku=master_sku,
        connection_id=connection_id,
    ):
        if collection_id in seen:
            continue
        seen.add(collection_id)
        desired.append(collection_id)

    # Legacy field/path mappings remain a fallback only for products that have
    # not yet entered the canonical master taxonomy workflow.
    from db.product_channel_taxonomy import sku_has_master_taxonomy_placements

    if not sku_has_master_taxonomy_placements(session, master_sku):
        for collection_id in _collections_from_path_mappings(
            session,
            fields=fields,
            connection_id=connection_id,
            client=client,
        ):
            if collection_id in seen:
                continue
            seen.add(collection_id)
            desired.append(collection_id)

    current = set(_product_collection_ids(client, product_id))
    managed = _managed_collection_ids_for_sku(
        session,
        master_sku=master_sku,
        connection_id=connection_id,
    )
    registry = _registry_by_collection_ids(session, desired, connection_id=connection_id)
    context = build_smart_collection_product_context(
        fields,
        product_input=product_input,
        taxonomy_category_id=taxonomy_category_id,
        channel_sku=master_sku,
        price=price,
    )
    warnings: List[Dict[str, Any]] = []

    for collection_id in sorted((current & managed) - set(desired)):
        _collection_remove_products(client, collection_id, [product_id])
    for collection_id in desired:
        row = registry.get(collection_id)
        if row is None:
            row = ensure_collection_registry_row(
                session,
                client,
                collection_id,
                connection_id=connection_id,
                shop_code=_shop_code_for_connection(session, connection_id),
            )
        if row is not None and row.collection_type == "smart":
            evaluation = evaluate_smart_collection_rules(row.rules_json, context)
            guidance = smart_collection_guidance(
                collection_title=row.title or row.handle or collection_id,
                collection_handle=row.handle,
                evaluation=evaluation,
            )
            if collection_id not in current and guidance.get("status") == "rule_mismatch":
                warnings.append(
                    {
                        "master_sku": master_sku,
                        "collection_id": collection_id,
                        **guidance,
                    }
                )
            continue
        if collection_id not in current:
            _collection_add_products(client, collection_id, [product_id])
    return CollectionAssignmentResult(collection_ids=desired, warnings=warnings)


def _registry_by_collection_ids(
    session: Session,
    collection_ids: List[str],
    *,
    connection_id: Optional[int],
) -> Dict[str, ShopifyCollectionRegistry]:
    if not collection_ids:
        return {}
    stmt = select(ShopifyCollectionRegistry).where(ShopifyCollectionRegistry.collection_id.in_(collection_ids))
    if connection_id is not None:
        scoped = stmt.where(ShopifyCollectionRegistry.connection_id == connection_id)
        rows = list(session.scalars(scoped).all())
        if rows:
            return {row.collection_id: row for row in rows}
    return {row.collection_id: row for row in session.scalars(stmt).all()}


def _product_collection_ids(client: Any, product_id: str) -> List[str]:
    data = client.graphql(PRODUCT_COLLECTIONS_QUERY, {"id": product_id})
    product = data.get("product") or {}
    connection = product.get("collections") or {}
    return [
        str(node.get("id"))
        for node in connection.get("nodes") or []
        if isinstance(node, dict) and node.get("id")
    ]


def _managed_collection_ids_for_sku(
    session: Session,
    *,
    master_sku: str,
    connection_id: Optional[int],
) -> set[str]:
    """Collections we may safely remove: prior local assignments that are manual."""
    from db.models import ProductChannelTaxonomyAssignment

    stmt = (
        select(ProductChannelTaxonomyAssignment.remote_id)
        .where(ProductChannelTaxonomyAssignment.master_sku == master_sku)
        .where(ProductChannelTaxonomyAssignment.channel_code == "shopify")
        .where(ProductChannelTaxonomyAssignment.taxonomy_kind == "collection")
    )
    if connection_id is not None:
        stmt = stmt.where(
            (ProductChannelTaxonomyAssignment.connection_id == connection_id)
            | (ProductChannelTaxonomyAssignment.connection_id.is_(None))
        )
    assigned = {str(value) for value in session.scalars(stmt).all() if value}
    if not assigned:
        return set()
    manual_stmt = select(ShopifyCollectionRegistry.collection_id).where(
        ShopifyCollectionRegistry.collection_id.in_(assigned),
        ShopifyCollectionRegistry.collection_type == "manual",
    )
    if connection_id is not None:
        manual_stmt = manual_stmt.where(ShopifyCollectionRegistry.connection_id == connection_id)
    return {str(value) for value in session.scalars(manual_stmt).all() if value}


def assign_product_to_mapped_collections(
    session: Session,
    client: Any,
    *,
    product_id: str,
    fields: Dict[str, Any],
    connection_id: Optional[int] = None,
    master_sku: Optional[str] = None,
) -> CollectionAssignmentResult:
    """Backward-compatible wrapper; prefers master_sku assignments when provided."""
    if master_sku:
        return assign_product_to_collections(
            session,
            client,
            product_id=product_id,
            master_sku=master_sku,
            fields=fields,
            connection_id=connection_id,
        )
    legacy_ids = _collections_from_path_mappings(
        session,
        fields=fields,
        connection_id=connection_id,
        client=client,
        apply=True,
        product_id=product_id,
    )
    return CollectionAssignmentResult(collection_ids=legacy_ids)


def _collections_from_path_mappings(
    session: Session,
    *,
    fields: Dict[str, Any],
    connection_id: Optional[int],
    client: Any,
    apply: bool = False,
    product_id: Optional[str] = None,
) -> List[str]:
    from db.channel_taxonomy import resolve_taxonomy_mappings_for_fields

    applied: List[str] = []
    mappings = resolve_taxonomy_mappings_for_fields(
        session,
        channel_code="shopify",
        fields=fields,
        connection_id=connection_id,
    )
    for mapping in mappings:
        rule = mapping.transform_rule or {}
        collection_id = rule.get("collection_id") or rule.get("remote_id") or mapping.channel_remote_id
        handle = rule.get("handle") or rule.get("collection_handle") or mapping.channel_handle_or_path
        if not collection_id and handle:
            collection_id = _lookup_collection_id(session, handle=handle)
        if not collection_id:
            collection_id = _create_manual_collection(
                client,
                session=session,
                shop_code=_shop_code_for_connection(session, connection_id),
                connection_id=connection_id,
                title=rule.get("title") or mapping.master_collection or handle,
            )
            if collection_id:
                mapping.channel_remote_id = collection_id
        if not collection_id:
            continue
        if apply and product_id:
            row = ensure_collection_registry_row(
                session,
                client,
                str(collection_id),
                connection_id=connection_id,
                shop_code=_shop_code_for_connection(session, connection_id),
            )
            if row is not None and row.collection_type == "smart":
                continue
            _collection_add_products(client, collection_id, [product_id])
        applied.append(collection_id)
    return applied


def _replace_registry(
    session: Session,
    *,
    shop_code: str,
    connection_id: Optional[int],
    rows: List[Dict[str, Any]],
    fetched_at: datetime,
) -> None:
    existing = {
        row.collection_id: row
        for row in session.scalars(
            select(ShopifyCollectionRegistry).where(ShopifyCollectionRegistry.shop_code == shop_code)
        ).all()
    }
    incoming_ids = set()
    for entry in rows:
        collection_id = str(entry.get("collection_id") or "").strip()
        if not collection_id:
            continue
        incoming_ids.add(collection_id)
        row = existing.get(collection_id)
        if row is None:
            row = ShopifyCollectionRegistry(
                shop_code=shop_code,
                connection_id=connection_id,
                collection_id=collection_id,
            )
            session.add(row)
        row.handle = entry.get("handle")
        row.title = entry.get("title") or entry.get("handle") or collection_id
        row.collection_type = entry.get("collection_type") or "manual"
        row.rules_json = entry.get("rules_json")
        row.product_count = entry.get("product_count")
        row.raw_json = entry.get("raw_json")
        row.fetched_at = fetched_at

    for collection_id, row in existing.items():
        if collection_id not in incoming_ids:
            session.delete(row)
    session.flush()


def _lookup_collection_id(session: Session, *, handle: str) -> Optional[str]:
    handle = str(handle or "").strip().lower()
    if not handle:
        return None
    row = session.scalar(
        select(ShopifyCollectionRegistry.collection_id)
        .where(ShopifyCollectionRegistry.handle == handle)
        .limit(1)
    )
    return row


def _create_manual_collection(
    client: Any,
    *,
    title: Optional[str],
    session: Optional[Session] = None,
    shop_code: Optional[str] = None,
    connection_id: Optional[int] = None,
) -> Optional[str]:
    title = str(title or "").strip()
    if not title:
        return None
    if session is not None and shop_code:
        created = create_shopify_collection(
            session,
            client,
            shop_code=shop_code,
            connection_id=connection_id,
            title=title,
        )
        return created.get("collection_id")
    data = client.graphql(
        COLLECTION_CREATE_MUTATION,
        {"input": {"title": title}},
    )
    result = data.get("collectionCreate") or {}
    errors = result.get("userErrors") or []
    if errors:
        raise RuntimeError(f"collectionCreate userErrors: {errors}")
    collection = result.get("collection") or {}
    return collection.get("id")


def _shop_code_for_connection(session: Session, connection_id: Optional[int]) -> str:
    if connection_id is None:
        return "default"
    from db.models import ShopifyConnection

    row = session.get(ShopifyConnection, connection_id)
    return row.shop_code if row else f"shopify-{connection_id}"


def _collection_add_products(client: Any, collection_id: str, product_ids: List[str]) -> None:
    data = client.graphql(
        COLLECTION_ADD_PRODUCTS_MUTATION,
        {"id": collection_id, "productIds": product_ids},
    )
    result = data.get("collectionAddProducts") or {}
    errors = result.get("userErrors") or []
    if errors:
        messages = " ".join(
            str(error.get("message") or error)
            for error in errors
            if isinstance(error, dict) or error
        )
        if "smart collection" in messages.lower():
            logger.warning(
                "Skipping manual add to smart collection %s: %s",
                collection_id,
                messages,
            )
            return
        raise RuntimeError(f"collectionAddProducts userErrors: {errors}")


def _collection_remove_products(client: Any, collection_id: str, product_ids: List[str]) -> None:
    data = client.graphql(
        COLLECTION_REMOVE_PRODUCTS_MUTATION,
        {"id": collection_id, "productIds": product_ids},
    )
    result = data.get("collectionRemoveProducts") or {}
    errors = result.get("userErrors") or []
    if errors:
        raise RuntimeError(f"collectionRemoveProducts userErrors: {errors}")
