#!/usr/bin/env python3
"""
build_content.py  --  render content/ source files into the site's detail pages.

Implements U2 of docs/plans/2026-08-08-001-feat-no-code-cms-plan.md. Reads the
front-matter + body produced by U1 (see docs/content-model.md) and writes the
blog / news / event detail pages under <lang>/{blog,news,projects}/<slug>/.

Design: clone a template page of the SAME language + type (the page's own
current file when it exists — "self-template" — so all chrome, scripts, nav and
footer are preserved byte-for-byte), then replace only the regions that are
derived from content:
  - <title>
  - article header (date line, H1, subtitle)   [article-page: blog + event]
  - hero <figure>
  - scrollspy sidebar (regenerated from the body's <h2 id> headings)  [blog+event]
  - the article body (verbatim for body_format: html; converted for markdown)
  - news pages use the simpler .news-article-* structure (no header/scrollspy)

For body_format: html (what migration produces), the stored body fragment is
injected verbatim, so guide-boxes, inline figures and event galleries render
exactly as before. body_format: markdown (new CMS posts) is converted via
build_blog's Markdown helpers.

Usage:
    python tools/build_content.py --check     # build in memory, diff vs current pages
    python tools/build_content.py --write      # write the pages
"""
import argparse
import html as html_mod
import os
import re
import sys

import yaml

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import build_blog  # markdown -> sections/body for body_format: markdown  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LANGS = ("en", "mk", "si")
TYPE_TO_DIR = {"blog": "blog", "news": "news", "events": "events"}
# content type -> output url folder (events render to the existing projects/ tree)
TYPE_TO_OUT_FOLDER = {"blog": "blog", "news": "news", "event": "projects"}
DEPTH_PREFIX = "../../../"  # every detail page sits at <lang>/<folder>/<slug>/


# --------------------------------------------------------------------------- IO

def load_entry(path):
    """Parse a content file into (meta_dict, body_str)."""
    with open(path, encoding="utf-8") as f:
        text = f.read()
    if not text.startswith("---\n"):
        raise ValueError(f"{path}: missing front-matter fence")
    _, front, body = text.split("---\n", 2)
    meta = yaml.safe_load(front) or {}
    return meta, body.strip("\n")


def iter_entries():
    """Yield (meta, body, content_path) for every content/ file."""
    for ctype_dir in TYPE_TO_DIR.values():
        base = os.path.join(ROOT, "content", ctype_dir)
        if not os.path.isdir(base):
            continue
        for slug in sorted(os.listdir(base)):
            for lang in LANGS:
                path = os.path.join(base, slug, f"{lang}.md")
                if os.path.exists(path):
                    meta, body = load_entry(path)
                    yield meta, body, path


def output_path(meta):
    folder = TYPE_TO_OUT_FOLDER[meta["type"]]
    return os.path.join(ROOT, meta["lang"], folder, meta["slug"], "index.html")


# ------------------------------------------------------------------- rendering

def _rel_img(src):
    """Rewrite a root-relative /images/... path to the page's ../../../ depth."""
    if src and src.startswith("/images/"):
        return DEPTH_PREFIX + src.lstrip("/")
    return src


def render_body(meta, body):
    """Return the body region markup (from <div class=...-body> to just before
    the container's </article>)."""
    if meta.get("body_format", "html") == "html":
        return body  # verbatim fragment (already includes the body wrapper)
    # markdown -> article-body via build_blog's converter
    _t, _s, sections = build_blog.parse_markdown("# x\n\n" + body)
    inner = build_blog.render_body(sections, meta.get("category", ""), "", "")
    return '<div class="article-body">\n' + inner + '\n                </div>'


def _scrollspy_is_populated(base):
    m = re.search(r'<nav class="scrollspy-nav">\s*<ul>(.*?)</ul>', base, re.DOTALL)
    return bool(m and "<li" in m.group(1))


def render_scrollspy_from_body(body_html):
    """Regenerate the scrollspy <li> list from the body's <h2 id=...> headings."""
    items, first = [], True
    for m in re.finditer(r'<h2 id="([^"]+)">(.*?)</h2>', body_html, re.DOTALL):
        hid, label = m.group(1), re.sub(r"<[^>]+>", "", m.group(2)).strip()
        cls = ' class="active"' if first else ""
        items.append(f'                        <li><a href="#{hid}"{cls}>{label}</a></li>')
        first = False
    return "\n".join(items)


def render_article_header(meta):
    lines = [f'                    <p class="article-publish-date">{meta.get("date_line", "")}</p>',
             f'                    <h1>{html_mod.escape(meta["title"])}</h1>']
    if meta.get("subtitle"):
        lines.append(f'                    <p class="article-subtitle">{html_mod.escape(meta["subtitle"])}</p>')
    # First line carries no indent: the template's pre-tag whitespace positions
    # it. Adding our own would compound on every rebuild (non-idempotent).
    return "\n".join(lines).lstrip()


def render_hero(hero, css_class):
    """Rebuild a hero <figure> (article-main-image or news-article-hero)."""
    if not hero:
        return None
    attrs = []
    if hero.get("width"):
        attrs.append(f'width="{hero["width"]}"')
    if hero.get("height"):
        attrs.append(f'height="{hero["height"]}"')
    attrs.append('fetchpriority="high"')
    attrs.append(f'src="{_rel_img(hero.get("src", ""))}"')
    attrs.append(f'alt="{html_mod.escape(hero.get("alt", ""))}"')
    fig = [f'                <figure class="{css_class}">',
           f'                    <img {" ".join(attrs)}>']
    if hero.get("caption_html"):
        fig.append(f'                    <figcaption>{hero["caption_html"]}</figcaption>')
    fig.append('                </figure>')
    # First line unindented — the template's pre-tag whitespace positions it
    # (otherwise indentation compounds on every rebuild).
    return "\n".join(fig).lstrip()


