"""
Tests for the programmatic SEO content generation engine.

Covers: slugify utility, migration 0010, generation pipeline,
scenario widget baking, public article serving, markets hub,
sitemap integration, admin CRUD routes, and path collision prevention.
"""
import importlib
import json
import sqlite3
from datetime import date
from pathlib import Path

import pytest
from padelnomics.content.routes import (
    RESERVED_PREFIXES,
    SCENARIO_RE,
    SECTION_TEMPLATES,
    bake_scenario_cards,
    is_reserved_path,
)
from padelnomics.core import execute, fetch_all, fetch_one, slugify, utcnow_iso
from padelnomics.planner.calculator import calc, validate_state

SCHEMA_PATH = Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "schema.sql"


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

def _table_names(conn):
    rows = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table'"
        " AND name NOT LIKE 'sqlite_%' ORDER BY name"
    ).fetchall()
    return [r[0] for r in rows]


def _trigger_names(conn):
    rows = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name"
    ).fetchall()
    return [r[0] for r in rows]


def _index_names(conn):
    rows = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='index'"
        " AND name NOT LIKE 'sqlite_%' ORDER BY name"
    ).fetchall()
    return [r[0] for r in rows]


def _column_names(conn, table):
    return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]


async def _create_published_scenario(slug="test-scenario", city="TestCity", country="TC"):
    """Insert a published scenario with real calc data, return its id."""
    state = validate_state({"dblCourts": 4, "sglCourts": 2})
    d = calc(state)
    return await execute(
        """INSERT INTO published_scenarios
           (slug, title, subtitle, location, country, venue_type, ownership,
            court_config, state_json, calc_json)
           VALUES (?, ?, '', ?, ?, 'indoor', 'rent', '4 double + 2 single', ?, ?)""",
        (slug, f"Scenario {city}", city, country, json.dumps(state), json.dumps(d)),
    )


