"""Product imagery, from an openly licensed source.

We deliberately do not take retailer photography -- it is their copyright, and in
every comparable case the retailer won on imagery rather than on prices. Instead
we look product variants up in Open Food Facts by barcode. That database is
community-built and its photos are openly licensed, which makes them safe to
show with attribution, and every product we collect already carries the barcode
that keys it.

Brand-supplied assets remain the better long-term answer; this fills the gap
without waiting on anyone.
"""

import json
import logging
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import ProductVariant
from app.services import imagery
from app.services.normalize import name_tokens

logger = logging.getLogger(__name__)

API = "https://world.openfoodfacts.org/api/v2/product/{gtin}.json"
SEARCH_API = "https://world.openfoodfacts.org/cgi/search.pl"
FIELDS = "product_name,brands,image_front_url,image_front_small_url"
# Share of our product's distinctive words a candidate must carry before we
# accept its photo. A picture of the wrong bottle is worse than no picture.
MIN_NAME_OVERLAP = 0.6
USER_AGENT = "DutyFreeProfessorBot/0.1 (+https://bot.dutyfreeprofessor.com)"
ATTRIBUTION = "Open Food Facts"
LICENCE = "ODbL 1.0 (data); CC BY-SA 3.0 (photos)"
# Provenance is part of the attribution: a barcode match is a fact, a name
# match is a judgement, and the audit reports them separately. The words are the
# controlled vocabulary in services/imagery.py (Stream AW3).
SOURCE_BY_BARCODE = imagery.PUBLIC_OFF_BARCODE
SOURCE_BY_NAME = imagery.PUBLIC_OFF_NAME

_NUMBER_RE = re.compile(r"\d+")


def numbers_agree(wanted: set[str], got: set[str]) -> bool:
    """Every number in one name must appear in the other.

    Ages and editions are numbers ("12", "18", "1926"), and the word overlap
    test treats them as one token among many: a "Glenfiddich 12" and a
    "Glenfiddich 18" share every other word, so the 18's photo cleared the
    bar for the 12. Sizes are stripped before this runs, so they cannot veto.
    """
    ours = {t for t in wanted if _NUMBER_RE.fullmatch(t)}
    theirs = {t for t in got if _NUMBER_RE.fullmatch(t)}
    return ours == theirs


# Open Food Facts serves an image URL for every record it has, including ones
# whose barcode it could not parse -- those come back with the literal segment
# "/products/invalid/" and 404. Taking a URL on faith puts a broken-image icon
# in the middle of the product grid.
_OFF_IMAGE_RE = re.compile(r"/images/products/(?:\d{3}/)+\d+/|/images/products/\d+/")


def usable_image_url(url: str | None) -> str | None:
    """An OFF image URL we are willing to publish, or None."""
    if not url or not url.startswith("https://images.openfoodfacts.org/"):
        return url or None
    return url if _OFF_IMAGE_RE.search(url) else None


@dataclass(slots=True)
class ImageResult:
    image_url: str | None
    thumb_url: str | None


def lookup(gtin: str, *, timeout: int = 20) -> ImageResult:
    """Find an openly licensed photo for one barcode. Never raises."""
    payload = _fetch_json(f"{API.format(gtin=gtin)}?fields={FIELDS}", timeout=timeout)
    if not payload:
        return ImageResult(None, None)

    if payload.get("status") != 1:
        return ImageResult(None, None)
    product = payload.get("product") or {}
    return ImageResult(
        image_url=usable_image_url(product.get("image_front_url")),
        thumb_url=usable_image_url(
            product.get("image_front_small_url") or product.get("image_front_url")
        ),
    )


def _fetch_json(url: str, timeout: int = 20, attempts: int = 3) -> dict | None:
    """Fetch JSON, backing off when the service asks us to slow down.

    Open Food Facts answers bursts with 503. Without a backoff those look
    identical to "no such product", which silently halves image coverage.
    """
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    for attempt in range(attempts):
        try:
            with urllib.request.urlopen(request, timeout=timeout) as response:
                return json.load(response)
        except urllib.error.HTTPError as exc:
            if exc.code == 404:
                return None
            if exc.code in (429, 503) and attempt < attempts - 1:
                time.sleep(2.0 * (attempt + 1))
                continue
            logger.debug("image_http_error code=%s", exc.code)
            return None
        except Exception as exc:
            if attempt < attempts - 1:
                time.sleep(1.0)
                continue
            logger.debug("image_request_failed error=%s", type(exc).__name__)
            return None
    return None


