#!/usr/bin/env python3
"""Add expandable text transcripts below media embeds on Canvas wiki pages,
or fill existing "[Transcript here]" placeholders on faculty-authored pages.

For WCAG 1.2.1 (audio-only) and 1.2.2 (captions), adds a <details> block
with the full transcript text below (or in place of a placeholder for) each
video/audio embed on the relevant pages. Writes are staged via
staging_manager.stage(), never pushed directly to Canvas. Push the staged
pages with scripts/push_to_canvas.py after reviewing them in the staging
server (Write Safety Guard, closes #208).

This script never fetches captions from Wistia. The Wistia OAuth token used
by the Wistia MCP connector is held by the MCP server; get-current-token
there returns metadata only, not the token itself, so there is no
programmatic path from this script to Wistia captions. Instead this script
consumes a local transcripts directory of {video_id}.srt or {video_id}.vtt
files that an operator (or Claude, via the Wistia MCP) has already saved.

Two modes:

1. Manual config (default, unchanged format). Pass a JSON config mapping
   each page to a transcript file:

    python add_transcripts.py <config_json>

   Config JSON format:
    {
        "transcripts_dir": "/path/to/transcript/files",
        "pages": [
            {
                "module": "m1",
                "page_slug": "m1-prepare-to-learn",
                "media_type": "audio",
                "vtt": "m1-audio.vtt",
                "file_id": 12345
            },
            {
                "module": "m1",
                "page_slug": "m1-lesson-lecture-video",
                "media_type": "video",
                "vtt": "m1-video.srt",
                "file_id": 12346
            }
        ]
    }

   The "vtt" field accepts either a .vtt or .srt file; the extension picks
   the parser. In manual mode each config entry fills at most the first
   "[Transcript here]" placeholder on its page (or, absent a placeholder,
   inserts after the media embed); for a page with multiple videos list one
   entry per placeholder. Auto mode fills all N placeholders on a page in
   one pass.

2. Auto-discovery. Walks every module page in the course, finds Wistia
   video ids embedded in the page body (in document order) and any
   "[Transcript here]" placeholders, and maps the first N video ids to the
   N placeholders found on that page:

    python add_transcripts.py --auto --course-id 123456 \\
        --transcripts-dir /path/to/transcript/files [--dry-run]

   --dry-run prints the discovered {page_slug: [video_id, ...]} mapping as
   JSON plus the set of video ids with no transcript file on disk yet, and
   writes/stages nothing.

Requires environment variables:
    CANVAS_TOKEN, CANVAS_DOMAIN

CANVAS_COURSE_ID (pre-migration env var) is used as a fallback course id
when --course-id is not passed, via canvas_api.get_config().
"""

import os
import sys
import re
import json
import html
import argparse

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from canvas_api import (
    get_config,
    require_course_id,
    get_page,
    get_modules,
    get_module_items,
)
import staging_manager

# Wistia embeds look like: https://fast.wistia.net/embed/iframe/abc123de
IFRAME_ID_RE = re.compile(r"/embed/iframe/([a-z0-9]+)")

# Tolerant match for the faculty placeholder wrapper. A transcript is
# multiple <p> elements so it must replace the whole <p>...</p> wrapper,
# not nest inside it. Attributes on the <p>/<span> vary, so only the
# opening <p ...> tag and the placeholder text are load-bearing.
PLACEHOLDER_RE = re.compile(
    r"<p[^>]*>(?:(?!</p>).)*?\[Transcript here\](?:(?!</p>).)*?</p>",
    re.IGNORECASE | re.DOTALL,
)

_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.?!])\s+")


def vtt_to_text(vtt_path):
    """Convert VTT file to plain text transcript."""
    with open(vtt_path, "r") as f:
        lines = f.readlines()

    text_lines = []
    for line in lines:
        line = line.strip()
        # Skip WEBVTT header, timestamps, and blank lines
        if not line or line == "WEBVTT" or re.match(r"\d{2}:\d{2}", line):
            continue
        text_lines.append(line)

    text = " ".join(text_lines)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def srt_to_text(srt_path):
    """Convert an SRT file to plain text transcript.

    Drops index-only lines, timestamp lines ("... --> ..."), and blank
    lines, keeping only the caption text.
    """
    with open(srt_path, "r") as f:
        lines = f.readlines()

    text_lines = []
    for line in lines:
        line = line.strip()
        if not line:
            continue
        if "-->" in line:
            continue
        if re.match(r"^\d+$", line):
            continue
        text_lines.append(line)

    text = " ".join(text_lines)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def transcript_to_text(path):
    """Convert a transcript file to plain text, dispatching on extension.
    .srt files use srt_to_text; everything else (including .vtt) uses
    vtt_to_text.
    """
    ext = os.path.splitext(path)[1].lower()
    if ext == ".srt":
        return srt_to_text(path)
    return vtt_to_text(path)


def paragraphize(text, sentences_per_para=5):
    """Group a plain-text transcript into paragraphs of roughly
    sentences_per_para sentences each. Returns a list of paragraph strings
    (no markup).
    """
    sentences = [s for s in _SENTENCE_SPLIT_RE.split(text) if s]
    if not sentences:
        return []
    return [
        " ".join(sentences[i : i + sentences_per_para])
        for i in range(0, len(sentences), sentences_per_para)
    ]


