"""
SSG-inspired pSEO content engine.

Templates live in git as .md.jinja files with YAML frontmatter.
Data comes from DuckDB serving tables.  Only articles + published_scenarios
are stored in SQLite (routing / application state).
"""
import json
import logging
import re
import time
from datetime import UTC, date, datetime, timedelta
from pathlib import Path

import mistune
import yaml
from jinja2 import ChainableUndefined, Environment

from ..analytics import fetch_analytics
from ..core import REPO_ROOT, slugify, transaction, utcnow_iso
from ..i18n import get_country_name

logger = logging.getLogger(__name__)

# ── Constants ────────────────────────────────────────────────────────────────

TEMPLATES_DIR = Path(__file__).parent / "templates"
BUILD_DIR = REPO_ROOT / "data" / "content" / "_build"

# Threshold functions per template slug.
# Return True → article should be noindex (insufficient data for quality content).
NOINDEX_THRESHOLDS: dict = {
    "city-pricing":     lambda row: (row.get("venue_count") or 0) < 3,
    "city-cost-de":     lambda row: (row.get("data_confidence") or 0) < 1.0,
    "country-overview": lambda row: (row.get("total_venues") or 0) < 5,
}

_REQUIRED_FRONTMATTER = {
    "name", "slug", "content_type", "data_table",
    "natural_key", "languages", "url_pattern", "title_pattern",
    "meta_description_pattern",
}

_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)

# FAQ extraction: **bold question** followed by answer paragraph(s)
_FAQ_RE = re.compile(
    r"\*\*(.+?)\*\*\s*\n((?:(?!\*\*).+\n?)+)",
    re.MULTILINE,
)


# ── Template discovery & loading ─────────────────────────────────────────────

def discover_templates() -> list[dict]:
    """Scan TEMPLATES_DIR for .md.jinja files, return parsed frontmatter list."""
    templates = []
    if not TEMPLATES_DIR.exists():
        return templates

    for path in sorted(TEMPLATES_DIR.glob("*.md.jinja")):
        try:
            config = _parse_frontmatter(path.read_text())
            config["_path"] = str(path)
            templates.append(config)
        except (ValueError, yaml.YAMLError):
            continue
    return templates


def load_template(slug: str) -> dict:
    """Load a single template by slug.  Returns frontmatter + body_template."""
    path = TEMPLATES_DIR / f"{slug}.md.jinja"
    assert path.exists(), f"Template not found: {slug}"

    text = path.read_text()
    config = _parse_frontmatter(text)

    # Everything after the closing --- is the body template
    match = _FRONTMATTER_RE.match(text)
    assert match, f"No frontmatter in {slug}"
    config["body_template"] = text[match.end():]
    return config


def _parse_frontmatter(text: str) -> dict:
    """Extract YAML frontmatter from a template file."""
    match = _FRONTMATTER_RE.match(text)
    if not match:
        raise ValueError("No YAML frontmatter found")

    config = yaml.safe_load(match.group(1))
    assert isinstance(config, dict), "Frontmatter must be a YAML mapping"

    missing = _REQUIRED_FRONTMATTER - set(config.keys())
    assert not missing, f"Missing frontmatter keys: {missing}"

    # Normalize schema_type to list
    schema_type = config.get("schema_type", "Article")
    if isinstance(schema_type, str):
        schema_type = [schema_type]
    config["schema_type"] = schema_type

    return config


# ── DuckDB data access ───────────────────────────────────────────────────────

async def get_table_columns(data_table: str) -> list[dict]:
    """Query DuckDB information_schema for a serving table's columns."""
    assert "." in data_table, "data_table must be schema-qualified (e.g. serving.xxx)"
    schema, table = data_table.split(".", 1)

    rows = await fetch_analytics(
        """SELECT column_name, data_type
           FROM information_schema.columns
           WHERE table_schema = ? AND table_name = ?
           ORDER BY ordinal_position""",
        [schema, table],
    )
    return [{"name": r["column_name"], "type": r["data_type"]} for r in rows]


async def fetch_template_data(
    data_table: str,
    order_by: str | None = None,
    limit: int = 0,
) -> list[dict]:
    """Fetch rows from a DuckDB serving table. limit=0 means all rows."""
    assert "." in data_table, "data_table must be schema-qualified"
    _validate_table_name(data_table)

    order_clause = f"ORDER BY {order_by} DESC" if order_by else ""
    if limit:
        return await fetch_analytics(
            f"SELECT * FROM {data_table} {order_clause} LIMIT ?",
            [limit],
        )
    return await fetch_analytics(f"SELECT * FROM {data_table} {order_clause}")


