#!/usr/bin/env python3
"""ASUCB Course Content Cache — local text dump for offline search.

Downloads all Canvas pages as HTML + plain text to enable offline search
and Q&A without repeated API calls.

Storage: courses/{courseId}/content_cache/pages/{slug}.html|.txt
         courses/{courseId}/content_cache/files/{safe_name}.txt
         courses/{courseId}/content_cache/metadata.json
"""

import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone

sys.path.insert(0, os.path.dirname(__file__))
import canvas_api
import url_safety


def _cache_dir(course_id, subdir=None):
    """Return content cache directory, creating if needed."""
    root = canvas_api.get_plugin_root()
    parts = [root, "courses", str(course_id), "content_cache"]
    if subdir:
        parts.append(subdir)
    path = os.path.join(*parts)
    os.makedirs(path, exist_ok=True)
    return path


def _metadata_path(course_id):
    return os.path.join(_cache_dir(course_id), "metadata.json")


def _load_metadata(course_id):
    path = _metadata_path(course_id)
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    return {"course_id": str(course_id), "last_dump": None, "pages": {}, "files": {}}


def _save_metadata(course_id, meta):
    with open(_metadata_path(course_id), "w", encoding="utf-8") as f:
        json.dump(meta, f, indent=2, ensure_ascii=False)


