"""
Tests for the affiliate product system.

Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer,
program CRUD, build_affiliate_url(), program-based redirect.
"""
import json
from datetime import date
from unittest.mock import patch

import pytest
from padelnomics.affiliate import (
    build_affiliate_url,
    get_all_products,
    get_all_programs,
    get_click_counts,
    get_click_stats,
    get_product,
    get_products_by_category,
    get_program,
    get_program_by_slug,
    hash_ip,
    log_click,
)
from padelnomics.content.routes import PRODUCT_GROUP_RE, PRODUCT_RE, bake_product_cards
from padelnomics.core import execute, fetch_all

# ── Helpers ────────────────────────────────────────────────────────────────────

async def _insert_product(
    slug="test-racket-amazon",
    name="Test Racket",
    brand="TestBrand",
    category="racket",
    retailer="Amazon",
    affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
    status="active",
    language="de",
    price_cents=14999,
    pros=None,
    cons=None,
    sort_order=0,
) -> int:
    """Insert an affiliate product, return its id."""
    return await execute(
        """INSERT INTO affiliate_products
           (slug, name, brand, category, retailer, affiliate_url,
            price_cents, currency, status, language, pros, cons, sort_order)
           VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
        (
            slug, name, brand, category, retailer, affiliate_url,
            price_cents, status, language,
            json.dumps(pros or ["Gut"]),
            json.dumps(cons or ["Teuer"]),
            sort_order,
        ),
    )


# ── hash_ip ────────────────────────────────────────────────────────────────────

def test_hash_ip_deterministic():
    """Same IP + same day → same hash."""
    h1 = hash_ip("1.2.3.4")
    h2 = hash_ip("1.2.3.4")
    assert h1 == h2
    assert len(h1) == 64  # SHA256 hex digest


def test_hash_ip_different_ips_differ():
    """Different IPs → different hashes."""
    assert hash_ip("1.2.3.4") != hash_ip("5.6.7.8")


def test_hash_ip_rotates_daily():
    """Different days → different hashes for same IP (GDPR daily rotation)."""
    with patch("padelnomics.affiliate.date") as mock_date:
        mock_date.today.return_value = date(2026, 2, 1)
        h1 = hash_ip("1.2.3.4")
        mock_date.today.return_value = date(2026, 2, 2)
        h2 = hash_ip("1.2.3.4")
    assert h1 != h2


# ── get_product ────────────────────────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_get_product_active_by_lang(db):
    """get_product returns active product for correct language."""
    await _insert_product(slug="vertex-amazon", language="de", status="active")
    product = await get_product("vertex-amazon", "de")
    assert product is not None
    assert product["slug"] == "vertex-amazon"
    assert isinstance(product["pros"], list)


@pytest.mark.usefixtures("db")
async def test_get_product_draft_returns_none(db):
    """Draft products are not returned."""
    await _insert_product(slug="vertex-draft", status="draft")
    product = await get_product("vertex-draft", "de")
    assert product is None


@pytest.mark.usefixtures("db")
async def test_get_product_lang_fallback(db):
    """Falls back to any language when no match for requested lang."""
    await _insert_product(slug="vertex-de-only", language="de", status="active")
    # Request EN but only DE exists — should fall back
    product = await get_product("vertex-de-only", "en")
    assert product is not None
    assert product["language"] == "de"


@pytest.mark.usefixtures("db")
async def test_get_product_not_found(db):
    """Returns None for unknown slug."""
    product = await get_product("nonexistent-slug", "de")
    assert product is None


# ── get_products_by_category ───────────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_get_products_by_category_sorted(db):
    """Returns products sorted by sort_order."""
    await _insert_product(slug="racket-b", name="Racket B", sort_order=2)
    await _insert_product(slug="racket-a", name="Racket A", sort_order=1)
    products = await get_products_by_category("racket", "de")
    assert len(products) == 2
    assert products[0]["sort_order"] == 1
    assert products[1]["sort_order"] == 2


@pytest.mark.usefixtures("db")
async def test_get_products_by_category_inactive_excluded(db):
    """Draft and archived products are excluded."""
    await _insert_product(slug="racket-draft", status="draft")
    await _insert_product(slug="racket-archived", status="archived")
    products = await get_products_by_category("racket", "de")
    assert products == []


# ── get_all_products ───────────────────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_get_all_products_no_filter(db):
    """Returns all products regardless of status."""
    await _insert_product(slug="p1", status="active")
    await _insert_product(slug="p2", status="draft")
    products = await get_all_products()
    assert len(products) == 2


@pytest.mark.usefixtures("db")
async def test_get_all_products_status_filter(db):
    """Status filter returns only matching rows."""
    await _insert_product(slug="p-active", status="active")
    await _insert_product(slug="p-draft", status="draft")
    active = await get_all_products(status="active")
    assert len(active) == 1
    assert active[0]["slug"] == "p-active"


# ── log_click + get_click_counts ──────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_log_click_inserts_row(db):
    """log_click inserts a row into affiliate_clicks."""
    product_id = await _insert_product(slug="clickable")
    await log_click(product_id, "1.2.3.4", "beste-padelschlaeger", "https://example.com/de/blog/test")
    rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (product_id,))
    assert len(rows) == 1
    assert rows[0]["article_slug"] == "beste-padelschlaeger"
    # IP hash must not be the raw IP
    assert rows[0]["ip_hash"] != "1.2.3.4"


@pytest.mark.usefixtures("db")
async def test_get_click_counts(db):
    """get_click_counts returns dict of product_id → count."""
    pid = await _insert_product(slug="tracked-product")
    await log_click(pid, "1.2.3.4", None, None)
    await log_click(pid, "5.6.7.8", None, None)
    counts = await get_click_counts()
    assert counts.get(pid) == 2


# ── get_click_stats ────────────────────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_get_click_stats_structure(db):
    """get_click_stats returns expected keys."""
    stats = await get_click_stats(days_count=30)
    assert "total_clicks" in stats
    assert "top_products" in stats
    assert "daily_bars" in stats
    assert "by_retailer" in stats


# ── bake_product_cards ────────────────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_bake_product_cards_replaces_marker(db):
    """[product:slug] marker is replaced with rendered HTML."""
    await _insert_product(slug="vertex-04-amazon", name="Bullpadel Vertex 04", status="active")
    html = "<p>Intro</p>\n[product:vertex-04-amazon]\n<p>Outro</p>"
    result = await bake_product_cards(html, lang="de")
    assert "[product:vertex-04-amazon]" not in result
    assert "Bullpadel Vertex 04" in result
    assert "/go/vertex-04-amazon" in result
    assert "sponsored" in result


@pytest.mark.usefixtures("db")
async def test_bake_product_cards_missing_slug_passthrough(db):
    """Unknown slugs pass through unchanged — no product card rendered."""
    html = "<p>Text</p>\n[product:nonexistent-slug]\n<p>End</p>"
    result = await bake_product_cards(html, lang="de")
    # Surrounding content is intact; no product HTML injected
    assert "<p>Text</p>" in result
    assert "<p>End</p>" in result
    assert "<article" not in result  # no product card rendered


@pytest.mark.usefixtures("db")
async def test_bake_product_cards_group_marker(db):
    """[product-group:category] renders a grid of products."""
    await _insert_product(slug="shoe-1-amazon", name="Test Shoe", category="shoe", status="active")
    html = "<h2>Shoes</h2>\n[product-group:shoe]\n<p>End</p>"
    result = await bake_product_cards(html, lang="de")
    assert "[product-group:shoe]" not in result
    assert "Test Shoe" in result


@pytest.mark.usefixtures("db")
async def test_bake_product_cards_no_markers(db):
    """HTML without markers is returned unchanged."""
    html = "<p>No markers here.</p>"
    result = await bake_product_cards(html, lang="de")
    assert result == html


@pytest.mark.usefixtures("db")
async def test_bake_product_cards_draft_not_shown(db):
    """Draft products are not baked into articles."""
    await _insert_product(slug="draft-product", name="Draft Product", status="draft")
    html = "[product:draft-product]"
    result = await bake_product_cards(html, lang="de")
    assert "Draft Product" not in result


# ── regex patterns ─────────────────────────────────────────────────────────────

def test_product_re_matches():
    """PRODUCT_RE matches valid [product:slug] markers."""
    assert PRODUCT_RE.match("[product:bullpadel-vertex-04-amazon]")
    assert PRODUCT_RE.match("[product:test-123]")


def test_product_group_re_matches():
    """PRODUCT_GROUP_RE matches valid [product-group:category] markers."""
    assert PRODUCT_GROUP_RE.match("[product-group:racket]")
    assert PRODUCT_GROUP_RE.match("[product-group:shoe]")


# ── multi-retailer ────────────────────────────────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_multi_retailer_same_slug_different_lang(db):
    """Same slug can exist in DE and EN with different affiliate URLs."""
    await _insert_product(
        slug="vertex-04", language="de",
        affiliate_url="https://amazon.de/dp/TEST?tag=de-21",
    )
    await execute(
        """INSERT INTO affiliate_products
           (slug, name, brand, category, retailer, affiliate_url,
            price_cents, currency, status, language, pros, cons, sort_order)
           VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
        (
            "vertex-04", "Test Racket EN", "TestBrand", "racket", "Amazon UK",
            "https://amazon.co.uk/dp/TEST?tag=en-21",
            14999, "active", "en", "[]", "[]", 0,
        ),
    )
    de_product = await get_product("vertex-04", "de")
    en_product = await get_product("vertex-04", "en")
    assert de_product is not None
    assert en_product is not None
    assert de_product["affiliate_url"] != en_product["affiliate_url"]
    assert "amazon.de" in de_product["affiliate_url"]
    assert "amazon.co.uk" in en_product["affiliate_url"]


