"""Offline tests for the Swiftly product-API client (R3-0).

All tests run against saved fixtures and mocked HTTP — no live network,
no Playwright/Chromium. Captured 2026-05-05 from a single live spike;
see ``.agent/context.md`` "Swiftly API" for the field-mapping rationale.
"""
from __future__ import annotations

import json
import sys
import uuid
from decimal import Decimal
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import requests

# Make `app.*` importable when pytest is invoked from the repo root.
BACKEND_DIR = Path(__file__).resolve().parent.parent
if str(BACKEND_DIR) not in sys.path:
    sys.path.insert(0, str(BACKEND_DIR))

from app.scraper.lucky_ca_scraper import (  # noqa: E402
    LuckyCaliforniaScraper,
    SwiftlyAuthError,
)

FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "lucky_ca"
CATEGORIES_HTML = FIXTURE_DIR / "categories.html"
CATEGORY_JSON = FIXTURE_DIR / "category_meat_seafood.json"


pytestmark = pytest.mark.scraper_offline


# ---------------------------------------------------------------------------
# Pure-parser tests against captured fixtures
# ---------------------------------------------------------------------------
def test_parse_categories_fixture() -> None:
    """Parser returns >=10 distinct API slugs from the captured page."""
    if not CATEGORIES_HTML.exists():
        pytest.skip(f"fixture missing: {CATEGORIES_HTML}")
    html = CATEGORIES_HTML.read_text(encoding="utf-8")

    slugs = LuckyCaliforniaScraper.parse_categories_html(html)

    assert len(slugs) >= 10, f"expected >=10 categories, got {len(slugs)}"
    assert len(set(slugs)) == len(slugs), "slugs must be deduplicated"
    # Every slug should look like `Product/<name>` per the API contract.
    for s in slugs:
        assert s.startswith("Product/"), f"unexpected slug shape: {s!r}"
    # Spot-check the one we know is in the captured snapshot.
    assert "Product/meat_seafood" in slugs


def test_parse_category_response_fixture() -> None:
    """Parser returns >=10 product dicts each with the mapped fields populated."""
    if not CATEGORY_JSON.exists():
        pytest.skip(f"fixture missing: {CATEGORY_JSON}")
    payload = json.loads(CATEGORY_JSON.read_text(encoding="utf-8"))

    raw_items = LuckyCaliforniaScraper.parse_category_response(payload)
    assert len(raw_items) >= 10, f"expected >=10 raw items, got {len(raw_items)}"

    mapped: list[dict] = []
    for raw in raw_items:
        m = LuckyCaliforniaScraper.map_product(
            raw, aisle="meat_seafood", source_slug="Product/meat_seafood"
        )
        if m is not None:
            mapped.append(m)

    assert len(mapped) >= 10, (
        f"expected >=10 mapped products, got {len(mapped)} "
        f"(from {len(raw_items)} raw)"
    )

    sample = mapped[0]
    # Required fields per the field-mapping contract.
    for key in (
        "external_id",
        "source",
        "name",
        "current_price",
        "regular_price",
        "is_on_sale",
        "image_url",
        "aisle",
    ):
        assert key in sample, f"missing key {key!r} in mapped product: {sample!r}"

    assert sample["source"] == "lucky_california"
    assert sample["aisle"] == "meat_seafood"
    assert isinstance(sample["external_id"], str) and sample["external_id"]
    assert isinstance(sample["name"], str) and sample["name"].strip()
    assert isinstance(sample["regular_price"], Decimal)
    assert sample["regular_price"] > 0
    assert isinstance(sample["is_on_sale"], bool)

    # Across the whole category at least SOME items should be on sale and
    # at least some should have a regular-only price (sanity for the parser).
    assert any(m["is_on_sale"] for m in mapped), "expected at least one sale item"
    assert any(not m["is_on_sale"] for m in mapped), "expected at least one reg-only item"


def test_map_product_returns_none_for_unparseable() -> None:
    """Products with no name AND no parseable price are dropped."""
    assert LuckyCaliforniaScraper.map_product({"name": ""}) is None
    assert (
        LuckyCaliforniaScraper.map_product(
            {"id": "x", "name": "Foo", "price": {"ok": {}}}
        )
        is None
    )