async def _create_article(slug="test-article", url_path="/test-article",
                           status="published", published_at=None,
                           article_type="editorial"):
    """Insert an article row, return its id."""
    pub = published_at or utcnow_iso()
    return await execute(
        """INSERT INTO articles
           (url_path, slug, title, meta_description, country, region,
            status, published_at, article_type)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (url_path, slug, f"Title {slug}", f"Desc {slug}", "US", "North America",
         status, pub, article_type),
    )


TEST_TEMPLATE = """\
---
name: "Test City Analysis"
slug: test-city
content_type: calculator
data_table: serving.test_cities
natural_key: city_slug
languages: [en]
url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
title_pattern: "Padel in {{ city }}"
meta_description_pattern: "Padel costs in {{ city }}"
schema_type: Article
---
# Padel in {{ city }}

Welcome to {{ city }}.

[scenario:{{ scenario_slug }}:capex]
"""

TEST_ROWS = [
    {"city": "Miami", "city_slug": "miami", "country": "US", "region": "North America", "electricity": 700},
    {"city": "Madrid", "city_slug": "madrid", "country": "ES", "region": "Europe", "electricity": 500},
    {"city": "Berlin", "city_slug": "berlin", "country": "DE", "region": "Europe", "electricity": 550},
]

TEST_COLUMNS = [
    {"column_name": "city", "data_type": "VARCHAR"},
    {"column_name": "city_slug", "data_type": "VARCHAR"},
    {"column_name": "country", "data_type": "VARCHAR"},
    {"column_name": "region", "data_type": "VARCHAR"},
    {"column_name": "electricity", "data_type": "INTEGER"},
]


@pytest.fixture
def pseo_env(tmp_path, monkeypatch):
    """Set up pSEO environment: temp template dir, build dir, mock DuckDB."""
    import padelnomics.content as content_mod

    tpl_dir = tmp_path / "templates"
    tpl_dir.mkdir()
    monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tpl_dir)

    build_dir = tmp_path / "build"
    build_dir.mkdir()
    monkeypatch.setattr(content_mod, "BUILD_DIR", build_dir)

    (tpl_dir / "test-city.md.jinja").write_text(TEST_TEMPLATE)

    async def mock_fetch_analytics(query, params=None):
        if "information_schema" in query:
            return TEST_COLUMNS
        if "COUNT(*)" in query.upper():
            return [{"cnt": len(TEST_ROWS)}]
        if "WHERE" in query and params:
            # preview_article: filter by natural key value
            return [r for r in TEST_ROWS if params[0] in r.values()]
        return TEST_ROWS

    monkeypatch.setattr(content_mod, "fetch_analytics", mock_fetch_analytics)

    return {"tpl_dir": tpl_dir, "build_dir": build_dir}


# ════════════════════════════════════════════════════════════
# slugify()
# ════════════════════════════════════════════════════════════

class TestSlugify:
    def test_basic(self):
        assert slugify("Hello World") == "hello-world"

    def test_unicode_stripped(self):
        assert slugify("Padel Court Cost in München") == "padel-court-cost-in-munchen"

    def test_special_chars_removed(self):
        assert slugify("Test/Special@Chars!") == "testspecialchars"

    def test_max_length(self):
        result = slugify("a " * 100, max_length_chars=20)
        assert len(result) <= 20

    def test_empty_string(self):
        assert slugify("") == ""

    def test_leading_trailing_hyphens_stripped(self):
        assert slugify("---hello---") == "hello"

    def test_multiple_spaces_collapsed(self):
        assert slugify("hello    world") == "hello-world"

    def test_accented_characters(self):
        assert slugify("café résumé naïve") == "cafe-resume-naive"


# ════════════════════════════════════════════════════════════
# Migration 0010
# ════════════════════════════════════════════════════════════

class TestMigration0010:
    """Synchronous tests — migration uses stdlib sqlite3."""

    def _run_migration(self, db_path):
        conn = sqlite3.connect(db_path)
        mod = importlib.import_module("padelnomics.migrations.versions.0010_add_content_tables")
        mod.up(conn)
        conn.commit()
        return conn

    def test_creates_all_tables(self, tmp_path):
        conn = self._run_migration(str(tmp_path / "test.db"))
        tables = _table_names(conn)
        assert "published_scenarios" in tables
        assert "article_templates" in tables
        assert "template_data" in tables
        assert "articles" in tables
        conn.close()

    def test_creates_fts_table(self, tmp_path):
        conn = self._run_migration(str(tmp_path / "test.db"))
        tables = _table_names(conn)
        assert "articles_fts" in tables
        conn.close()

    def test_creates_sync_triggers(self, tmp_path):
        conn = self._run_migration(str(tmp_path / "test.db"))
        triggers = _trigger_names(conn)
        assert "articles_ai" in triggers
        assert "articles_ad" in triggers
        assert "articles_au" in triggers
        conn.close()

    def test_creates_indexes(self, tmp_path):
        conn = self._run_migration(str(tmp_path / "test.db"))
        indexes = _index_names(conn)
        assert "idx_pub_scenarios_slug" in indexes
        assert "idx_article_templates_slug" in indexes
        assert "idx_template_data_template" in indexes
        assert "idx_articles_url_path" in indexes
        assert "idx_articles_slug" in indexes
        assert "idx_articles_status" in indexes
        conn.close()

    def test_published_scenarios_columns(self, tmp_path):
        conn = self._run_migration(str(tmp_path / "test.db"))
        cols = _column_names(conn, "published_scenarios")
        for expected in ("id", "slug", "title", "location", "country",
                         "venue_type", "ownership", "court_config",
                         "state_json", "calc_json", "template_data_id"):
            assert expected in cols, f"{expected} missing from published_scenarios"
        conn.close()

    def test_articles_columns(self, tmp_path):
        conn = self._run_migration(str(tmp_path / "test.db"))
        cols = _column_names(conn, "articles")
        for expected in ("id", "url_path", "slug", "title", "meta_description",
                         "country", "region", "status", "published_at",
                         "template_data_id"):
            assert expected in cols, f"{expected} missing from articles"
        conn.close()

    def test_idempotent(self, tmp_path):
        """Running the migration twice does not raise."""
        db_path = str(tmp_path / "test.db")
        conn = self._run_migration(db_path)
        conn.close()
        # Run again
        conn = self._run_migration(db_path)
        tables = _table_names(conn)
        assert "articles" in tables
        conn.close()

    def test_fts_sync_on_insert(self, tmp_path):
        """Inserting into articles populates articles_fts via trigger."""
        conn = self._run_migration(str(tmp_path / "test.db"))
        conn.execute(
            """INSERT INTO articles (url_path, slug, title, meta_description,
               country, region, status, published_at)
               VALUES ('/test', 'test', 'Test Title', 'desc', 'US', 'NA',
                       'published', '2026-01-01')"""
        )
        conn.commit()
        rows = conn.execute(
            "SELECT * FROM articles_fts WHERE articles_fts MATCH 'Test'"
        ).fetchall()
        assert len(rows) == 1
        conn.close()

    def test_fts_sync_on_delete(self, tmp_path):
        """Deleting from articles removes from articles_fts via trigger."""
        conn = self._run_migration(str(tmp_path / "test.db"))
        conn.execute(
            """INSERT INTO articles (url_path, slug, title, meta_description,
               country, region, status, published_at)
               VALUES ('/test', 'test', 'UniqueTitle', 'desc', 'US', 'NA',
                       'published', '2026-01-01')"""
        )
        conn.commit()
        conn.execute("DELETE FROM articles WHERE slug = 'test'")
        conn.commit()
        rows = conn.execute(
            "SELECT * FROM articles_fts WHERE articles_fts MATCH 'UniqueTitle'"
        ).fetchall()
        assert len(rows) == 0
        conn.close()


# ════════════════════════════════════════════════════════════
# Scenario regex
# ════════════════════════════════════════════════════════════

class TestScenarioRegex:
    def test_simple_marker(self):
        m = SCENARIO_RE.search("[scenario:my-slug]")
        assert m.group(1) == "my-slug"
        assert m.group(2) is None

    def test_section_marker(self):
        m = SCENARIO_RE.search("[scenario:my-slug:capex]")
        assert m.group(1) == "my-slug"
        assert m.group(2) == "capex"

    def test_all_sections_have_templates(self):
        for section in (None, "capex", "operating", "cashflow", "returns", "full"):
            assert section in SECTION_TEMPLATES

    def test_no_match_on_invalid(self):
        assert SCENARIO_RE.search("[scenario:UPPER]") is None
        assert SCENARIO_RE.search("[scenario:]") is None


# ════════════════════════════════════════════════════════════
# Path collision prevention
# ════════════════════════════════════════════════════════════

class TestReservedPaths:
    def test_admin_reserved(self):
        assert is_reserved_path("/admin/anything") is True

    def test_planner_reserved(self):
        assert is_reserved_path("/planner/") is True

    def test_markets_not_reserved(self):
        # /markets sub-paths are article URLs; explicit /markets route takes priority
        assert is_reserved_path("/markets/germany/berlin") is False

    def test_custom_path_allowed(self):
        assert is_reserved_path("/padel-court-cost-miami") is False

    def test_leading_slash_normalized(self):
        assert is_reserved_path("admin/foo") is True

    def test_all_prefixes_covered(self):
        """Every prefix in the tuple is actually checked."""
        for prefix in RESERVED_PREFIXES:
            assert is_reserved_path(prefix + "/test") is True


# ════════════════════════════════════════════════════════════
# Scenario card baking
# ════════════════════════════════════════════════════════════

class TestBakeScenarioCards:
    async def test_no_markers_returns_unchanged(self, db):
        html = "<p>Hello world</p>"
        result = await bake_scenario_cards(html)
        assert result == html

    async def test_unknown_slug_left_as_marker(self, db):
        """Markers referencing non-existent scenarios are removed (replaced with nothing)."""
        html = "<p>[scenario:nonexistent]</p>"
        result = await bake_scenario_cards(html)
        # The marker position is replaced but scenario not found → no replacement
        assert "scenario-widget" not in result

    async def test_summary_card_baked(self, db):
        await _create_published_scenario(slug="test-bake")
        html = "<div>[scenario:test-bake]</div>"
        result = await bake_scenario_cards(html)
        assert "scenario-widget" in result
        assert "scenario-summary" in result
        assert "TestCity" in result

    async def test_capex_section_baked(self, db):
        await _create_published_scenario(slug="test-capex")
        html = "[scenario:test-capex:capex]"
        result = await bake_scenario_cards(html)
        assert "scenario-capex" in result
        assert "Investment Breakdown" in result

    async def test_operating_section_baked(self, db):
        await _create_published_scenario(slug="test-op")
        html = "[scenario:test-op:operating]"
        result = await bake_scenario_cards(html)
        assert "scenario-operating" in result

    async def test_cashflow_section_baked(self, db):
        await _create_published_scenario(slug="test-cf")
        html = "[scenario:test-cf:cashflow]"
        result = await bake_scenario_cards(html)
        assert "scenario-cashflow" in result

    async def test_returns_section_baked(self, db):
        await _create_published_scenario(slug="test-ret")
        html = "[scenario:test-ret:returns]"
        result = await bake_scenario_cards(html)
        assert "scenario-returns" in result

    async def test_full_section_baked(self, db):
        await _create_published_scenario(slug="test-full")
        html = "[scenario:test-full:full]"
        result = await bake_scenario_cards(html)
        # Full includes all sections
        assert "scenario-summary" in result
        assert "scenario-capex" in result
        assert "scenario-operating" in result
        assert "scenario-cashflow" in result
        assert "scenario-returns" in result

    async def test_multiple_markers_same_slug(self, db):
        await _create_published_scenario(slug="multi")
        html = "[scenario:multi]\n---\n[scenario:multi:capex]"
        result = await bake_scenario_cards(html)
        assert "scenario-summary" in result
        assert "scenario-capex" in result

    async def test_planner_cta_link(self, db):
        await _create_published_scenario(slug="cta-test")
        html = "[scenario:cta-test]"
        result = await bake_scenario_cards(html)
        assert "planner" in result
        assert "Try with your own numbers" in result

    async def test_invalid_section_ignored(self, db):
        await _create_published_scenario(slug="bad-sec")
        html = "[scenario:bad-sec:nonexistent]"
        result = await bake_scenario_cards(html)
        # Invalid section key → no template → marker untouched
        assert "scenario-widget" not in result


# ════════════════════════════════════════════════════════════
# Generation pipeline
# ════════════════════════════════════════════════════════════

class TestGenerationPipeline:
    async def test_generates_correct_count(self, db, pseo_env):
        from padelnomics.content import generate_articles
        generated = await generate_articles("test-city", date(2026, 3, 1), 10)
        assert generated == 3  # 3 rows × 1 language

    async def test_staggered_dates_two_per_day(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 2)

        articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
        assert len(articles) == 3
        dates = [a["published_at"][:10] for a in articles]
        # 2 on day 1, 1 on day 2
        assert dates[0] == "2026-03-01"
        assert dates[1] == "2026-03-01"
        assert dates[2] == "2026-03-02"

    async def test_staggered_dates_one_per_day(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 1)

        articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
        dates = sorted({a["published_at"][:10] for a in articles})
        assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]

    async def test_article_url_and_title(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 10)

        miami = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
        assert miami is not None
        assert miami["url_path"] == "/markets/us/miami"
        assert miami["title"] == "Padel in Miami"
        assert miami["template_slug"] == "test-city"
        assert miami["language"] == "en"
        assert miami["status"] == "published"

    async def test_scenario_created_per_row(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 10)

        scenarios = await fetch_all("SELECT * FROM published_scenarios")
        assert len(scenarios) == 3

    async def test_scenario_has_valid_calc_json(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 10)

        scenario = await fetch_one(
            "SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
        )
        assert scenario is not None
        d = json.loads(scenario["calc_json"])
        assert "capex" in d
        assert "ebitdaMonth" in d
        assert "irr" in d
        assert d["capex"] > 0

    async def test_build_files_written(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 10)

        build_dir = pseo_env["build_dir"]
        articles = await fetch_all("SELECT slug FROM articles")
        for a in articles:
            build_path = build_dir / "en" / f"{a['slug']}.html"
            assert build_path.exists(), f"Missing build file: {build_path}"
            content = build_path.read_text()
            assert len(content) > 50

    async def test_updates_existing_on_regeneration(self, db, pseo_env):
        """Running generate twice updates articles, doesn't duplicate."""
        from padelnomics.content import generate_articles

        first = await generate_articles("test-city", date(2026, 3, 1), 10)
        assert first == 3

        second = await generate_articles("test-city", date(2026, 3, 10), 10)
        assert second == 3  # Updates existing

        articles = await fetch_all("SELECT * FROM articles")
        assert len(articles) == 3  # No duplicates

    async def test_calc_overrides_applied(self, db, pseo_env):
        """Data row values that match DEFAULTS keys are used as calc overrides."""
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 10)

        # Miami had electricity=700, default is 600
        scenario = await fetch_one(
            "SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
        )
        state = json.loads(scenario["state_json"])
        assert state["electricity"] == 700

    async def test_seo_head_populated(self, db, pseo_env):
        from padelnomics.content import generate_articles
        await generate_articles("test-city", date(2026, 3, 1), 10)

        article = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
        assert article["seo_head"] is not None
        assert 'rel="canonical"' in article["seo_head"]
        assert 'application/ld+json' in article["seo_head"]


