"""Proposes a field map for an unknown product API from one sample record.

The proposal is never trusted: every path is executed against the sample before
it is returned, so a merchant is never shown a mapping that would fail at sync
time. Nothing is stored here -- the caller approves a map before it is saved.

This runs once, on a source's first sync, and its result is cached in
product_sources.config.fields. Every sync after that executes the stored map
directly with zero model calls -- if inference ran per product, the mapping
could drift between products, content_hash would drift with it, and the whole
catalog would re-embed nightly for no reason.
"""
import json
import logging

from app.core.config import settings
from app.core.llm_client import client
from app.core.prompts import FIELD_MAP_PROPOSAL_PROMPT
from app.services.catalog.fieldmap import FieldMapError, resolve_path

logger = logging.getLogger(__name__)

TARGET_FIELDS = (
    "external_id", "title", "description", "brand", "raw_category",
    "image_url", "price", "discount_percentage", "availability", "sku",
    "tags", "related_ids", "cross_sells", "upsells", "rating", "review_count",
)

# Not asked of the model: neither appears in a payload that lacks them, so a
# request invites a confident, wrong answer. url_template is derived
# arithmetically from the base URL below; currency falls back to the tenant's
# existing one. Where neither works, the product is flagged incomplete rather
# than guessed.
NOT_MODELLED = ("url_template", "currency")

# A path can resolve and still be wrong: on one real API "$.reviews" resolved
# to a list of review objects, not a count, and reached a psycopg2 INT column.
# Resolving is not enough -- the resolved value must fit the target's storage.
FIELD_TYPES = {
    "external_id":         (str, int),
    "title":               (str,),
    "description":         (str,),
    "brand":               (str,),
    "raw_category":        (str,),
    "image_url":           (str,),
    "sku":                 (str, int),
    "availability":        (str,),
    "price":               (int, float, str),
    "discount_percentage": (int, float),
    "rating":              (int, float),
    "review_count":        (int,),
    "tags":                (list,),
    "related_ids":         (list,),
    "cross_sells":         (list,),
    "upsells":             (list,),
}

# bool is a subclass of int in Python, so an unguarded isinstance(x, (int, float))
# would let a True/False through into a numeric column as 1/0.
_NUMERIC_TARGETS = {
    target for target, types in FIELD_TYPES.items()
    if int in types or float in types
}


def summarise_record(record: dict, max_value_len: int = 80) -> dict:
    """Reduce a record to shape -- keys, types, one truncated example each.

    A real product record can be tens of kilobytes of description prose; that
    teaches the model nothing about structure and costs money on every token.
    """
    summary = {}
    for key, value in (record or {}).items():
        example = value
        if isinstance(value, str):
            example = value[:max_value_len]
        elif isinstance(value, list):
            example = value[:2]
        elif isinstance(value, dict):
            example = list(value)[:5]
        summary[key] = {"type": type(value).__name__, "example": example}
    return summary


def validate_proposal(proposal: dict, sample: dict) -> tuple[dict, list]:
    """Execute every proposed path against the sample; keep only what resolves
    AND fits the target's storage.

    A path resolving is not enough: on one real API "$.reviews" resolved
    cleanly to a list of review objects that was proposed for review_count,
    an INT column, and psycopg2 could not adapt it. Nothing the model says is
    trusted -- an unknown target name, a malformed path, a path that finds
    nothing, or a value of the wrong type is dropped rather than stored.
    """
    kept, dropped = {}, []
    for target, path in (proposal or {}).items():
        if target not in TARGET_FIELDS:
            dropped.append(target)
            continue
        try:
            value = resolve_path(sample, path)
        except FieldMapError:
            dropped.append(target)
            continue
        if value is None:
            dropped.append(target)
            continue

        expected = FIELD_TYPES.get(target)
        if expected is not None:
            is_bad_bool = target in _NUMERIC_TARGETS and isinstance(value, bool)
            if is_bad_bool or not isinstance(value, expected):
                logger.warning(
                    "Dropping field map %r -> %r: expected %s, got %s",
                    target, path, expected, type(value).__name__,
                )
                dropped.append(target)
                continue

        kept[target] = path
    return kept, sorted(dropped)


def _call_llm(summary: dict) -> dict:
    response = client.chat.completions.create(
        model=settings.LLM_MODEL,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system",
             "content": FIELD_MAP_PROPOSAL_PROMPT.format(
                 targets=", ".join(TARGET_FIELDS))},
            {"role": "user", "content": json.dumps(summary, default=str)},
        ],
    )
    return json.loads(response.choices[0].message.content)


def propose_field_map(sample: dict) -> dict:
    """Ask the model to map one sample record, then verify every answer.

    A model outage or a malformed response must leave the source connected and
    mappable by hand -- never break the sync, and never store an unvalidated
    guess.
    """
    try:
        raw = _call_llm(summarise_record(sample))
    except Exception:
        logger.error("Field-map proposal failed", exc_info=True)
        raw = {}

    if not isinstance(raw, dict):
        logger.warning("Field-map proposal was not an object; ignoring")
        raw = {}

    kept, dropped = validate_proposal(raw, sample)
    return {"fields": kept, "dropped": dropped}


def infer_url_template(base_url: str, fields: dict) -> str | None:
    """Derive a per-product URL from a collection endpoint.

    A REST collection almost always addresses one member by id, so
    ".../products" plus an id mapping yields ".../products/{external_id}".
    Without an id mapping there is nothing to substitute, and guessing would
    give every product a broken link -- so this returns None and those
    products are stored with a null URL instead.
    """
    if not base_url or "external_id" not in (fields or {}):
        return None
    root = base_url.split("?")[0].rstrip("/")
    return f"{root}/{{external_id}}"