# ── click redirect (e2e via Quart test client) ────────────────────────────────

@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_302(app, db):
    """GET /go/<slug> redirects to affiliate_url with 302."""
    await _insert_product(slug="redirect-test", affiliate_url="https://amazon.de/dp/XYZ?tag=test-21")
    async with app.test_client() as client:
        response = await client.get("/go/redirect-test")
    assert response.status_code == 302
    assert "amazon.de" in response.headers.get("Location", "")


@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_logs_click(app, db):
    """Successful redirect logs a click in affiliate_clicks."""
    pid = await _insert_product(slug="logged-test", affiliate_url="https://amazon.de/dp/LOG?tag=test-21")
    async with app.test_client() as client:
        await client.get(
            "/go/logged-test",
            headers={"Referer": "https://padelnomics.io/de/beste-padelschlaeger-2026"},
        )
    rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (pid,))
    assert len(rows) == 1
    assert rows[0]["article_slug"] == "beste-padelschlaeger-2026"


@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_inactive_404(app, db):
    """Draft products return 404 on /go/<slug>."""
    await _insert_product(slug="inactive-test", status="draft")
    async with app.test_client() as client:
        response = await client.get("/go/inactive-test")
    assert response.status_code == 404


@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_unknown_404(app, db):
    """Unknown slug returns 404."""
    async with app.test_client() as client:
        response = await client.get("/go/totally-unknown-xyz")
    assert response.status_code == 404


