#!/usr/bin/env python3
"""Rebuild the schedule table in schedule.md from a Google Sheet.

The sheet is the source of truth. This script fetches it as CSV, renders a
markdown table, and splices it into schedule.md between the BEGIN/END markers.
Everything outside the markers is left alone.

Usage:
    # from the published Google Sheet (URL from --url or $SCHEDULE_CSV_URL)
    python scripts/build_schedule.py

    # from a local CSV, e.g. to test before publishing
    python scripts/build_schedule.py --csv schedule.csv

    # check for drift without writing (exit 1 if out of date)
    python scripts/build_schedule.py --check
"""

import argparse
import csv
import io
import os
import re
import sys
import urllib.request

BEGIN = "<!-- BEGIN SCHEDULE TABLE (generated by scripts/build_schedule.py — edit the Google Sheet, not this) -->"
END = "<!-- END SCHEDULE TABLE -->"

DEFAULT_TARGET = "schedule.md"


def fetch_csv(url: str) -> str:
    with urllib.request.urlopen(url, timeout=30) as resp:
        if resp.status != 200:
            sys.exit(f"error: fetching the sheet returned HTTP {resp.status}")
        body = resp.read().decode("utf-8-sig")
    if body.lstrip().lower().startswith("<!doctype html"):
        sys.exit(
            "error: got an HTML page instead of CSV. The sheet is probably not "
            "shared publicly. Use File > Share > Publish to web > CSV, or set "
            "link sharing to 'Anyone with the link can view'."
        )
    return body


def escape(cell: str) -> str:
    """Make a sheet cell safe to drop into a markdown table cell."""
    cell = cell.replace("|", "\\|")
    # Sheets uses real newlines for in-cell line breaks; markdown tables need <br>
    cell = re.sub(r"\r?\n", "<br>", cell)
    return cell.strip()


def syllabus_anchors(path: str = "syllabus.md") -> set[str] | None:
    """Collect the heading anchors GitHub will generate for the syllabus."""
    try:
        with open(path, encoding="utf-8") as f:
            text = f.read()
    except OSError:
        return None
    anchors = set()
    for line in text.splitlines():
        m = re.match(r"^#{2,6}\s+(.*)$", line)
        if not m:
            continue
        slug = re.sub(r"[^\w\s-]", "", m.group(1).strip().lower(), flags=re.UNICODE)
        anchors.add(slug.replace(" ", "-"))
    return anchors


def check_links(rows: list[list[str]]) -> None:
    """Warn about links into syllabus.md whose anchors don't exist."""
    known = syllabus_anchors()
    if not known:
        return
    seen = set()
    for row in rows:
        for cell in row:
            for anchor in re.findall(r"syllabus\.md#([\w-]+)", cell):
                if anchor not in known and anchor not in seen:
                    seen.add(anchor)
                    print(
                        f"warning: no heading in syllabus.md matches #{anchor}",
                        file=sys.stderr,
                    )


def render(rows: list[list[str]]) -> str:
    if not rows:
        sys.exit("error: the sheet is empty")

    header = [escape(c) for c in rows[0]]
    width = len(header)
    if width < 2:
        sys.exit("error: expected at least two columns in the sheet")

    out = ["| " + " | ".join(header) + " |"]
    out.append("| " + " | ".join(["---"] * width) + " |")

    for lineno, raw in enumerate(rows[1:], start=2):
        # skip fully blank rows so trailing empty rows in the sheet don't render
        if not any(c.strip() for c in raw):
            continue
        cells = [escape(c) for c in raw]
        if len(cells) > width:
            print(
                f"warning: row {lineno} has {len(cells)} cells, expected {width}; "
                "extra cells dropped",
                file=sys.stderr,
            )
            cells = cells[:width]
        cells += [""] * (width - len(cells))
        out.append("| " + " | ".join(cells) + " |")

    return "\n".join(out)


def splice(target: str, table: str) -> tuple[str, str]:
    with open(target, encoding="utf-8") as f:
        current = f.read()

    if BEGIN not in current or END not in current:
        sys.exit(
            f"error: {target} is missing the schedule markers. Add these two lines "
            f"where the table should go:\n\n{BEGIN}\n{END}\n"
        )

    pattern = re.compile(
        re.escape(BEGIN) + r".*?" + re.escape(END), re.DOTALL
    )
    updated = pattern.sub(f"{BEGIN}\n\n{table}\n\n{END}", current)
    return current, updated


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--url", default=os.environ.get("SCHEDULE_CSV_URL"))
    ap.add_argument("--csv", help="read from a local CSV file instead of the sheet")
    ap.add_argument("--target", default=DEFAULT_TARGET)
    ap.add_argument(
        "--check",
        action="store_true",
        help="exit 1 if the file is out of date; don't write",
    )
    args = ap.parse_args()

    if args.csv:
        with open(args.csv, encoding="utf-8-sig") as f:
            text = f.read()
    elif args.url:
        text = fetch_csv(args.url)
    else:
        sys.exit(
            "error: no source. Pass --csv FILE, or --url, or set SCHEDULE_CSV_URL."
        )

    rows = list(csv.reader(io.StringIO(text)))
    check_links(rows)
    table = render(rows)
    current, updated = splice(args.target, table)

    if current == updated:
        print(f"{args.target} is already up to date")
        return

    if args.check:
        sys.exit(f"{args.target} is out of date with the sheet")

    with open(args.target, "w", encoding="utf-8") as f:
        f.write(updated)
    print(f"updated {args.target} ({len(rows) - 1} rows)")


if __name__ == "__main__":
    main()
