import json
import pathlib

import httpx
import pytest

from app.services.catalog import http as catalog_http
from app.services.catalog.http import (
    HttpSourceError, auth_headers, fetch_products, to_source_product,
)
from app.services.catalog.normalise import normalise

PAGE = json.loads(
    pathlib.Path("tests/fixtures/http/dummyjson-page.json").read_text())

CONFIG = {
    "base_url": "https://dummyjson.com/products",
    "records_path": "$.products",
    "pagination": {"style": "offset", "limit_param": "limit",
                   "offset_param": "skip", "page_size": 30,
                   "total_path": "$.total"},
    "fields": {
        "external_id": "$.id",
        "title": "$.title",
        "description": "$.description",
        "brand": "$.brand",
        "raw_category": "$.category",
        "image_url": "$.thumbnail",
        "price": "$.price",
        "discount_percentage": "$.discountPercentage",
        "availability": "$.availabilityStatus",
        "tags": "$.tags",
    },
    "url_template": "https://shop.example.com/product/{external_id}",
    "currency": "USD",
    "auth": {"style": "bearer"},
}

CONTEXT = {"shop_name": None, "primary_domain": None,
           "url_template": CONFIG["url_template"], "currency": "USD",
           "reference_currency": "USD", "known_brands": []}


def test_maps_a_record():
    sp = to_source_product(PAGE["products"][0], CONFIG, "dummyjson.com")
    assert sp["external_id"] == 1
    assert sp["title"] == "Essence Mascara Lash Princess"
    assert sp["brand"] == "Essence"
    assert sp["raw_category"] == "beauty"
    assert sp["source_kind"] == "http_api"
    assert sp["source_ref"] == "dummyjson.com"


def test_price_becomes_a_single_synthetic_variant():
    # The reference API has no variants; one record is one product.
    sp = to_source_product(PAGE["products"][0], CONFIG, "dummyjson.com")
    assert len(sp["variants"]) == 1
    assert sp["variants"][0]["price"] == 9.99


def test_discount_percentage_derives_compare_at():
    sp = to_source_product(PAGE["products"][0], CONFIG, "dummyjson.com")
    p = normalise(sp, CONTEXT)
    assert p["on_sale"] is True
    # 9.99 / (1 - 0.1048) == 11.16
    assert p["compare_at_cents"] == 1116


def test_availability_string_maps_to_stock():
    in_stock = to_source_product(PAGE["products"][0], CONFIG, "dummyjson.com")
    out = to_source_product(PAGE["products"][1], CONFIG, "dummyjson.com")
    assert normalise(in_stock, CONTEXT)["in_stock"] is True
    assert normalise(out, CONTEXT)["in_stock"] is False


def test_unrecognised_status_defaults_to_in_stock():
    # "Low Stock" is not in the out-of-stock set and is genuinely purchasable;
    # a deny-list default (only known-bad strings are unavailable) is correct
    # here, not a safety-first allow-list.
    record = dict(PAGE["products"][0], availabilityStatus="Low Stock")
    sp = to_source_product(record, CONFIG, "dummyjson.com")
    assert normalise(sp, CONTEXT)["in_stock"] is True


def test_discount_over_100_does_not_produce_negative_compare_at():
    record = dict(PAGE["products"][0], discountPercentage=150)
    sp = to_source_product(record, CONFIG, "dummyjson.com")
    assert sp["variants"][0]["compare_at_price"] is None


def test_url_comes_from_the_template():
    # The reference API returns no product URL at all; without a template every
    # record is rejected for having no resolvable URL.
    p = normalise(to_source_product(PAGE["products"][0], CONFIG,
                                    "dummyjson.com"), CONTEXT)
    assert p["product_url"] == "https://shop.example.com/product/1"