async def count_template_data(data_table: str) -> int:
    """Return the row count of a DuckDB serving table. Returns 0 if unavailable."""
    assert "." in data_table, "data_table must be schema-qualified"
    _validate_table_name(data_table)

    rows = await fetch_analytics(f"SELECT COUNT(*) AS cnt FROM {data_table}")
    return rows[0]["cnt"] if rows else 0


def _validate_table_name(data_table: str) -> None:
    """Guard against SQL injection in table names."""
    assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
        f"Invalid table name: {data_table}"
    )


# ── Rendering helpers ────────────────────────────────────────────────────────

def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str:
    """Jinja2 filter: format a date string (or 'now') with strftime."""
    from datetime import datetime

    if value == "now":
        dt = datetime.now(UTC)
    else:
        dt = datetime.fromisoformat(value)
    return dt.strftime(fmt)


def _render_pattern(pattern: str, context: dict) -> str:
    """Render a Jinja2 pattern string with context variables."""
    env = Environment(undefined=ChainableUndefined)
    env.filters["slugify"] = slugify
    env.filters["datetimeformat"] = _datetimeformat
    # Replace None values with 0 so numeric filters (round, int) don't crash
    safe_context = {k: (v if v is not None else 0) for k, v in context.items()}
    return env.from_string(pattern).render(**safe_context)


def _extract_faq_pairs(markdown: str) -> list[dict]:
    """Extract FAQ Q&A pairs from a ## FAQ section in markdown."""
    # Find the FAQ section
    faq_start = markdown.find("## FAQ")
    if faq_start == -1:
        return []

    # Take content until next ## heading or end
    rest = markdown[faq_start:]
    next_h2 = rest.find("\n## ", 1)
    faq_block = rest[:next_h2] if next_h2 > 0 else rest

    pairs = []
    for match in _FAQ_RE.finditer(faq_block):
        question = match.group(1).strip()
        answer = match.group(2).strip()
        if question and answer:
            pairs.append({"question": question, "answer": answer})
    return pairs


# ── JSON-LD structured data ──────────────────────────────────────────────────

def build_jsonld(
    schema_types: list[str],
    *,
    title: str,
    description: str,
    url: str,
    published_at: str,
    date_modified: str,
    language: str,
    breadcrumbs: list[dict],
    faq_pairs: list[dict] | None = None,
) -> list[dict]:
    """Build JSON-LD structured data objects for an article."""
    objects = []

    # BreadcrumbList — always present
    objects.append({
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {
                "@type": "ListItem",
                "position": i + 1,
                "name": bc["name"],
                "item": bc["url"],
            }
            for i, bc in enumerate(breadcrumbs)
        ],
    })

    # Article
    if "Article" in schema_types:
        objects.append({
            "@context": "https://schema.org",
            "@type": "Article",
            "headline": title[:110],
            "description": description[:200],
            "url": url,
            "inLanguage": language,
            "datePublished": published_at,
            "dateModified": date_modified,
            "author": {
                "@type": "Organization",
                "name": "Padelnomics",
                "url": "https://padelnomics.io",
            },
            "publisher": {
                "@type": "Organization",
                "name": "Padelnomics",
                "url": "https://padelnomics.io",
            },
        })

    # FAQPage
    if "FAQPage" in schema_types and faq_pairs:
        objects.append({
            "@context": "https://schema.org",
            "@type": "FAQPage",
            "mainEntity": [
                {
                    "@type": "Question",
                    "name": faq["question"],
                    "acceptedAnswer": {
                        "@type": "Answer",
                        "text": faq["answer"],
                    },
                }
                for faq in faq_pairs
            ],
        })

    return objects


def _build_breadcrumbs(url_path: str, base_url: str) -> list[dict]:
    """Build breadcrumb list from URL path segments."""
    parts = [p for p in url_path.strip("/").split("/") if p]
    crumbs = [{"name": "Home", "url": base_url + "/"}]
    for i, part in enumerate(parts):
        label = part.replace("-", " ").title()
        path = "/" + "/".join(parts[: i + 1])
        crumbs.append({"name": label, "url": base_url + path})
    return crumbs


# ── Article generation pipeline ──────────────────────────────────────────────