def search_by_name(brand: str | None, name: str, *, timeout: int = 25) -> ImageResult:
    """Fall back to Open Food Facts' search when a barcode misses.

    Deliberately strict: the candidate must share the brand and most of the
    product's distinctive words, because an almost-right bottle photo is a
    worse error than a missing one.
    """
    # Build the query from distinctive words only. Sending the raw
    # "Tanqueray Tanqueray London Dry Gin 1L" returns nothing, because the
    # duplicated brand and the size are noise to a product search.
    wanted = name_tokens(f"{brand or ''} {name}")
    if not wanted:
        return ImageResult(None, None)
    terms = " ".join(sorted(wanted))

    query = urllib.parse.urlencode(
        {
            "search_terms": terms,
            "json": 1,
            "page_size": 5,
            "fields": FIELDS,
        }
    )
    payload = _fetch_json(f"{SEARCH_API}?{query}", timeout=timeout)
    if not payload:
        return ImageResult(None, None)

    # Open Food Facts' word for its rows is `products`: this is its payload, not our
    # vocabulary. The K1 rename swept the key to `product_variants` and the name fallback
    # found nothing since, without an error (Stream AW3.4; `test_images_public.py`).
    for candidate in payload.get("products") or []:
        thumb = usable_image_url(
            candidate.get("image_front_small_url") or candidate.get("image_front_url")
        )
        if not thumb:
            continue
        candidate_text = f"{candidate.get('brands') or ''} {candidate.get('product_name') or ''}"
        got = name_tokens(candidate_text)
        if not got:
            continue
        if brand and not (name_tokens(brand) & got):
            continue
        if not numbers_agree(wanted, got):
            continue
        overlap = len(wanted & got) / len(wanted)
        if overlap >= MIN_NAME_OVERLAP:
            return ImageResult(
                image_url=usable_image_url(candidate.get("image_front_url")) or thumb,
                thumb_url=thumb,
            )
    return ImageResult(None, None)


def enrich_product_variants(
    db: Session,
    *,
    limit: int | None = None,
    delay: float = 0.8,
    recheck: bool = False,
    by_name: bool = True,
) -> dict[str, int]:
    """Attach imagery: barcode lookup first, then a strict name search."""
    stmt = select(ProductVariant)
    if not recheck:
        stmt = stmt.where(ProductVariant.image_checked.is_(False))
    stmt = stmt.order_by(ProductVariant.id)
    if limit:
        stmt = stmt.limit(limit)

    stats = {"checked": 0, "found": 0, "by_barcode": 0, "by_name": 0, imagery.IMAGE_ADMIN_KEPT: 0}

    for product in db.scalars(stmt):
        result = lookup(product.gtin) if product.gtin else ImageResult(None, None)
        source = SOURCE_BY_BARCODE
        if result.thumb_url:
            stats["by_barcode"] += 1
        elif by_name:
            time.sleep(delay)
            result = search_by_name(product.brand, product.name)
            source = SOURCE_BY_NAME
            if result.thumb_url:
                stats["by_name"] += 1
        product.image_checked = True
        if result.thumb_url:
            # Through the one writer: a picture the client supplied is never replaced by a
            # fetched one, and every write carries its level and licence.
            outcome = imagery.set_image(
                product, url=result.image_url or result.thumb_url, thumb_url=result.thumb_url, source=source,
                level="variant", licence=LICENCE, attribution=ATTRIBUTION,
            )
            if outcome == imagery.IMAGE_ADMIN_KEPT:
                stats[imagery.IMAGE_ADMIN_KEPT] += 1
            else:
                stats["found"] += 1
        stats["checked"] += 1
        if stats["checked"] % 25 == 0:
            db.commit()
        time.sleep(delay)

    db.commit()
    logger.info("images_enriched %s", stats)
    return stats