# ------------------------------------------------------------- page assembly

def _title_suffix(base, escaped_title):
    """Preserve each page's existing <title> suffix verbatim (EN/SI use
    ' - MSOS', MK uses the Cyrillic ' - МСОС', some events use none) by taking
    whatever trails the title base in the current page. Language-agnostic."""
    m = re.search(r"<title>(.*?)</title>", base, re.DOTALL)
    if not m:
        return ""
    cur = m.group(1)
    return cur[len(escaped_title):] if cur.startswith(escaped_title) else ""


def _sub1(pattern, repl, text, what):
    new, n = re.subn(pattern, lambda m: repl, text, count=1, flags=re.DOTALL)
    if n != 1:
        raise ValueError(f"region not found: {what}")
    return new


def build_article_page(meta, body, base):
    """blog + event pages (main.article-page)."""
    title = html_mod.escape(meta["title"])
    base = _sub1(r"<title>.*?</title>", f"<title>{title}{_title_suffix(base, title)}</title>", base, "title")
    base = _sub1(
        r'<p class="article-publish-date">.*?</p>\s*<h1>.*?</h1>(?:\s*<p class="article-subtitle">.*?</p>)?',
        render_article_header(meta), base, "article-header",
    )
    hero = render_hero(meta.get("hero"), "article-main-image")
    if hero:
        base = _sub1(r'<figure class="article-main-image">.*?</figure>', hero, base, "hero")
    # Scrollspy labels are hand-curated (often shorter than the H2 headings), so
    # a populated sidebar is preserved as-is. Only generate one when the template
    # has none yet (a brand-new page). See docs/content-model.md (`toc`).
    if not _scrollspy_is_populated(base):
        nav = render_scrollspy_from_body(body)
        if nav:
            base = re.sub(r'(<nav class="scrollspy-nav">\s*<ul>).*?(</ul>)',
                          lambda m: m.group(1) + "\n" + nav + "\n                    " + m.group(2),
                          base, count=1, flags=re.DOTALL)
    base = _sub1(r'<div class="article-body">.*?</article>',
                 body + "\n            </article>", base, "article-body")
    return base


def build_news_page(meta, body, base):
    """news pages (main.news-article)."""
    title = html_mod.escape(meta["title"])
    base = _sub1(r"<title>.*?</title>", f"<title>{title}{_title_suffix(base, title)}</title>", base, "title")
    base = _sub1(r"<h1>.*?</h1>", f"<h1>{title}</h1>", base, "news h1")
    base = _sub1(r'<p class="news-article-date">.*?</p>',
                 f'<p class="news-article-date">{meta.get("date_line", "")}</p>', base, "news date")
    hero = render_hero(meta.get("hero"), "news-article-hero")
    if hero:
        base = _sub1(r'<figure class="news-article-hero">.*?</figure>', hero, base, "news hero")
    base = _sub1(r'<div class="news-article-body">.*?</article>',
                 body + "\n        </article>", base, "news body")
    return base


def build_entry(meta, body, base_html):
    if meta["type"] == "news":
        return build_news_page(meta, body, base_html)
    return build_article_page(meta, body, base_html)


def _template_html(meta):
    """Self-template: use the page's own current file as the chrome skeleton."""
    out = output_path(meta)
    if not os.path.exists(out):
        raise FileNotFoundError(
            f"no template page for {meta['lang']}/{meta['type']}/{meta['slug']} "
            f"(new-page templates are handled in a later unit)")
    with open(out, encoding="utf-8") as f:
        return f.read()


def run(write=False, check=False):
    built = 0
    mismatches = []
    for meta, body, path in iter_entries():
        base = _template_html(meta)
        rendered = build_entry(meta, body, base)
        out = output_path(meta)
        if check:
            with open(out, encoding="utf-8") as f:
                current = f.read()
            if _normalize(rendered) != _normalize(current):
                mismatches.append(os.path.relpath(out, ROOT).replace(os.sep, "/"))
        if write:
            with open(out, "w", encoding="utf-8", newline="\n") as f:
                f.write(rendered)
        built += 1

    print(f"Rendered {built} pages from content/.")
    if check:
        if mismatches:
            print(f"\n{len(mismatches)} page(s) differ from the current site "
                  f"(semantic comparison):")
            for m in mismatches:
                print(f"  ~ {m}")
        else:
            print("All rendered pages are equivalent to the current site. [OK]")
    return built, mismatches


# ----------------------------------------------------- normalization (for --check)

def _normalize(html_text):
    """Collapse insignificant whitespace and canonicalise attribute order so the
    comparison ignores formatting-only differences (per plan R5)."""
    from bs4 import BeautifulSoup
    soup = BeautifulSoup(html_text, "html.parser")
    for tag in soup.find_all(True):
        if tag.attrs:
            tag.attrs = {k: tag.attrs[k] for k in sorted(tag.attrs)}
    text = str(soup)
    text = re.sub(r">\s+<", "><", text)   # drop whitespace between tags
    text = re.sub(r"\s+", " ", text)       # collapse runs of whitespace
    return text.strip()


if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--write", action="store_true", help="write the detail pages")
    ap.add_argument("--check", action="store_true",
                    help="compare rendered output to the current pages (no write)")
    args = ap.parse_args()
    if not (args.write or args.check):
        args.check = True
    run(write=args.write, check=args.check)
