"""Fetches a catalog from an authenticated HTTP product API.

Unlike Shopify, the shape is unknown, so the mapping comes from the source's
stored config. C's LLM authors that config; this module only executes it.
"""
import logging

import httpx

from app.services.catalog.fieldmap import apply_field_map, resolve_path
from app.services.catalog.fieldmap_proposer import infer_url_template, propose_field_map
from app.services.catalog.sources import KIND_HTTP_API, update_source_config
from app.services.infra.ssrf import assert_safe_url

logger = logging.getLogger(__name__)

# Allow-list, not deny-list: real APIs use qualifier statuses like "Low Stock"
# that still mean purchasable. Defaulting unknown strings to out-of-stock
# would silently hide every such product instead of only the ones confirmed
# unavailable.
_OUT_OF_STOCK = {"out of stock", "outofstock", "unavailable", "sold out"}

# At the default page size this is far beyond any real catalog, so it can
# only trip when an API never reports completion -- better to fail loudly
# than loop forever.
MAX_PAGES = 1000


class HttpSourceError(Exception):
    """The source is misconfigured or unreachable."""


def auth_headers(credentials: dict, config: dict) -> dict:
    auth = config.get("auth") or {}
    style = auth.get("style", "none")
    if style == "none":
        return {}
    key = credentials.get("api_key") or credentials.get("access_token")
    if not key:
        raise HttpSourceError("auth style requires a key, none stored")
    if style == "bearer":
        return {"Authorization": f"Bearer {key}"}
    if style == "header":
        header = auth.get("header")
        if not header:
            raise HttpSourceError("header auth style requires a header name")
        return {header: key}
    raise HttpSourceError(f"unknown auth style: {style}")


def to_source_product(record: dict, config: dict, source_ref: str, *,
                      require_currency: bool = True) -> dict:
    # No API in this class reliably carries a currency, and guessing it
    # silently mis-prices an entire catalog -- so by default this refuses to
    # proceed without one. require_currency=False is for a caller that has
    # already tried every fallback (a merchant override, then the tenant's
    # established reference currency) and wants the product produced anyway,
    # to be flagged incomplete downstream rather than guessed here.
    if not config.get("currency") and require_currency:
        raise HttpSourceError("source config must declare a currency")

    mapped = apply_field_map(record, config["fields"])

    price = mapped.get("price")
    discount = mapped.get("discount_percentage")
    compare_at = None
    if price is not None and discount:
        try:
            discount = float(discount)
            if 0 < discount < 100:
                compare_at = round(float(price) / (1 - discount / 100), 2)
        except (TypeError, ValueError, ZeroDivisionError):
            compare_at = None

    availability = (mapped.get("availability") or "").strip().lower()
    available = availability not in _OUT_OF_STOCK

    relations = {}
    for target, key in (("related_ids", "related"),
                        ("cross_sells", "cross_sells"),
                        ("upsells", "upsells")):
        values = mapped.get(target)
        if isinstance(values, list) and values:
            relations[key] = [str(v) for v in values]

    return {
        "external_id": mapped.get("external_id"),
        "title": mapped.get("title"),
        "description": mapped.get("description"),
        "brand": mapped.get("brand"),
        "handle": None,
        "product_url": mapped.get("product_url"),
        "image_url": mapped.get("image_url"),
        "raw_category": mapped.get("raw_category"),
        "product_type": None,
        "tags": mapped.get("tags") or [],
        "collections": [],
        "status": "ACTIVE",
        "tracks_inventory": True,
        "variants": [{
            "sku": mapped.get("sku"),
            "price": price,
            "compare_at_price": compare_at,
            "available": available,
            "inventory_quantity": None,
            "options": {},
        }],
        "attributes_raw": [],
        "source_kind": "http_api",
        "source_ref": source_ref,
        "tenant_relations": relations,
        "rating": mapped.get("rating"),
        "review_count": mapped.get("review_count"),
        "featured_rank": mapped.get("featured_rank"),
    }


def _infer_and_cache_fields(config: dict, sample: dict, source_ref: str) -> None:
    """Infer this source's field map from its first record and store it.

    Runs once, on a source's first sync: config["fields"] being populated is
    what tells every sync after this one to skip inference and its model
    call entirely, so the result is written back to the row here rather than
    left to the caller.
    """
    proposal = propose_field_map(sample)
    config["fields"] = proposal["fields"]
    if not config.get("url_template"):
        config["url_template"] = infer_url_template(
            config.get("base_url"), config["fields"])
    update_source_config(KIND_HTTP_API, source_ref, config)


async def fetch_products(config: dict, credentials: dict, source_ref: str,
                         *, client: httpx.AsyncClient | None = None) -> list[dict]:
    # Every page or nothing: a partial fetch must not be mistaken for a
    # complete catalog, or the sync would delete products it simply never saw.
    pg = config.get("pagination") or {}
    if pg.get("style") != "offset":
        raise HttpSourceError(f"unsupported pagination style: {pg.get('style')}")

    base = config.get("base_url")
    if not base:
        raise HttpSourceError("source config must declare a base_url")

    # A stored base URL is as user-supplied as a freshly submitted one, and
    # it is re-fetched on every scheduled sync -- guarding only the connect
    # path would leave this one wide open.
    assert_safe_url(base)

    size = int(pg.get("page_size", 30))
    headers = auth_headers(credentials, config)

    owned = client is None
    c = client or httpx.AsyncClient(timeout=60.0)
    records, offset = [], 0
    try:
        for _ in range(MAX_PAGES):
            resp = await c.get(base, headers=headers, params={
                pg.get("limit_param", "limit"): size,
                pg.get("offset_param", "skip"): offset,
            })
            if resp.status_code != 200:
                logger.error("Catalog fetch for %s failed: HTTP %s",
                             source_ref, resp.status_code)
                raise HttpSourceError(f"HTTP {resp.status_code}")

            body = resp.json()
            page = resolve_path(body, config["records_path"]) or []

            if offset == 0 and not config.get("fields") and page:
                _infer_and_cache_fields(config, page[0], source_ref)

            records.extend(page)

            total = resolve_path(body, pg["total_path"]) if pg.get("total_path") else None
            if total is not None:
                try:
                    total = int(total)
                except (TypeError, ValueError):
                    raise HttpSourceError(
                        f"non-numeric total from {source_ref}: {total!r}")

            offset += size
            if not page or (total is not None and offset >= total):
                return records

        # A well-behaved API always reports an empty page or a total; one
        # that keeps returning full pages forever must fail loudly rather
        # than hang the sync indefinitely.
        raise HttpSourceError(
            f"{source_ref} exceeded MAX_PAGES ({MAX_PAGES}) without completing")
    finally:
        if owned:
            await c.aclose()