def _strip_html(html):
    """Convert HTML to plain text (naive regex strip)."""
    if not html:
        return ""
    text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL)
    text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"&nbsp;", " ", text)
    text = re.sub(r"&amp;", "&", text)
    text = re.sub(r"&lt;", "<", text)
    text = re.sub(r"&gt;", ">", text)
    text = re.sub(r"&#\d+;", "", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _safe_filename(name):
    """Convert a name to a safe filename."""
    return re.sub(r"[^\w\-.]", "_", name)


def dump_pages(config, since=None):
    """Download all pages (or updated since timestamp) and save to cache.

    Returns count of pages dumped.
    """
    course_id = config["course_id"]
    pages_dir = _cache_dir(course_id, "pages")
    meta = _load_metadata(course_id)
    count = 0

    # Fetch all pages with bodies in a single paginated call (include[]=body).
    # Verified live on canvas.asu.edu: the index body is byte-identical to the
    # per-page GET body, eliminating an N+1 request pattern.
    all_pages = canvas_api.get_all_pages(config, include_body=True)
    for page_info in all_pages:
        slug = page_info.get("url", "")
        updated = page_info.get("updated_at", "")

        # Skip if not updated since last dump
        if since and updated and updated <= since:
            continue

        if not slug:
            continue

        body = page_info.get("body", "") or ""
        title = page_info.get("title", slug)

        # Sanitize the slug before using it as a write-path component.
        # Canvas normally slugifies page URLs, but treat it as untrusted
        # input (defense-in-depth, consistent with dump_files below).
        safe_slug = _safe_filename(slug)

        # Save HTML
        html_path = os.path.join(pages_dir, f"{safe_slug}.html")
        with open(html_path, "w", encoding="utf-8") as f:
            f.write(body)

        # Save plain text
        txt_path = os.path.join(pages_dir, f"{safe_slug}.txt")
        plain = _strip_html(body)
        with open(txt_path, "w", encoding="utf-8") as f:
            f.write(f"# {title}\n\n{plain}")

        meta["pages"][slug] = {
            "title": title,
            "updated_at": updated,
            "cached_at": datetime.now(timezone.utc).isoformat(),
            "size": len(body),
        }
        count += 1
        print(f"  Cached: {slug} ({len(body)} chars)")

    meta["last_dump"] = datetime.now(timezone.utc).isoformat()
    _save_metadata(course_id, meta)
    return count


def dump_files(config):
    """Download course files and extract text where possible.

    Returns count of files processed.
    """
    course_id = config["course_id"]
    files_dir = _cache_dir(course_id, "files")
    meta = _load_metadata(course_id)
    count = 0

    # Get all course files
    files = canvas_api.paginated_get(
        f"{config['course_url']}/files?per_page=100",
        config["headers"],
    )

    for finfo in files:
        name = finfo.get("display_name", finfo.get("filename", ""))
        url = finfo.get("url", "")
        size = finfo.get("size", 0)

        if not url or not name:
            continue

        safe = _safe_filename(name)
        ext = os.path.splitext(name)[1].lower()

        # Only process text-extractable types
        if ext not in (".txt", ".md", ".html", ".htm", ".pdf", ".docx", ".pptx"):
            continue

        try:
            import requests
            if not url_safety.is_safe_fetch_url(url):
                print(f"  Skipped {name} (unsafe URL)")
                continue
            resp = requests.get(url, timeout=30, stream=True)
            if resp.status_code != 200:
                continue

            # Stream into a capped buffer instead of resp.content, which
            # would buffer the whole body regardless of size.
            content = bytearray()
            for chunk in resp.iter_content(chunk_size=8192):
                content.extend(chunk)
                if len(content) > url_safety.MAX_FETCH_BYTES:
                    break
            if len(content) > url_safety.MAX_FETCH_BYTES:
                print(f"  Skipped {name} (exceeds size cap)")
                continue
            content = bytes(content)

            text = ""
            if ext in (".txt", ".md"):
                text = content.decode(resp.encoding or "utf-8", errors="replace")
            elif ext in (".html", ".htm"):
                text = _strip_html(content.decode(resp.encoding or "utf-8", errors="replace"))
            elif ext == ".pdf":
                try:
                    import fitz  # pymupdf
                    doc = fitz.open(stream=content, filetype="pdf")
                    text = "\n\n".join(page.get_text() for page in doc)
                except ImportError:
                    print(f"  Skipped PDF {name} (pymupdf not installed)")
                    continue
            elif ext == ".docx":
                try:
                    import docx
                    import io
                    doc = docx.Document(io.BytesIO(content))
                    text = "\n".join(p.text for p in doc.paragraphs)
                except ImportError:
                    print(f"  Skipped DOCX {name} (python-docx not installed)")
                    continue
            elif ext == ".pptx":
                try:
                    import pptx
                    import io
                    prs = pptx.Presentation(io.BytesIO(content))
                    slides = []
                    for slide in prs.slides:
                        for shape in slide.shapes:
                            if shape.has_text_frame:
                                slides.append(shape.text_frame.text)
                    text = "\n\n".join(slides)
                except ImportError:
                    print(f"  Skipped PPTX {name} (python-pptx not installed)")
                    continue

            if text.strip():
                out_path = os.path.join(files_dir, f"{safe}.txt")
                with open(out_path, "w", encoding="utf-8") as f:
                    f.write(f"# {name}\n\n{text}")
                meta["files"][safe] = {
                    "original_name": name,
                    "cached_at": datetime.now(timezone.utc).isoformat(),
                    "size": len(text),
                }
                count += 1
                print(f"  Cached file: {name} ({len(text)} chars)")

        except Exception as e:
            print(f"  Error processing {name}: {e}")

    _save_metadata(course_id, meta)
    return count


def get_status(course_id):
    """Return cache metadata and counts."""
    meta = _load_metadata(course_id)
    return {
        "course_id": str(course_id),
        "last_dump": meta.get("last_dump"),
        "page_count": len(meta.get("pages", {})),
        "file_count": len(meta.get("files", {})),
        "cache_dir": _cache_dir(course_id),
    }


# ============================================================
# CLI
# ============================================================
def main():
    parser = argparse.ArgumentParser(description="ASUCB Course Content Cache")
    parser.add_argument("command", choices=["dump", "refresh", "status"])
    parser.add_argument("--course-id", help="Course ID override")
    parser.add_argument("--include-files", action="store_true",
                        help="Also download and extract course files")

    args = parser.parse_args()
    config = canvas_api.get_config(course_id=args.course_id)
    canvas_api.require_course_id(config)
    course_id = config["course_id"]

    if args.command == "status":
        status = get_status(course_id)
        print(json.dumps(status, indent=2))

    elif args.command == "dump":
        print(f"Dumping all pages for course {course_id}...")
        count = dump_pages(config)
        print(f"Cached {count} pages.")
        if args.include_files:
            print("Extracting course files...")
            fcount = dump_files(config)
            print(f"Cached {fcount} files.")

    elif args.command == "refresh":
        meta = _load_metadata(course_id)
        since = meta.get("last_dump")
        if since:
            print(f"Refreshing pages updated since {since}...")
        else:
            print("No previous dump — doing full dump...")
        count = dump_pages(config, since=since)
        print(f"Refreshed {count} pages.")


if __name__ == "__main__":
    main()