# ════════════════════════════════════════════════════════════
# Jinja string rendering
# ════════════════════════════════════════════════════════════

class TestRenderPattern:
    def test_simple(self):
        from padelnomics.content import _render_pattern
        assert _render_pattern("Hello {{ name }}!", {"name": "World"}) == "Hello World!"

    def test_missing_var_empty(self):
        from padelnomics.content import _render_pattern
        result = _render_pattern("Hello {{ missing }}!", {})
        assert result == "Hello !"

    def test_url_pattern(self):
        from padelnomics.content import _render_pattern
        result = _render_pattern("/markets/{{ country | lower }}/{{ slug }}", {"country": "US", "slug": "miami"})
        assert result == "/markets/us/miami"

    def test_slugify_filter(self):
        from padelnomics.content import _render_pattern
        result = _render_pattern("{{ name | slugify }}", {"name": "Hello World"})
        assert result == "hello-world"


# ════════════════════════════════════════════════════════════
# Template discovery & loading
# ════════════════════════════════════════════════════════════

class TestDiscoverTemplates:
    def test_discovers_templates(self, pseo_env):
        from padelnomics.content import discover_templates
        templates = discover_templates()
        assert len(templates) == 1
        assert templates[0]["slug"] == "test-city"
        assert templates[0]["name"] == "Test City Analysis"

    def test_empty_dir(self, tmp_path, monkeypatch):
        import padelnomics.content as content_mod
        monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tmp_path / "nonexistent")
        from padelnomics.content import discover_templates
        assert discover_templates() == []

    def test_skips_invalid_frontmatter(self, pseo_env):
        from padelnomics.content import discover_templates
        (pseo_env["tpl_dir"] / "bad.md.jinja").write_text("no frontmatter here")
        templates = discover_templates()
        assert len(templates) == 1  # Only the valid test-city template

    def test_includes_path(self, pseo_env):
        from padelnomics.content import discover_templates
        templates = discover_templates()
        assert "_path" in templates[0]
        assert templates[0]["_path"].endswith("test-city.md.jinja")