async def generate_articles(
    slug: str,
    start_date: date,
    articles_per_day: int,
    *,
    limit: int = 0,
    base_url: str = "https://padelnomics.io",
    task_id: int | None = None,
) -> int:
    """
    Generate articles from a git template + DuckDB data.

    For each row in the DuckDB table x each language:
      - render patterns (url, title, meta)
      - create/update published_scenario if calculator type
      - render body markdown -> HTML
      - bake scenario cards
      - inject SEO head (canonical, hreflang, JSON-LD, OG)
      - write HTML to disk
      - upsert article row in SQLite

    If task_id is given, writes progress_current / progress_total / error_log
    to the tasks table every _PROGRESS_BATCH articles so the pSEO dashboard
    can show a live progress bar.  Per-article errors are logged and collected
    rather than aborting the run — the full task still completes.

    Returns count of articles generated (excluding per-article errors).
    """
    from ..core import execute as db_execute
    from ..planner.calculator import DEFAULTS, calc, validate_state
    from .routes import bake_product_cards, bake_scenario_cards, is_reserved_path

    assert articles_per_day > 0, "articles_per_day must be positive"

    config = load_template(slug)
    order_by = config.get("priority_column")
    rows = await fetch_template_data(config["data_table"], order_by=order_by, limit=limit)

    if not rows:
        return 0

    # Pre-compile all Jinja templates once — avoids creating a new Environment()
    # and re-parsing the same template strings on every iteration.
    _env = Environment(undefined=ChainableUndefined)
    _env.filters["slugify"] = slugify
    _env.filters["datetimeformat"] = _datetimeformat
    url_tmpl   = _env.from_string(config["url_pattern"])
    title_tmpl = _env.from_string(config["title_pattern"])
    meta_tmpl  = _env.from_string(config["meta_description_pattern"])
    body_tmpl  = _env.from_string(config["body_template"])

    publish_date = start_date
    published_today = 0
    generated = 0
    now_iso = utcnow_iso()

    # Timing accumulators — logged at end so we can see where time goes.
    t_calc = t_render = t_bake = 0.0

    _BATCH_SIZE = 200
    _PROGRESS_BATCH = 50  # write task progress every N articles (avoid write amplification)

    # Write progress_total before the loop so the dashboard can show 0/N immediately.
    if task_id is not None:
        total = len(rows) * len(config["languages"])
        await db_execute(
            "UPDATE tasks SET progress_total = ? WHERE id = ?",
            (total, task_id),
        )

    async with transaction() as db:
        for row in rows:
            for lang in config["languages"]:
                # Build render context, replacing None with 0 so numeric
                # Jinja filters (round, int) don't crash.
                safe_ctx = {k: (v if v is not None else 0) for k, v in row.items()}
                safe_ctx["language"] = lang
                if "country_name_en" in safe_ctx:
                    safe_ctx["country_name"] = get_country_name(
                        safe_ctx["country_name_en"], lang
                    )

                # Render URL pattern (no lang prefix — blueprint provides /<lang>)
                url_path = url_tmpl.render(**safe_ctx)
                if is_reserved_path(url_path):
                    continue

                title     = title_tmpl.render(**safe_ctx)
                meta_desc = meta_tmpl.render(**safe_ctx)
                article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])

                # Calculator content type: create scenario
                scenario_slug = None
                scenario_overrides = None
                if config["content_type"] == "calculator":
                    t0 = time.perf_counter()
                    # DuckDB lowercases all column names; build a case-insensitive
                    # reverse map so "ratepeak" (stored) matches "ratePeak" (DEFAULTS).
                    _defaults_ci = {k.lower(): k for k in DEFAULTS}
                    calc_overrides = {
                        _defaults_ci[k.lower()]: v
                        for k, v in row.items()
                        if k.lower() in _defaults_ci and v is not None
                    }
                    state = validate_state(calc_overrides)
                    d = calc(state, lang=lang)
                    t_calc += time.perf_counter() - t0

                    scenario_slug = slug + "-" + str(row[config["natural_key"]])
                    dbl = state.get("dblCourts", 0)
                    sgl = state.get("sglCourts", 0)
                    court_config = f"{dbl} double + {sgl} single"
                    city = row.get("city_name", row.get("city", ""))
                    country = row.get("country", state.get("country", ""))

                    await db.execute(
                        """INSERT INTO published_scenarios
                               (slug, title, location, country, venue_type, ownership,
                                court_config, state_json, calc_json, created_at)
                           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                           ON CONFLICT(slug) DO UPDATE SET
                               state_json = excluded.state_json,
                               calc_json  = excluded.calc_json,
                               updated_at = excluded.created_at""",
                        (
                            scenario_slug, city, city, country,
                            state.get("venue", "indoor"),
                            state.get("own", "rent"),
                            court_config,
                            json.dumps(state), json.dumps(d), now_iso,
                        ),
                    )

                    safe_ctx["scenario_slug"] = scenario_slug
                    # Pass scenario data directly so bake_scenario_cards skips the
                    # DB re-fetch (the row was just upserted and may not be visible
                    # on a separate connection within the same uncommitted transaction).
                    scenario_overrides = {
                        scenario_slug: {
                            "slug": scenario_slug,
                            "title": city,
                            "location": city,
                            "country": country,
                            "venue_type": state.get("venue", "indoor"),
                            "ownership": state.get("own", "rent"),
                            "court_config": court_config,
                            "state_json": json.dumps(state),
                            "calc_json": json.dumps(d),
                            "created_at": now_iso,
                        }
                    }

                # Render body template
                t0 = time.perf_counter()
                body_md   = body_tmpl.render(**safe_ctx)
                body_html = mistune.html(body_md)
                t_render += time.perf_counter() - t0

                t0 = time.perf_counter()
                body_html = await bake_scenario_cards(
                    body_html, lang=lang, scenario_overrides=scenario_overrides
                )
                body_html = await bake_product_cards(body_html, lang=lang)
                t_bake += time.perf_counter() - t0

                # Extract FAQ pairs for structured data
                faq_pairs = _extract_faq_pairs(body_md)

                # Build SEO metadata (full_url includes lang prefix for canonical/OG)
                full_url = f"{base_url}/{lang}{url_path}"
                publish_dt = datetime(
                    publish_date.year, publish_date.month, publish_date.day,
                    8, 0, 0,
                ).isoformat()

                # Hreflang links — reuse compiled url_tmpl with swapped language
                hreflang_links = []
                for alt_lang in config["languages"]:
                    alt_url = f"/{alt_lang}" + url_tmpl.render(**{**safe_ctx, "language": alt_lang})
                    hreflang_links.append(
                        f'<link rel="alternate" hreflang="{alt_lang}" href="{base_url}{alt_url}" />'
                    )
                # x-default points to English (or first language)
                default_lang = "en" if "en" in config["languages"] else config["languages"][0]
                default_url = f"/{default_lang}" + url_tmpl.render(**{**safe_ctx, "language": default_lang})
                hreflang_links.append(
                    f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_url}" />'
                )

                # JSON-LD
                breadcrumbs = _build_breadcrumbs(f"/{lang}{url_path}", base_url)
                jsonld_objects = build_jsonld(
                    config["schema_type"],
                    title=title,
                    description=meta_desc,
                    url=full_url,
                    published_at=publish_dt,
                    date_modified=now_iso,
                    language=lang,
                    breadcrumbs=breadcrumbs,
                    faq_pairs=faq_pairs,
                )

                # Build SEO head block
                seo_head = "\n".join([
                    f'<link rel="canonical" href="{full_url}" />',
                    *hreflang_links,
                    f'<meta property="og:title" content="{_escape_attr(title)}" />',
                    f'<meta property="og:description" content="{_escape_attr(meta_desc)}" />',
                    f'<meta property="og:url" content="{full_url}" />',
                    '<meta property="og:type" content="article" />',
                    *[
                        f'<script type="application/ld+json">{json.dumps(obj, ensure_ascii=False)}</script>'
                        for obj in jsonld_objects
                    ],
                ])

                # Write HTML to disk
                build_dir = BUILD_DIR / lang
                build_dir.mkdir(parents=True, exist_ok=True)
                (build_dir / f"{article_slug}.html").write_text(body_html)

                # Write markdown source to disk (for admin editing)
                md_dir = BUILD_DIR / lang / "md"
                md_dir.mkdir(parents=True, exist_ok=True)
                (md_dir / f"{article_slug}.md").write_text(body_md)

                # Evaluate noindex threshold for this template + data row.
                _threshold = NOINDEX_THRESHOLDS.get(slug)
                should_noindex = 1 if _threshold and _threshold(row) else 0

                # Upsert article — keyed by (url_path, language).
                # Single statement: no SELECT round-trip, no per-row commit.
                await db.execute(
                    """INSERT INTO articles
                           (url_path, slug, title, meta_description, country, region,
                            status, published_at, template_slug, language, date_modified,
                            seo_head, noindex, article_type, created_at)
                       VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, 'generated', ?)
                       ON CONFLICT(url_path, language) DO UPDATE SET
                           title            = excluded.title,
                           meta_description = excluded.meta_description,
                           template_slug    = excluded.template_slug,
                           date_modified    = excluded.date_modified,
                           seo_head         = excluded.seo_head,
                           noindex          = excluded.noindex,
                           article_type     = 'generated',
                           updated_at       = excluded.date_modified""",
                    (
                        url_path, article_slug, title, meta_desc,
                        row.get("country", ""), row.get("region", ""),
                        publish_dt, slug, lang, now_iso, seo_head,
                        should_noindex, now_iso,
                    ),
                )

                generated += 1

                # Commit every _BATCH_SIZE articles so the admin UI shows progress
                # earlier rather than waiting for the full run to complete.
                if generated % _BATCH_SIZE == 0:
                    await db.commit()
                    logger.info("%s: committed batch — %d articles", slug, generated)
                elif generated % 25 == 0:
                    logger.info("%s: %d articles written…", slug, generated)

                # Write progress every _PROGRESS_BATCH articles so the pSEO
                # dashboard live-updates without excessive write amplification.
                if task_id is not None and generated % _PROGRESS_BATCH == 0:
                    await db_execute(
                        "UPDATE tasks SET progress_current = ? WHERE id = ?",
                        (generated, task_id),
                    )

                # Stagger dates
                published_today += 1
                if published_today >= articles_per_day:
                    published_today = 0
                    publish_date += timedelta(days=1)

    # Write final progress so the dashboard shows 100% on completion.
    if task_id is not None:
        await db_execute(
            "UPDATE tasks SET progress_current = ? WHERE id = ?",
            (generated, task_id),
        )

    logger.info(
        "%s: done — %d total | calc=%.1fs render=%.1fs bake=%.1fs",
        slug, generated, t_calc, t_render, t_bake,
    )
    return generated