def make_transcript_body(text):
    """Generate ONLY the scrollable transcript container (no <details>/<summary>).

    Used to fill a "[Transcript here]" placeholder that already sits inside a
    faculty-authored <details> disclosure; wrapping it in another <details>
    would nest one disclosure inside another and make the student click twice.
    """
    escaped = html.escape(text)
    paragraphs = paragraphize(escaped)
    content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
    return (
        '<div style="padding: 15px; font-size: 14px; line-height: 1.6; '
        'color: #333; max-height: 400px; overflow-y: auto;">\n'
        f'{content}\n'
        '</div>'
    )


def make_transcript_block(text, media_type="video"):
    """Generate an expandable transcript HTML block (<details> + scrollable body).

    Used by the after-embed fallback path, where there is no existing
    disclosure to nest inside.
    """
    label = "Lecture Video Transcript" if media_type == "video" else "Audio Primer Transcript"
    body = make_transcript_body(text)
    return f'''<details style="margin: 15px 0; border: 1px solid #ddd; border-radius: 6px; padding: 0;">
<summary style="padding: 12px 15px; background-color: #f5f5f5; cursor: pointer; color: #8C1D40; border-radius: 6px;"><strong>{label} (click to expand)</strong></summary>
{body}
</details>'''


def replace_placeholders(body, blocks):
    """Replace each "[Transcript here]" placeholder wrapper with the
    corresponding transcript block, in document order.

    Returns (new_body, n_replaced). If there are more placeholders than
    blocks, the leftover placeholders are left untouched.
    """
    blocks_iter = iter(blocks)
    state = {"count": 0}

    def _sub(match):
        try:
            block = next(blocks_iter)
        except StopIteration:
            return match.group(0)
        state["count"] += 1
        return block

    new_body = PLACEHOLDER_RE.sub(_sub, body)
    return new_body, state["count"]


def _insert_after_embed(body, transcript_block, file_id, module, media_type):
    """Insert transcript_block after the media embed identified by file_id.

    Falls back to inserting before the first <hr>/<h2>, or appending, when
    the enclosing embed tag can't be located precisely. Used when a page has
    no "[Transcript here]" placeholder.
    """
    file_str = str(file_id)

    if file_str in body:
        idx = body.index(file_str)
        search_from = idx

        patterns_to_try = [
            (r"</a>", "</a>"),
            (r"</iframe>\s*</div>", "</div>"),
            (r"</iframe>", "</iframe>"),
        ]

        best_pos = len(body)
        for pattern, tag in patterns_to_try:
            m = re.search(pattern, body[search_from:])
            if m and (search_from + m.end()) < best_pos:
                best_pos = search_from + m.end()

        if best_pos < len(body):
            return body[:best_pos] + f"\n{transcript_block}\n" + body[best_pos:]

        m = re.search(r"(<hr|<h2)", body[idx:])
        if m:
            insert_pos = idx + m.start()
            return body[:insert_pos] + f"\n{transcript_block}\n" + body[insert_pos:]
        return body + f"\n{transcript_block}\n"

    print(f"  WARN {module} {media_type}: file ID {file_id} not found on page, appending")
    m = re.search(r"<h2", body)
    if m:
        return body[: m.start()] + f"\n{transcript_block}\n" + body[m.start() :]
    return body + f"\n{transcript_block}\n"


def add_transcript_to_page(config, page_slug, transcript_path, media_type, file_id, module):
    """Stage a transcript block onto a page. Returns True on success.

    Fills the first "[Transcript here]" placeholder if the page has one,
    otherwise inserts the block after the media embed identified by
    file_id.
    """
    if not os.path.exists(transcript_path):
        print(f"  ERR {module} {media_type}: transcript file not found: {transcript_path}")
        return False

    page = get_page(config, page_slug)
    if not page:
        print(f"  ERR {module} {media_type}: page fetch failed")
        return False

    body = page.get("body", "") or ""

    # Idempotent check
    if "Transcript (click to expand)" in body:
        print(f"  SKIP {module} {media_type}: transcript already exists")
        return True

    text = transcript_to_text(transcript_path)

    if PLACEHOLDER_RE.search(body):
        # The placeholder already lives inside a faculty <details> disclosure,
        # so insert only the scrollable body; wrapping it in another <details>
        # would nest one disclosure inside another.
        body, _ = replace_placeholders(body, [make_transcript_body(text)])
    else:
        body = _insert_after_embed(
            body, make_transcript_block(text, media_type), file_id, module, media_type)

    stage_result = staging_manager.stage(
        config["course_id"], "pages", page_slug, body, title=page.get("title"),
    )
    if stage_result.get("ok"):
        print(f"  OK {module} {media_type}: transcript staged for {page_slug}")
        return True
    else:
        print(f"  ERR {module} {media_type}: staging failed ({stage_result.get('error')})")
        return False