class TestLoadTemplate:
    def test_loads_config_and_body(self, pseo_env):
        from padelnomics.content import load_template
        config = load_template("test-city")
        assert config["slug"] == "test-city"
        assert config["content_type"] == "calculator"
        assert config["data_table"] == "serving.test_cities"
        assert "body_template" in config
        assert "Padel in {{ city }}" in config["body_template"]

    def test_missing_template_raises(self, pseo_env):
        from padelnomics.content import load_template
        with pytest.raises(AssertionError, match="Template not found"):
            load_template("nonexistent")

    def test_schema_type_normalized_to_list(self, pseo_env):
        from padelnomics.content import load_template
        config = load_template("test-city")
        assert isinstance(config["schema_type"], list)
        assert "Article" in config["schema_type"]

    def test_languages_parsed(self, pseo_env):
        from padelnomics.content import load_template
        config = load_template("test-city")
        assert config["languages"] == ["en"]


# ════════════════════════════════════════════════════════════
# FAQ extraction
# ════════════════════════════════════════════════════════════

class TestExtractFaqPairs:
    def test_extracts_pairs(self):
        from padelnomics.content import _extract_faq_pairs
        md = (
            "# Title\n\n"
            "## FAQ\n\n"
            "**How much does it cost?**\n"
            "It costs about 500k.\n\n"
            "**How long does it take?**\n"
            "About 12 months.\n\n"
            "## Other Section\n"
        )
        pairs = _extract_faq_pairs(md)
        assert len(pairs) == 2
        assert pairs[0]["question"] == "How much does it cost?"
        assert "500k" in pairs[0]["answer"]
        assert pairs[1]["question"] == "How long does it take?"

    def test_no_faq_section(self):
        from padelnomics.content import _extract_faq_pairs
        assert _extract_faq_pairs("# Title\n\nSome content") == []

    def test_faq_at_end_of_document(self):
        from padelnomics.content import _extract_faq_pairs
        md = "## FAQ\n\n**Question one?**\nAnswer one.\n"
        pairs = _extract_faq_pairs(md)
        assert len(pairs) == 1
        assert pairs[0]["question"] == "Question one?"
        assert pairs[0]["answer"] == "Answer one."


# ════════════════════════════════════════════════════════════
# Breadcrumbs
# ════════════════════════════════════════════════════════════

