import inspect

import pytest

from app.services.catalog import sync as catalog_sync


def test_delete_never_runs_without_a_source_ref():
    # A blank ref would match every row of this kind across every source.
    # Checked before any connection is opened, so this needs no database.
    with pytest.raises(ValueError):
        catalog_sync.delete_stale("t", "shopify", None, "2026-01-01")
    with pytest.raises(ValueError):
        catalog_sync.delete_stale("t", "shopify", "", "2026-01-01")


@pytest.mark.asyncio
async def test_partial_fetch_deletes_nothing(monkeypatch):
    # A failure mid-fetch must not be mistaken for "these products are gone".
    async def boom(*a, **kw):
        raise RuntimeError("network died on page 2")

    deleted = []
    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", boom)
    monkeypatch.setattr(catalog_sync, "delete_stale",
                        lambda *a, **kw: deleted.append(a))
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})

    with pytest.raises(RuntimeError):
        await catalog_sync.sync_source("t", {
            "kind": "shopify", "external_ref": "s.myshopify.com",
            "config": {"shop_name": "S", "primary_domain": "https://s.com",
                       "currency": "INR"}})

    assert deleted == []


@pytest.mark.asyncio
async def test_rejected_products_are_counted_not_fatal(monkeypatch):
    async def two_nodes(*a, **kw):
        return [
            {"id": "gid://1", "title": "A Real Snowboard", "handle": "a",
             "status": "ACTIVE", "productType": "snowboard", "vendor": "V",
             "tags": [], "onlineStoreUrl": None, "description": None,
             "category": None, "featuredImage": None,
             "collections": {"nodes": []}, "tracksInventory": True,
             "variants": {"nodes": [{"price": "10.00", "compareAtPrice": None,
                                     "availableForSale": True, "sku": None,
                                     "inventoryQuantity": 1,
                                     "selectedOptions": []}]},
             "metafields": {"nodes": []}},
            {"id": None, "title": None, "handle": None, "status": "ACTIVE",
             "productType": None, "vendor": None, "tags": [],
             "onlineStoreUrl": None, "description": None, "category": None,
             "featuredImage": None, "collections": {"nodes": []},
             "tracksInventory": True, "variants": {"nodes": []},
             "metafields": {"nodes": []}},
        ]

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", two_nodes)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})
    monkeypatch.setattr(catalog_sync, "persist",
                        lambda *a, **kw: {"saved": 1})
    monkeypatch.setattr(catalog_sync, "delete_stale", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    monkeypatch.setattr(catalog_sync, "get_setting", lambda *a: "INR")

    out = await catalog_sync.sync_source("t", {
        "kind": "shopify", "external_ref": "s.myshopify.com",
        "config": {"shop_name": "S", "primary_domain": "https://s.com",
                   "currency": "INR"}})

    assert out["fetched"] == 2
    assert out["normalised"] == 1
    assert out["rejected"] == 1
    assert out["rejection_rate"] == 0.5


@pytest.mark.asyncio
async def test_incomplete_products_are_counted_in_the_report(monkeypatch):
    async def one_urlless_node(*a, **kw):
        return [
            {"id": "gid://1", "title": "A Real Snowboard", "handle": None,
             "status": "ACTIVE", "productType": "snowboard", "vendor": "V",
             "tags": [], "onlineStoreUrl": None, "description": None,
             "category": None, "featuredImage": None,
             "collections": {"nodes": []}, "tracksInventory": True,
             "variants": {"nodes": [{"price": "10.00", "compareAtPrice": None,
                                     "availableForSale": True, "sku": None,
                                     "inventoryQuantity": 1,
                                     "selectedOptions": []}]},
             "metafields": {"nodes": []}},
        ]

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products",
                        one_urlless_node)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})
    monkeypatch.setattr(catalog_sync, "persist", lambda *a, **kw: {"saved": 1})
    monkeypatch.setattr(catalog_sync, "delete_stale", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    monkeypatch.setattr(catalog_sync, "get_setting", lambda *a: "INR")

    out = await catalog_sync.sync_source("t", {
        "kind": "shopify", "external_ref": "s.myshopify.com",
        "config": {"shop_name": "S", "primary_domain": "https://s.com",
                   "currency": "INR"}})

    # No handle and no onlineStoreUrl means no URL can be resolved, but the
    # product must still be kept -- only flagged and counted, not rejected.
    assert out["fetched"] == 1
    assert out["normalised"] == 1
    assert out["rejected"] == 0
    assert out["incomplete"] == 1


@pytest.mark.asyncio
async def test_reference_currency_is_recorded_once_by_the_first_source(monkeypatch):
    # The first connected source defines the tenant's reference currency.
    # Recomputing it on every sync would let it drift once a second source
    # in a different currency arrives, silently invalidating price
    # comparisons made against the original currency.
    async def one_node(*a, **kw):
        return [
            {"id": "gid://1", "title": "A Real Snowboard", "handle": "a",
             "status": "ACTIVE", "productType": "snowboard", "vendor": "V",
             "tags": [], "onlineStoreUrl": None, "description": None,
             "category": None, "featuredImage": None,
             "collections": {"nodes": []}, "tracksInventory": True,
             "variants": {"nodes": [{"price": "10.00", "compareAtPrice": None,
                                     "availableForSale": True, "sku": None,
                                     "inventoryQuantity": 1,
                                     "selectedOptions": []}]},
             "metafields": {"nodes": []}},
        ]

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", one_node)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})
    monkeypatch.setattr(catalog_sync, "persist", lambda *a, **kw: {"saved": 1})
    monkeypatch.setattr(catalog_sync, "delete_stale", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    monkeypatch.setattr(catalog_sync, "get_setting", lambda *a: None)
    recorded = []
    monkeypatch.setattr(
        catalog_sync, "update_setting",
        lambda tenant, key, merge: recorded.append((tenant, key, merge(None))))

    await catalog_sync.sync_source("t", {
        "kind": "shopify", "external_ref": "s.myshopify.com",
        "config": {"shop_name": "S", "primary_domain": "https://s.com",
                   "currency": "INR"}})

    assert recorded == [("t", "reference_currency", "INR")]


@pytest.mark.asyncio
async def test_reference_currency_is_not_recomputed_once_set(monkeypatch):
    async def one_node(*a, **kw):
        return [
            {"id": "gid://1", "title": "A Real Snowboard", "handle": "a",
             "status": "ACTIVE", "productType": "snowboard", "vendor": "V",
             "tags": [], "onlineStoreUrl": None, "description": None,
             "category": None, "featuredImage": None,
             "collections": {"nodes": []}, "tracksInventory": True,
             "variants": {"nodes": [{"price": "10.00", "compareAtPrice": None,
                                     "availableForSale": True, "sku": None,
                                     "inventoryQuantity": 1,
                                     "selectedOptions": []}]},
             "metafields": {"nodes": []}},
        ]

    from app.services.catalog import normalise as catalog_normalise

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", one_node)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})
    monkeypatch.setattr(catalog_sync, "persist", lambda *a, **kw: {"saved": 1})
    monkeypatch.setattr(catalog_sync, "delete_stale", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    # A USD product converting against an INR reference would otherwise hit
    # a real fx lookup; irrelevant to what this test is checking.
    monkeypatch.setattr(catalog_normalise, "to_reference_cents",
                        lambda *a: (1000, 0.012))
    # Already INR from an earlier sync; a second source priced in USD must
    # not flip it.
    monkeypatch.setattr(catalog_sync, "get_setting", lambda *a: "INR")
    updated = []
    monkeypatch.setattr(catalog_sync, "update_setting",
                        lambda *a: updated.append(a))

    await catalog_sync.sync_source("t", {
        "kind": "shopify", "external_ref": "s2.myshopify.com",
        "config": {"shop_name": "S2", "primary_domain": "https://s2.com",
                   "currency": "USD"}})

    assert updated == []


@pytest.mark.asyncio
async def test_a_source_without_a_currency_does_not_borrow_the_tenants(
        monkeypatch):
    # Assuming a source shares the tenant's currency mis-priced 194 real
    # products by ~83x on a live connection. Unknown must stay unknown.
    import json
    import pathlib

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

    async def one_page(*a, **kw):
        return [page["products"][0]]

    config = {
        "base_url": "https://dummyjson.com/products",
        "fields": {"external_id": "$.id", "title": "$.title", "price": "$.price"},
        "url_template": "https://dummyjson.com/products/{external_id}",
        # Deliberately no currency override.
    }

    persisted = []
    monkeypatch.setattr(catalog_sync.catalog_http, "fetch_products", one_page)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"api_key": "k"})
    monkeypatch.setattr(
        catalog_sync, "persist",
        lambda t, products, *a, **kw: (persisted.extend(products),
                                       {"saved": len(products)})[1])
    monkeypatch.setattr(catalog_sync, "delete_stale", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    # The tenant already has a reference currency from an earlier, unrelated
    # source -- exactly the value that must NOT leak into this one.
    monkeypatch.setattr(catalog_sync, "get_setting", lambda *a: "INR")

    out = await catalog_sync.sync_source("t", {
        "kind": "http_api", "external_ref": "dummyjson.com", "config": config})

    assert out["incomplete"] == 1
    assert persisted[0]["currency"] is None
    assert "price_reference" in persisted[0]["missing_fields"]


def test_source_status_helpers_exist():
    from app.services.catalog import sources as product_sources
    assert hasattr(product_sources, "mark_source_status")
    assert hasattr(product_sources, "touch_last_synced")


@pytest.mark.asyncio
async def test_rejected_token_marks_the_source_as_error(monkeypatch):
    from app.services.integrations.shopify_oauth import ShopQueryError

    marked = []

    async def unauthorised(*a, **kw):
        raise ShopQueryError("HTTP 401")

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", unauthorised)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_dead"})
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    monkeypatch.setattr(catalog_sync, "mark_source_status",
                        lambda *a: marked.append(a))

    with pytest.raises(ShopQueryError):
        await catalog_sync.sync_source("t", {
            "kind": "shopify", "external_ref": "s.myshopify.com",
            "config": {"shop_name": "S", "primary_domain": "https://s.com",
                       "currency": "INR"}})

    assert marked, "a rejected token must mark the source as error"
    assert marked[0][2] == "error"