def _find_transcript_file(transcripts_dir, video_id):
    """Return the path to {video_id}.srt or {video_id}.vtt in transcripts_dir,
    or None if neither exists."""
    for ext in ("srt", "vtt"):
        candidate = os.path.join(transcripts_dir, f"{video_id}.{ext}")
        if os.path.exists(candidate):
            return candidate
    return None


def discover_page_mapping(config, transcripts_dir):
    """Walk every module page in the course and map each page with at
    least one "[Transcript here]" placeholder to the video ids embedded on
    it, in document order, taking the first N ids where N is the
    placeholder count.

    Returns (mapping, missing_ids):
        mapping: {page_slug: [video_id, ...]}
        missing_ids: sorted list of video ids (within mapping) with no
            transcript file present in transcripts_dir.
    """
    mapping = {}
    missing = set()
    seen_slugs = set()

    for module in get_modules(config):
        items = get_module_items(config, module["id"])
        for item in items:
            if item.get("type") != "Page" or not item.get("page_url"):
                continue
            slug = item["page_url"]
            if slug in seen_slugs:
                continue
            seen_slugs.add(slug)

            page = get_page(config, slug)
            if not page:
                continue
            body = page.get("body", "") or ""

            placeholder_count = len(PLACEHOLDER_RE.findall(body))
            if placeholder_count == 0:
                continue

            video_ids = IFRAME_ID_RE.findall(body)
            ids = video_ids[:placeholder_count]
            mapping[slug] = ids

            for vid in ids:
                if not _find_transcript_file(transcripts_dir, vid):
                    missing.add(vid)

    return mapping, sorted(missing)


def run_auto(config, transcripts_dir, dry_run=False):
    """Auto-discovery entry point. Returns a process exit code."""
    mapping, missing = discover_page_mapping(config, transcripts_dir)

    if dry_run:
        print(json.dumps(mapping, indent=2))
        if missing:
            print(
                f"\nMissing transcript files for video ids: {', '.join(missing)}",
                file=sys.stderr,
            )
        return 0

    success = 0
    errors = 0

    for slug, video_ids in mapping.items():
        paths = [_find_transcript_file(transcripts_dir, vid) for vid in video_ids]
        missing_here = [vid for vid, p in zip(video_ids, paths) if p is None]
        if missing_here:
            print(f"  ERR {slug}: missing transcript file(s) for {', '.join(missing_here)}")
            errors += 1
            continue

        page = get_page(config, slug)
        if not page:
            print(f"  ERR {slug}: page fetch failed")
            errors += 1
            continue

        body = page.get("body", "") or ""
        # Placeholders sit inside faculty <details> disclosures; insert only
        # the scrollable body so we do not nest disclosures.
        blocks = [make_transcript_body(transcript_to_text(p)) for p in paths]
        new_body, n = replace_placeholders(body, blocks)

        if n == 0:
            print(f"  SKIP {slug}: no placeholders replaced")
            continue

        stage_result = staging_manager.stage(
            config["course_id"], "pages", slug, new_body, title=page.get("title"),
        )
        if stage_result.get("ok"):
            print(f"  OK {slug}: {n} transcript(s) staged")
            success += 1
        else:
            print(f"  ERR {slug}: staging failed ({stage_result.get('error')})")
            errors += 1

    print(f"\n=== DONE === {success} pages staged, {errors} errors")
    return 1 if errors else 0


def main():
    parser = argparse.ArgumentParser(
        description="Add expandable text transcripts below media embeds on Canvas pages."
    )
    parser.add_argument(
        "config_json",
        nargs="?",
        help="Path to JSON config file with page to transcript-file mappings "
             "(required unless --auto is passed)",
    )
    parser.add_argument(
        "--auto",
        action="store_true",
        help="Auto-discover pages and embedded videos across all course modules "
             "instead of using a hand-written config file.",
    )
    parser.add_argument(
        "--course-id",
        dest="course_id",
        help="Canvas course ID. Falls back to CANVAS_COURSE_ID env var if omitted.",
    )
    parser.add_argument(
        "--transcripts-dir",
        dest="transcripts_dir",
        default=".",
        help="Directory containing {video_id}.srt or {video_id}.vtt transcript "
             "files (used with --auto).",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="With --auto, print the discovered page-to-video mapping as JSON "
             "and stage nothing.",
    )
    args = parser.parse_args()

    config = get_config(course_id=args.course_id)
    require_course_id(config)

    if args.auto:
        sys.exit(run_auto(config, args.transcripts_dir, args.dry_run))

    if not args.config_json:
        parser.error("config_json is required unless --auto is passed")

    with open(args.config_json, "r") as f:
        transcript_config = json.load(f)

    transcripts_dir = transcript_config.get("transcripts_dir", ".")
    success = 0
    errors = 0

    for entry in transcript_config.get("pages", []):
        transcript_path = os.path.join(transcripts_dir, entry["vtt"])
        result = add_transcript_to_page(
            config,
            entry["page_slug"],
            transcript_path,
            entry["media_type"],
            entry["file_id"],
            entry["module"],
        )
        if result:
            success += 1
        else:
            errors += 1

    print(f"\n=== DONE === {success} transcripts staged, {errors} errors")


if __name__ == "__main__":
    main()