# ── affiliate_programs ────────────────────────────────────────────────────────

async def _insert_program(
    name="Test Shop",
    slug="test-shop",
    url_template="https://testshop.example.com/p/{product_id}?ref={tag}",
    tracking_tag="testref",
    commission_pct=5.0,
    homepage_url="https://testshop.example.com",
    status="active",
) -> int:
    """Insert an affiliate program, return its id."""
    return await execute(
        """INSERT INTO affiliate_programs
           (name, slug, url_template, tracking_tag, commission_pct, homepage_url, status)
           VALUES (?, ?, ?, ?, ?, ?, ?)""",
        (name, slug, url_template, tracking_tag, commission_pct, homepage_url, status),
    )


@pytest.mark.usefixtures("db")
async def test_get_all_programs_returns_all(db):
    """get_all_programs returns inserted programs sorted by name."""
    await _insert_program(slug="zebra-shop", name="Zebra Shop")
    await _insert_program(slug="alpha-shop", name="Alpha Shop")
    programs = await get_all_programs()
    names = [p["name"] for p in programs]
    assert "Alpha Shop" in names
    assert "Zebra Shop" in names
    # Sorted by name ascending
    assert names.index("Alpha Shop") < names.index("Zebra Shop")


@pytest.mark.usefixtures("db")
async def test_get_all_programs_status_filter(db):
    """get_all_programs(status='active') excludes inactive programs."""
    await _insert_program(slug="inactive-prog", status="inactive")
    await _insert_program(slug="active-prog", name="Active Shop")
    active = await get_all_programs(status="active")
    statuses = [p["status"] for p in active]
    assert all(s == "active" for s in statuses)
    slugs = [p["slug"] for p in active]
    assert "inactive-prog" not in slugs
    assert "active-prog" in slugs