# ---------------------------------------------------------------------------
# 401 → SwiftlyAuthError → FAILED ScrapeLog
# ---------------------------------------------------------------------------
def _mock_response(status_code: int, payload=None) -> MagicMock:
    resp = MagicMock(spec=requests.Response)
    resp.status_code = status_code
    if payload is not None:
        resp.json.return_value = payload
    if status_code >= 400:
        resp.raise_for_status.side_effect = requests.HTTPError(
            f"{status_code} error", response=resp
        )
    else:
        resp.raise_for_status.return_value = None
    return resp


def test_swiftly_auth_error_on_401_from_api() -> None:
    """A 401 from the API host raises SwiftlyAuthError before raise_for_status."""
    scraper = LuckyCaliforniaScraper(bearer_token="stale-token")

    with patch.object(scraper.api_session, "get", return_value=_mock_response(401)):
        with pytest.raises(SwiftlyAuthError) as excinfo:
            scraper.fetch_category("Product/meat_seafood")

    assert "Swiftly returned 401" in str(excinfo.value)


def test_fetch_category_mints_token_when_none_pinned() -> None:
    """With no explicit bearer_token, fetch_category calls swiftly_auth.get_token()
    and threads the minted JWT into the Authorization header.
    """
    from app.scraper import lucky_ca_scraper as scraper_mod

    scraper = LuckyCaliforniaScraper()
    assert scraper.bearer_token is None  # auto-mint path

    with patch.object(scraper_mod, "_get_swiftly_token", return_value="minted-jwt") as mtoken, \
        patch.object(
            scraper.api_session,
            "get",
            return_value=_mock_response(
                200, payload={"products": {"items": []}}
            ),
        ) as mget:
        scraper.fetch_category("Product/meat_seafood")

    mtoken.assert_called_once()
    sent_headers = mget.call_args.kwargs["headers"]
    assert sent_headers["Authorization"] == "Bearer minted-jwt"


@pytest.mark.requires_postgres
def test_background_runner_writes_failed_with_token_message(monkeypatch):
    """A 401 during the background scrape lands in ScrapeLog as FAILED + message.

    Uses a real (non-fixture) session so the bg runner's rollback+re-query
    path mirrors production. The bg runner commits the FAILED row; we clean
    up explicitly at the end.
    """
    from app.models import ScrapeLog, ScrapeStatus
    from app.services import scraper_service
    from app.scraper.lucky_ca_scraper import SwiftlyAuthError, LuckyCaliforniaScraper
    from app.database import SessionLocal
    from datetime import datetime, timezone

    log_id = uuid.uuid4()
    setup_session = SessionLocal()
    try:
        setup_session.add(
            ScrapeLog(
                id=log_id,
                source="lucky_california",
                scrape_type="weekly_ad",
                status=ScrapeStatus.STARTED,
                started_at=datetime.now(timezone.utc),
            )
        )
        setup_session.commit()
    finally:
        setup_session.close()

    def _explode(self):
        raise SwiftlyAuthError(
            "SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user "
            "(capture from luckysupermarkets.com network tab on a /search/api/v1 request)"
        )

    monkeypatch.setattr(LuckyCaliforniaScraper, "fetch_all", _explode)

    try:
        scraper_service._run_scrape_in_background(
            log_id, "lucky_california", "weekly_ad"
        )

        verify_session = SessionLocal()
        try:
            refreshed = (
                verify_session.query(ScrapeLog)
                .filter(ScrapeLog.id == log_id)
                .first()
            )
            assert refreshed is not None
            assert refreshed.status == ScrapeStatus.FAILED
            assert "SWIFTLY_BEARER_TOKEN expired" in (refreshed.error_message or "")
            assert refreshed.completed_at is not None
        finally:
            verify_session.close()
    finally:
        cleanup_session = SessionLocal()
        try:
            cleanup_session.query(ScrapeLog).filter(ScrapeLog.id == log_id).delete()
            cleanup_session.commit()
        finally:
            cleanup_session.close()