class TestBuildBreadcrumbs:
    def test_basic_path(self):
        from padelnomics.content import _build_breadcrumbs
        crumbs = _build_breadcrumbs("/en/markets/germany/berlin", "https://padelnomics.io")
        assert len(crumbs) == 5
        assert crumbs[0] == {"name": "Home", "url": "https://padelnomics.io/"}
        assert crumbs[1] == {"name": "En", "url": "https://padelnomics.io/en"}
        assert crumbs[2] == {"name": "Markets", "url": "https://padelnomics.io/en/markets"}
        assert crumbs[3] == {"name": "Germany", "url": "https://padelnomics.io/en/markets/germany"}
        assert crumbs[4] == {"name": "Berlin", "url": "https://padelnomics.io/en/markets/germany/berlin"}

    def test_root_path(self):
        from padelnomics.content import _build_breadcrumbs
        crumbs = _build_breadcrumbs("/", "https://padelnomics.io")
        assert len(crumbs) == 1
        assert crumbs[0]["name"] == "Home"

    def test_hyphenated_segments_titlecased(self):
        from padelnomics.content import _build_breadcrumbs
        crumbs = _build_breadcrumbs("/en/my-section", "https://padelnomics.io")
        assert crumbs[2]["name"] == "My Section"


# ════════════════════════════════════════════════════════════
# JSON-LD structured data
# ════════════════════════════════════════════════════════════