@pytest.mark.usefixtures("db")
async def test_get_program_by_id(db):
    """get_program returns a program by id."""
    prog_id = await _insert_program()
    prog = await get_program(prog_id)
    assert prog is not None
    assert prog["slug"] == "test-shop"


@pytest.mark.usefixtures("db")
async def test_get_program_not_found(db):
    """get_program returns None for unknown id."""
    prog = await get_program(99999)
    assert prog is None


@pytest.mark.usefixtures("db")
async def test_get_program_by_slug(db):
    """get_program_by_slug returns the program for a known slug."""
    await _insert_program(slug="find-by-slug")
    prog = await get_program_by_slug("find-by-slug")
    assert prog is not None
    assert prog["name"] == "Test Shop"


@pytest.mark.usefixtures("db")
async def test_get_program_by_slug_not_found(db):
    """get_program_by_slug returns None for unknown slug."""
    prog = await get_program_by_slug("nonexistent-slug-xyz")
    assert prog is None


@pytest.mark.usefixtures("db")
async def test_get_all_programs_product_count(db):
    """get_all_programs includes product_count for each program."""
    prog_id = await _insert_program(slug="counted-prog")
    await _insert_product(slug="p-for-count", program_id=prog_id)
    programs = await get_all_programs()
    prog = next(p for p in programs if p["slug"] == "counted-prog")
    assert prog["product_count"] == 1


# ── build_affiliate_url ───────────────────────────────────────────────────────

def test_build_affiliate_url_with_program():
    """build_affiliate_url assembles URL from program template."""
    product = {"program_id": 1, "product_identifier": "B0TESTTEST", "affiliate_url": ""}
    program = {"url_template": "https://amazon.de/dp/{product_id}?tag={tag}", "tracking_tag": "mysite-21"}
    url = build_affiliate_url(product, program)
    assert url == "https://amazon.de/dp/B0TESTTEST?tag=mysite-21"


def test_build_affiliate_url_legacy_fallback():
    """build_affiliate_url falls back to baked affiliate_url when no program."""
    product = {"program_id": None, "product_identifier": "", "affiliate_url": "https://baked.example.com/p?tag=x"}
    url = build_affiliate_url(product, None)
    assert url == "https://baked.example.com/p?tag=x"


def test_build_affiliate_url_no_program_id():
    """build_affiliate_url uses fallback when program_id is 0/falsy."""
    product = {"program_id": 0, "product_identifier": "B0IGNORED", "affiliate_url": "https://fallback.example.com"}
    program = {"url_template": "https://shop.example.com/{product_id}?ref={tag}", "tracking_tag": "tag123"}
    url = build_affiliate_url(product, program)
    # program_id is falsy → fallback
    assert url == "https://fallback.example.com"


def test_build_affiliate_url_no_program_dict():
    """build_affiliate_url uses fallback when program dict is None."""
    product = {"program_id": 5, "product_identifier": "ASIN123", "affiliate_url": "https://fallback.example.com"}
    url = build_affiliate_url(product, None)
    assert url == "https://fallback.example.com"


# ── program-based redirect ────────────────────────────────────────────────────

async def _insert_product(  # noqa: F811 — redefined to add program_id support
    slug="test-racket-amazon",
    name="Test Racket",
    brand="TestBrand",
    category="racket",
    retailer="Amazon",
    affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
    status="active",
    language="de",
    price_cents=14999,
    pros=None,
    cons=None,
    sort_order=0,
    program_id=None,
    product_identifier="",
) -> int:
    """Insert an affiliate product with optional program_id, return its id."""
    return await execute(
        """INSERT INTO affiliate_products
           (slug, name, brand, category, retailer, affiliate_url,
            price_cents, currency, status, language, pros, cons, sort_order,
            program_id, product_identifier)
           VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?, ?, ?)""",
        (
            slug, name, brand, category, retailer, affiliate_url,
            price_cents, status, language,
            json.dumps(pros or ["Gut"]),
            json.dumps(cons or ["Teuer"]),
            sort_order,
            program_id,
            product_identifier,
        ),
    )