async def preview_article(
    slug: str,
    row_key: str,
    lang: str = "en",
    base_url: str = "https://padelnomics.io",
) -> dict:
    """
    Render one article in-memory for admin preview.
    No disk write, no DB insert.  Returns {title, url_path, html, meta_description}.
    """
    from ..planner.calculator import DEFAULTS, calc, validate_state
    from .routes import bake_product_cards, bake_scenario_cards

    config = load_template(slug)

    # Fetch one row by natural key
    _validate_table_name(config["data_table"])
    natural_key = config["natural_key"]
    rows = await fetch_analytics(
        f"SELECT * FROM {config['data_table']} WHERE {natural_key} = ? LIMIT 1",
        [row_key],
    )
    assert rows, f"No row found for {natural_key}={row_key}"
    row = rows[0]

    ctx = {**row, "language": lang}

    url_path = _render_pattern(config["url_pattern"], ctx)
    title = _render_pattern(config["title_pattern"], ctx)
    meta_desc = _render_pattern(config["meta_description_pattern"], ctx)

    # Calculator: compute scenario in-memory
    scenario_overrides = None
    if config["content_type"] == "calculator":
        # DuckDB lowercases all column names; build a case-insensitive
        # reverse map so "ratepeak" (stored) matches "ratePeak" (DEFAULTS).
        _defaults_ci = {k.lower(): k for k in DEFAULTS}
        calc_overrides = {
            _defaults_ci[k.lower()]: v
            for k, v in row.items()
            if k.lower() in _defaults_ci and v is not None
        }
        state = validate_state(calc_overrides)
        d = calc(state, lang=lang)
        scenario_slug = slug + "-" + str(row[natural_key])
        ctx["scenario_slug"] = scenario_slug

        # Build a fake scenario dict so bake_scenario_cards can render
        # without a published_scenarios DB row.
        city = row.get("city_name", row.get("city", ""))
        scenario_overrides = {
            scenario_slug: {
                "slug": scenario_slug,
                "title": city,
                "location": city,
                "country": row.get("country", state.get("country", "")),
                "venue_type": state.get("venue", "indoor"),
                "ownership": state.get("own", "rent"),
                "calc_json": json.dumps(d),
                "state_json": json.dumps(state),
            }
        }

    body_md = _render_pattern(config["body_template"], ctx)
    body_html = mistune.html(body_md)
    body_html = await bake_scenario_cards(
        body_html, lang=lang, scenario_overrides=scenario_overrides,
    )
    body_html = await bake_product_cards(body_html, lang=lang)

    return {
        "title": title,
        "url_path": url_path,
        "meta_description": meta_desc,
        "html": body_html,
    }


def _escape_attr(text: str) -> str:
    """Escape text for use in HTML attribute values."""
    return text.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