@pytest.mark.asyncio
async def test_throttled_request_does_not_mark_the_source_as_error(monkeypatch):
    # A routine 429 (or a 503 outage) must not disable the source -- only an
    # auth failure (401/403) should, since anything else is worth retrying
    # on the next scheduled run without forcing the merchant through OAuth again.
    from app.services.integrations.shopify_oauth import ShopQueryError

    marked = []

    async def throttled(*a, **kw):
        raise ShopQueryError("HTTP 429")

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", throttled)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    monkeypatch.setattr(catalog_sync, "mark_source_status",
                        lambda *a: marked.append(a))

    with pytest.raises(ShopQueryError):
        await catalog_sync.sync_source("t", {
            "kind": "shopify", "external_ref": "s.myshopify.com",
            "config": {"shop_name": "S", "primary_domain": "https://s.com",
                       "currency": "INR"}})

    assert not marked, "a throttle must not mark the source as error"


@pytest.mark.asyncio
async def test_successful_sync_touches_last_synced_at(monkeypatch):
    touched = []

    async def none_at_all(*a, **kw):
        return []

    monkeypatch.setattr(catalog_sync.catalog_shopify, "fetch_products", none_at_all)
    monkeypatch.setattr(catalog_sync, "get_credentials",
                        lambda *a: {"access_token": "shpat_x"})
    monkeypatch.setattr(catalog_sync, "migrate_products_table", lambda t: None)
    monkeypatch.setattr(catalog_sync, "count_new_products", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "check_quota", lambda *a, **kw: None)
    monkeypatch.setattr(catalog_sync, "persist", lambda *a, **kw: {"saved": 0})
    monkeypatch.setattr(catalog_sync, "delete_stale", lambda *a, **kw: 0)
    monkeypatch.setattr(catalog_sync, "get_setting", lambda *a: "INR")
    monkeypatch.setattr(catalog_sync, "touch_last_synced",
                        lambda *a: touched.append(a))

    await catalog_sync.sync_source("t", {
        "kind": "shopify", "external_ref": "s.myshopify.com",
        "config": {"shop_name": "S", "primary_domain": "https://s.com",
                   "currency": "INR"}})

    assert touched, "a completed sync must record last_synced_at"