@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_uses_program_url(app, db):
    """Redirect assembles URL from program template when product has program_id."""
    prog_id = await _insert_program(
        slug="amzn-test",
        url_template="https://www.amazon.de/dp/{product_id}?tag={tag}",
        tracking_tag="testsite-21",
    )
    await _insert_product(
        slug="program-redirect-test",
        affiliate_url="",
        program_id=prog_id,
        product_identifier="B0PROGRAM01",
    )
    async with app.test_client() as client:
        response = await client.get("/go/program-redirect-test")
    assert response.status_code == 302
    location = response.headers.get("Location", "")
    assert "B0PROGRAM01" in location
    assert "testsite-21" in location


@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_legacy_url_still_works(app, db):
    """Legacy products with baked affiliate_url still redirect correctly."""
    await _insert_product(
        slug="legacy-redirect-test",
        affiliate_url="https://amazon.de/dp/LEGACY?tag=old-21",
        program_id=None,
        product_identifier="",
    )
    async with app.test_client() as client:
        response = await client.get("/go/legacy-redirect-test")
    assert response.status_code == 302
    assert "LEGACY" in response.headers.get("Location", "")


# ── migration backfill ────────────────────────────────────────────────────────

def _load_migration_0027():
    """Import migration 0027 via importlib (filename starts with a digit)."""
    import importlib
    from pathlib import Path

    versions_dir = Path(__file__).parent.parent / "src/padelnomics/migrations/versions"
    spec = importlib.util.spec_from_file_location(
        "migration_0027", versions_dir / "0027_affiliate_programs.py"
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _make_pre_migration_db():
    """Create a minimal sqlite3 DB simulating state just before migration 0027.

    Provides the affiliate_products table (migration ALTERs it), but not
    affiliate_programs (migration CREATEs it).
    """
    import sqlite3

    conn = sqlite3.connect(":memory:")
    conn.execute("""
        CREATE TABLE affiliate_products (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            slug TEXT NOT NULL,
            name TEXT NOT NULL DEFAULT '',
            affiliate_url TEXT NOT NULL DEFAULT '',
            UNIQUE(slug)
        )
    """)
    conn.commit()
    return conn


def test_migration_seeds_amazon_program():
    """Migration 0027 up() seeds the Amazon program with expected fields.

    Tests the migration function directly against a real sqlite3 DB
    (the conftest only replays CREATE TABLE DDL, not INSERT seeds).
    """
    migration = _load_migration_0027()
    conn = _make_pre_migration_db()
    migration.up(conn)
    conn.commit()

    row = conn.execute(
        "SELECT * FROM affiliate_programs WHERE slug = 'amazon'"
    ).fetchone()
    assert row is not None
    cols = [d[0] for d in conn.execute("SELECT * FROM affiliate_programs WHERE slug = 'amazon'").description]
    prog = dict(zip(cols, row))
    assert prog["name"] == "Amazon"
    assert "padelnomics-21" in prog["tracking_tag"]
    assert "{product_id}" in prog["url_template"]
    assert "{tag}" in prog["url_template"]
    assert prog["commission_pct"] == 3.0
    conn.close()


def test_migration_backfills_asin_from_url():
    """Migration 0027 up() extracts ASINs from existing affiliate_url values."""
    migration = _load_migration_0027()
    conn = _make_pre_migration_db()
    conn.execute(
        "INSERT INTO affiliate_products (slug, affiliate_url) VALUES (?, ?)",
        ("test-racket", "https://www.amazon.de/dp/B0ASIN1234?tag=test-21"),
    )
    conn.commit()
    migration.up(conn)
    conn.commit()

    row = conn.execute(
        "SELECT program_id, product_identifier FROM affiliate_products WHERE slug = 'test-racket'"
    ).fetchone()
    assert row is not None
    assert row[0] is not None  # program_id set
    assert row[1] == "B0ASIN1234"  # ASIN extracted correctly
    conn.close()