def test_missing_url_template_flags_everything_as_incomplete():
    # A missing URL is kept and flagged rather than rejected, so a source with
    # no URLs at all doesn't present the merchant with an empty catalog.
    ctx = dict(CONTEXT, url_template=None)
    p = normalise(to_source_product(PAGE["products"][0], CONFIG,
                                    "dummyjson.com"), ctx)
    assert p is not None
    assert p["product_url"] is None
    assert p["missing_fields"] == ["product_url"]


def test_currency_is_never_defaulted():
    with pytest.raises(HttpSourceError):
        to_source_product(PAGE["products"][0],
                          dict(CONFIG, currency=None), "dummyjson.com")


def test_auth_headers_bearer():
    assert auth_headers({"api_key": "k"}, {"auth": {"style": "bearer"}}) == \
        {"Authorization": "Bearer k"}


def test_auth_headers_api_key_header():
    assert auth_headers(
        {"api_key": "k"},
        {"auth": {"style": "header", "header": "X-API-Key"}}) == {"X-API-Key": "k"}


def test_auth_headers_none():
    assert auth_headers({}, {"auth": {"style": "none"}}) == {}


@pytest.mark.asyncio
async def test_fetch_follows_offset_pagination():
    seen = []

    def handler(request):
        seen.append(str(request.url))
        skip = int(dict(request.url.params).get("skip", 0))
        if skip == 0:
            return httpx.Response(200, json={"products": [{"id": 1}], "total": 2})
        return httpx.Response(200, json={"products": [{"id": 2}], "total": 2})

    cfg = dict(CONFIG)
    cfg["pagination"] = dict(CONFIG["pagination"], page_size=1)
    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c:
        out = await fetch_products(cfg, {"api_key": "k"}, "dummyjson.com", client=c)

    assert [r["id"] for r in out] == [1, 2]
    assert len(seen) == 2


@pytest.mark.asyncio
async def test_fetch_raises_on_non_200():
    def handler(request):
        return httpx.Response(401, text="unauthorized")

    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c:
        with pytest.raises(HttpSourceError):
            await fetch_products(CONFIG, {"api_key": "k"}, "x", client=c)


@pytest.mark.asyncio
async def test_fetch_raises_instead_of_hanging_when_total_never_completes(
        monkeypatch):
    # An API that always returns a full page and never reports a total must
    # not loop forever; the MAX_PAGES cap has to trip and raise instead.
    monkeypatch.setattr(catalog_http, "MAX_PAGES", 3)

    def handler(request):
        return httpx.Response(200, json={"products": [{"id": 1}]})

    cfg = dict(CONFIG)
    cfg["pagination"] = dict(CONFIG["pagination"], page_size=1, total_path=None)
    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c:
        with pytest.raises(HttpSourceError):
            await fetch_products(cfg, {"api_key": "k"}, "dummyjson.com", client=c)


@pytest.mark.asyncio
async def test_fetch_raises_on_non_numeric_total():
    def handler(request):
        return httpx.Response(
            200, json={"products": [{"id": 1}], "total": "unknown"})

    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c:
        with pytest.raises(HttpSourceError):
            await fetch_products(CONFIG, {"api_key": "k"}, "dummyjson.com", client=c)


def test_relations_and_prominence_map_from_config():
    cfg = dict(CONFIG)
    cfg["fields"] = dict(CONFIG["fields"], related_ids="$.related",
                         cross_sells="$.crossSells", rating="$.rating",
                         review_count="$.reviewCount")
    record = dict(PAGE["products"][0], related=[7, 8], crossSells=[9],
                  rating=4.5, reviewCount=120)

    sp = to_source_product(record, cfg, "dummyjson.com")

    assert sp["tenant_relations"] == {"related": ["7", "8"], "cross_sells": ["9"]}
    assert sp["rating"] == 4.5
    assert sp["review_count"] == 120


def test_unmapped_relations_yield_an_empty_object():
    sp = to_source_product(PAGE["products"][0], CONFIG, "dummyjson.com")
    assert sp["tenant_relations"] == {}
    assert sp["rating"] is None