class TestBuildJsonld:
    _COMMON = dict(
        title="Test Title",
        description="Test description",
        url="https://padelnomics.io/en/markets/us/miami",
        published_at="2026-01-01T08:00:00",
        date_modified="2026-01-02T10:00:00",
        language="en",
        breadcrumbs=[
            {"name": "Home", "url": "https://padelnomics.io/"},
            {"name": "Markets", "url": "https://padelnomics.io/en/markets"},
        ],
    )

    def test_always_includes_breadcrumbs(self):
        from padelnomics.content import build_jsonld
        objects = build_jsonld(["Article"], **self._COMMON)
        types = [o["@type"] for o in objects]
        assert "BreadcrumbList" in types

    def test_breadcrumb_positions(self):
        from padelnomics.content import build_jsonld
        objects = build_jsonld(["Article"], **self._COMMON)
        bc = [o for o in objects if o["@type"] == "BreadcrumbList"][0]
        items = bc["itemListElement"]
        assert items[0]["position"] == 1
        assert items[0]["name"] == "Home"
        assert items[1]["position"] == 2

    def test_article_schema(self):
        from padelnomics.content import build_jsonld
        objects = build_jsonld(["Article"], **self._COMMON)
        article = [o for o in objects if o["@type"] == "Article"][0]
        assert article["headline"] == "Test Title"
        assert article["inLanguage"] == "en"
        assert article["datePublished"] == "2026-01-01T08:00:00"
        assert article["dateModified"] == "2026-01-02T10:00:00"
        assert article["publisher"]["name"] == "Padelnomics"

    def test_headline_truncated_at_110(self):
        from padelnomics.content import build_jsonld
        long_title = "A" * 200
        objects = build_jsonld(["Article"], **{**self._COMMON, "title": long_title})
        article = [o for o in objects if o["@type"] == "Article"][0]
        assert len(article["headline"]) == 110

    def test_faqpage_schema(self):
        from padelnomics.content import build_jsonld
        faq_pairs = [
            {"question": "How much?", "answer": "About 500k."},
            {"question": "How long?", "answer": "12 months."},
        ]
        objects = build_jsonld(["Article", "FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
        faq = [o for o in objects if o["@type"] == "FAQPage"][0]
        assert len(faq["mainEntity"]) == 2
        assert faq["mainEntity"][0]["name"] == "How much?"
        assert faq["mainEntity"][0]["acceptedAnswer"]["text"] == "About 500k."

    def test_faqpage_omitted_without_pairs(self):
        from padelnomics.content import build_jsonld
        objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=[])
        types = [o["@type"] for o in objects]
        assert "FAQPage" not in types

    def test_no_article_when_not_in_types(self):
        from padelnomics.content import build_jsonld
        faq_pairs = [{"question": "Q?", "answer": "A."}]
        objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
        types = [o["@type"] for o in objects]
        assert "Article" not in types
        assert "FAQPage" in types


# ════════════════════════════════════════════════════════════
# Preview article
# ════════════════════════════════════════════════════════════

class TestPreviewArticle:
    async def test_preview_returns_rendered_data(self, db, pseo_env):
        from padelnomics.content import preview_article
        result = await preview_article("test-city", "miami")
        assert result["title"] == "Padel in Miami"
        assert result["url_path"] == "/markets/us/miami"
        assert result["meta_description"] == "Padel costs in Miami"
        assert "<h1>" in result["html"]

    async def test_preview_unknown_row_raises(self, db, pseo_env):
        from padelnomics.content import preview_article
        with pytest.raises(AssertionError, match="No row found"):
            await preview_article("test-city", "nonexistent")

    async def test_preview_with_language(self, db, pseo_env):
        from padelnomics.content import preview_article
        result = await preview_article("test-city", "miami", lang="de")
        assert result["url_path"] == "/markets/us/miami"

    async def test_preview_unknown_template_raises(self, db, pseo_env):
        from padelnomics.content import preview_article
        with pytest.raises(AssertionError, match="Template not found"):
            await preview_article("nonexistent", "miami")


# ════════════════════════════════════════════════════════════
# Public routes
# ════════════════════════════════════════════════════════════

class TestMarketsHub:
    async def test_markets_returns_200(self, client):
        resp = await client.get("/en/markets")
        assert resp.status_code == 200

    async def test_markets_has_search(self, client):
        resp = await client.get("/en/markets")
        html = (await resp.data).decode()
        assert 'id="market-q"' in html

    async def test_markets_results_partial(self, client):
        resp = await client.get("/en/markets/results")
        assert resp.status_code == 200

    async def test_markets_shows_published_articles(self, client, db):
        await _create_article(slug="pub-test", url_path="/pub-test", status="published")
        resp = await client.get("/en/markets")
        html = (await resp.data).decode()
        assert "Title pub-test" in html

    async def test_markets_hides_draft_articles(self, client, db):
        await _create_article(slug="draft-test", url_path="/draft-test", status="draft")
        resp = await client.get("/en/markets")
        html = (await resp.data).decode()
        assert "Title draft-test" not in html

    async def test_markets_hides_future_articles(self, client, db):
        await _create_article(
            slug="future-test", url_path="/future-test",
            status="published", published_at="2099-01-01T00:00:00",
        )
        resp = await client.get("/en/markets")
        html = (await resp.data).decode()
        assert "Title future-test" not in html

    async def test_markets_filter_by_country(self, client, db):
        await _create_article(slug="us-art", url_path="/us-art")
        resp = await client.get("/en/markets/results?country=US")
        html = (await resp.data).decode()
        assert "Title us-art" in html

        resp = await client.get("/en/markets/results?country=DE")
        html = (await resp.data).decode()
        assert "Title us-art" not in html


class TestArticleServing:
    async def test_nonexistent_article_returns_404(self, client):
        resp = await client.get("/en/padel-court-cost-nonexistent")
        assert resp.status_code == 404

    async def test_draft_article_redirects_to_parent(self, client, db):
        # Draft articles are not served; the route redirects to the nearest parent
        # rather than showing a bare 404.
        await _create_article(slug="draft-serve", url_path="/draft-serve", status="draft")
        resp = await client.get("/en/draft-serve")
        assert resp.status_code == 302

    async def test_future_article_redirects_to_parent(self, client, db):
        # Scheduled (not yet live) articles redirect to the parent path instead of 404.
        await _create_article(
            slug="future-serve", url_path="/future-serve",
            status="published", published_at="2099-01-01T00:00:00",
        )
        resp = await client.get("/en/future-serve")
        assert resp.status_code == 302

    async def test_published_article_served(self, client, db):
        from padelnomics.content.routes import BUILD_DIR
        await _create_article(slug="live-art", url_path="/live-art")

        # Write a build file
        BUILD_DIR.mkdir(parents=True, exist_ok=True)
        build_path = BUILD_DIR / "live-art.html"
        build_path.write_text("<p>Article body content</p>")

        try:
            resp = await client.get("/en/live-art")
            assert resp.status_code == 200
            html = (await resp.data).decode()
            assert "Article body content" in html
            assert "Title live-art" in html
        finally:
            if build_path.exists():
                build_path.unlink()

    async def test_article_missing_build_file_returns_404(self, client, db):
        await _create_article(slug="no-build", url_path="/no-build")
        resp = await client.get("/en/no-build")
        assert resp.status_code == 404


# ════════════════════════════════════════════════════════════
# Sitemap integration
# ════════════════════════════════════════════════════════════

class TestSitemapContent:
    async def test_sitemap_includes_markets(self, client):
        resp = await client.get("/sitemap.xml")
        xml = (await resp.data).decode()
        assert "/markets" in xml

    async def test_sitemap_includes_published_articles(self, client, db):
        await _create_article(slug="sitemap-art", url_path="/sitemap-art")
        resp = await client.get("/sitemap.xml")
        xml = (await resp.data).decode()
        assert "/sitemap-art" in xml

    async def test_sitemap_excludes_draft_articles(self, client, db):
        await _create_article(slug="sitemap-draft", url_path="/sitemap-draft", status="draft")
        resp = await client.get("/sitemap.xml")
        xml = (await resp.data).decode()
        assert "/sitemap-draft" not in xml

    async def test_sitemap_excludes_future_articles(self, client, db):
        await _create_article(
            slug="sitemap-future", url_path="/sitemap-future",
            status="published", published_at="2099-01-01T00:00:00",
        )
        resp = await client.get("/sitemap.xml")
        xml = (await resp.data).decode()
        assert "/sitemap-future" not in xml


# ════════════════════════════════════════════════════════════
# Route registration
# ════════════════════════════════════════════════════════════

class TestRouteRegistration:
    def test_markets_route_registered(self, app):
        rules = [r.rule for r in app.url_map.iter_rules()]
        assert "/<lang>/markets" in rules

    def test_admin_content_routes_registered(self, app):
        rules = [r.rule for r in app.url_map.iter_rules()]
        assert "/admin/templates" in rules
        assert "/admin/scenarios" in rules
        assert "/admin/articles" in rules
        assert "/admin/scenarios/new" in rules
        assert "/admin/rebuild-all" in rules

    def test_catchall_route_registered(self, app):
        rules = [r.rule for r in app.url_map.iter_rules()]
        assert "/<lang>/<path:url_path>" in rules


# ════════════════════════════════════════════════════════════
# Admin routes (require admin session)
# ════════════════════════════════════════════════════════════

class TestAdminTemplates:
    async def test_template_list_requires_admin(self, client):
        resp = await client.get("/admin/templates")
        assert resp.status_code == 302

    async def test_template_list(self, admin_client):
        resp = await admin_client.get("/admin/templates")
        assert resp.status_code == 200

    async def test_template_list_shows_discovered(self, admin_client, pseo_env):
        resp = await admin_client.get("/admin/templates")
        assert resp.status_code == 200
        html = (await resp.data).decode()
        assert "Test City Analysis" in html
        assert "test-city" in html


class TestAdminTemplateDetail:
    async def test_detail_shows_config(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/test-city")
        assert resp.status_code == 200
        html = (await resp.data).decode()
        assert "Test City Analysis" in html
        assert "serving.test_cities" in html

    async def test_detail_shows_columns(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/test-city")
        html = (await resp.data).decode()
        assert "city_slug" in html
        assert "VARCHAR" in html

    async def test_detail_shows_sample_data(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/test-city")
        html = (await resp.data).decode()
        assert "Miami" in html
        assert "Berlin" in html

    async def test_detail_unknown_slug_redirects(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/nonexistent")
        assert resp.status_code == 302


class TestAdminTemplatePreview:
    async def test_preview_renders_article(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/test-city/preview/miami")
        assert resp.status_code == 200
        html = (await resp.data).decode()
        assert "Padel in Miami" in html

    async def test_preview_bad_key_redirects(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/test-city/preview/nonexistent")
        assert resp.status_code == 302

    async def test_preview_bad_template_redirects(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/bad-slug/preview/miami")
        assert resp.status_code == 302


class TestAdminTemplateGenerate:
    async def test_generate_form(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/test-city/generate")
        assert resp.status_code == 200
        html = (await resp.data).decode()
        assert "3" in html  # 3 rows available
        assert "Generate" in html

    async def test_generate_enqueues_task(self, admin_client, db, pseo_env):
        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/templates/test-city/generate", form={
            "csrf_token": "test",
            "start_date": "2026-04-01",
            "articles_per_day": "2",
        })
        assert resp.status_code == 302

        # Generation is now queued, not inline
        tasks = await fetch_all(
            "SELECT * FROM tasks WHERE task_name = 'generate_articles'"
        )
        assert len(tasks) == 1
        import json
        payload = json.loads(tasks[0]["payload"])
        assert payload["template_slug"] == "test-city"
        assert payload["start_date"] == "2026-04-01"
        assert payload["articles_per_day"] == 2

    async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env):
        resp = await admin_client.get("/admin/templates/nonexistent/generate")
        assert resp.status_code == 302


class TestAdminTemplateRegenerate:
    async def test_regenerate_enqueues_task(self, admin_client, db, pseo_env):
        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/templates/test-city/regenerate", form={
            "csrf_token": "test",
        })
        assert resp.status_code == 302

        # Regeneration is now queued, not inline
        tasks = await fetch_all(
            "SELECT * FROM tasks WHERE task_name = 'generate_articles'"
        )
        assert len(tasks) == 1
        import json
        payload = json.loads(tasks[0]["payload"])
        assert payload["template_slug"] == "test-city"

    async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env):
        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/templates/nonexistent/regenerate", form={
            "csrf_token": "test",
        })
        assert resp.status_code == 302


class TestAdminScenarios:
    async def test_scenario_list(self, admin_client):
        resp = await admin_client.get("/admin/scenarios")
        assert resp.status_code == 200

    async def test_scenario_new_form(self, admin_client):
        resp = await admin_client.get("/admin/scenarios/new")
        assert resp.status_code == 200

    async def test_scenario_create(self, admin_client, db):
        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/scenarios/new", form={
            "csrf_token": "test",
            "title": "Miami Test",
            "slug": "miami-test",
            "location": "Miami",
            "country": "US",
            "venue": "indoor",
            "own": "rent",
            "dblCourts": "4",
            "sglCourts": "2",
        })
        assert resp.status_code == 302

        row = await fetch_one("SELECT * FROM published_scenarios WHERE slug = 'miami-test'")
        assert row is not None
        assert row["title"] == "Miami Test"
        d = json.loads(row["calc_json"])
        assert d["capex"] > 0

    async def test_scenario_edit_recalculates(self, admin_client, db):
        scenario_id = await _create_published_scenario(slug="edit-sc")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post(f"/admin/scenarios/{scenario_id}/edit", form={
            "csrf_token": "test",
            "title": "Updated",
            "location": "TestCity",
            "country": "TC",
            "dblCourts": "6",
            "sglCourts": "0",
        })
        assert resp.status_code == 302

        row = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
        assert row["title"] == "Updated"
        state = json.loads(row["state_json"])
        assert state["dblCourts"] == 6

    async def test_scenario_delete(self, admin_client, db):
        scenario_id = await _create_published_scenario(slug="del-sc")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post(f"/admin/scenarios/{scenario_id}/delete", form={
            "csrf_token": "test",
        })
        assert resp.status_code == 302
        assert await fetch_one("SELECT 1 FROM published_scenarios WHERE id = ?", (scenario_id,)) is None

    async def test_scenario_preview(self, admin_client, db):
        scenario_id = await _create_published_scenario(slug="preview-sc")
        resp = await admin_client.get(f"/admin/scenarios/{scenario_id}/preview")
        assert resp.status_code == 200
        html = (await resp.data).decode()
        assert "scenario-widget" in html


class TestAdminArticles:
    async def test_article_list(self, admin_client):
        resp = await admin_client.get("/admin/articles")
        assert resp.status_code == 200

    async def test_article_new_form(self, admin_client):
        resp = await admin_client.get("/admin/articles/new")
        assert resp.status_code == 200

    async def test_article_create_manual(self, admin_client, db):
        from padelnomics.content.routes import BUILD_DIR

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/articles/new", form={
            "csrf_token": "test",
            "title": "Manual Article",
            "slug": "manual-art",
            "url_path": "/manual-art",
            "meta_description": "A test article",
            "country": "US",
            "region": "North America",
            "body": "# Hello\n\nThis is a test.",
            "status": "published",
        })
        assert resp.status_code == 302

        row = await fetch_one("SELECT * FROM articles WHERE slug = 'manual-art'")
        assert row is not None
        assert row["title"] == "Manual Article"

        # Build file created at BUILD_DIR/<language>/<slug>.html (language defaults to "en")
        build_path = BUILD_DIR / "en" / "manual-art.html"
        try:
            assert build_path.exists()
            content = build_path.read_text()
            assert "<h1>Hello</h1>" in content
        finally:
            if build_path.exists():
                build_path.unlink()
            md_path = Path("data/content/articles/manual-art.md")
            if md_path.exists():
                md_path.unlink()

    async def test_article_reserved_path_rejected(self, admin_client, db):
        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/articles/new", form={
            "csrf_token": "test",
            "title": "Bad Path",
            "slug": "bad-path",
            "url_path": "/admin/evil",
            "body": "# Nope",
            "status": "draft",
        })
        # Should re-render form with error, not redirect
        assert resp.status_code == 200
        html = (await resp.data).decode()
        assert "reserved" in html.lower() or "conflict" in html.lower()

    async def test_article_publish_toggle(self, admin_client, db):
        article_id = await _create_article(slug="toggle-art", url_path="/toggle-art", status="draft")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post(f"/admin/articles/{article_id}/publish", form={
            "csrf_token": "test",
        })
        assert resp.status_code == 302

        row = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
        assert row["status"] == "published"

        # Toggle back
        resp = await admin_client.post(f"/admin/articles/{article_id}/publish", form={
            "csrf_token": "test",
        })
        row = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
        assert row["status"] == "draft"

    async def test_article_delete(self, admin_client, db):
        article_id = await _create_article(slug="del-art", url_path="/del-art")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post(f"/admin/articles/{article_id}/delete", form={
            "csrf_token": "test",
        })
        assert resp.status_code == 302
        assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None

    async def test_delete_never_removes_md_source(self, admin_client, db, tmp_path, monkeypatch):
        """Regression: deleting an article must NOT touch source .md files."""
        import padelnomics.content.routes as content_routes_mod

        build_dir = tmp_path / "build"
        build_dir.mkdir()
        monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)

        (build_dir / "del-safe.html").write_text("<p>built</p>")
        md_file = tmp_path / "del-safe.md"
        md_file.write_text("# Source")

        article_id = await _create_article(slug="del-safe", url_path="/del-safe")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post(f"/admin/articles/{article_id}/delete", form={
            "csrf_token": "test",
        })
        assert resp.status_code == 302
        assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
        assert not (build_dir / "del-safe.html").exists(), "build file should be removed"
        assert md_file.exists(), "source .md must NOT be deleted"

    async def test_bulk_delete_by_ids_never_removes_md(self, admin_client, db, tmp_path, monkeypatch):
        """Regression: bulk delete by explicit IDs must NOT touch source .md files."""
        import padelnomics.content.routes as content_routes_mod

        build_dir = tmp_path / "build"
        build_dir.mkdir()
        monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)

        (build_dir / "bulk-del-1.html").write_text("<p>1</p>")
        (build_dir / "bulk-del-2.html").write_text("<p>2</p>")
        md1 = tmp_path / "bulk-del-1.md"
        md2 = tmp_path / "bulk-del-2.md"
        md1.write_text("# One")
        md2.write_text("# Two")

        id1 = await _create_article(slug="bulk-del-1", url_path="/bulk-del-1", article_type="generated")
        id2 = await _create_article(slug="bulk-del-2", url_path="/bulk-del-2", article_type="cornerstone")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/articles/bulk", form={
            "csrf_token": "test",
            "action": "delete",
            "article_ids": f"{id1},{id2}",
            "apply_to_all": "false",
            "article_type": "generated",
        })
        assert resp.status_code == 200
        assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (id1,)) is None
        assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (id2,)) is None
        assert not (build_dir / "bulk-del-1.html").exists()
        assert not (build_dir / "bulk-del-2.html").exists()
        assert md1.exists(), "generated article .md must NOT be deleted"
        assert md2.exists(), "cornerstone article .md must NOT be deleted"

    async def test_bulk_delete_apply_to_all_never_removes_md(self, admin_client, db, tmp_path, monkeypatch):
        """Regression: bulk delete apply_to_all must NOT touch source .md files."""
        import padelnomics.content.routes as content_routes_mod

        build_dir = tmp_path / "build"
        build_dir.mkdir()
        monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)

        (build_dir / "ata-del.html").write_text("<p>x</p>")
        md_file = tmp_path / "ata-del.md"
        md_file.write_text("# Source")

        await _create_article(slug="ata-del", url_path="/ata-del", article_type="generated")

        async with admin_client.session_transaction() as sess:
            sess["csrf_token"] = "test"

        resp = await admin_client.post("/admin/articles/bulk", form={
            "csrf_token": "test",
            "action": "delete",
            "apply_to_all": "true",
            "article_type": "generated",
            "search": "ata-del",
        })
        assert resp.status_code == 200
        assert await fetch_one("SELECT 1 FROM articles WHERE slug = 'ata-del'") is None
        assert not (build_dir / "ata-del.html").exists()
        assert md_file.exists(), "source .md must NOT be deleted"




# ════════════════════════════════════════════════════════════
# Admin dashboard quick links
# ════════════════════════════════════════════════════════════

class TestAdminDashboardLinks:
    async def test_dashboard_has_content_links(self, admin_client):
        resp = await admin_client.get("/admin/")
        html = (await resp.data).decode()
        # Sidebar always shows the Content entry point (/admin/articles).
        # Templates and Scenarios appear in the subnav only when on a content page.
        assert "/admin/articles" in html


# ════════════════════════════════════════════════════════════
# Footer
# ════════════════════════════════════════════════════════════

class TestFooterMarkets:
    async def test_footer_has_markets_link(self, client):
        resp = await client.get("/en/")
        html = (await resp.data).decode()
        assert "/markets" in html
