#!/usr/bin/env python3
"""ASUCB Backup Manager — per-course, object-aware backups.

Creates timestamped backups of Canvas content before writes,
enabling rollback to any previous version.

Storage layout:
    courses/{courseId}/backups/pages/{timestamp}_{slug}.html
    courses/{courseId}/backups/assignments/{timestamp}_{assignment_id}.html
    courses/{courseId}/backups/discussions/{timestamp}_{topic_id}.html
    courses/{courseId}/backups/quizzes/{timestamp}_{quiz_id}.json
"""

import argparse
import hashlib
import json
import logging
import os
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, os.path.dirname(__file__))
import canvas_api
from canvas_api import get_plugin_root
from staging_server import COURSE_ID_RE, OBJ_ID_RE
try:
    import timeline_logger
except Exception:
    timeline_logger = None


EXTENSIONS = {
    "pages": ".html",
    "assignments": ".html",
    "discussions": ".html",
    "quizzes": ".json",
    "interactives": ".html",
}

VALID_TYPES = list(EXTENSIONS.keys())


def _invalid_path():
    raise ValueError("Invalid path component")


def _validate_course_id(course_id):
    course_id = str(course_id or "")
    if not COURSE_ID_RE.fullmatch(course_id) or course_id in {".", ".."}:
        _invalid_path()
    return course_id


def _validate_object_type(object_type):
    object_type = str(object_type or "")
    if not OBJ_ID_RE.fullmatch(object_type) or object_type not in EXTENSIONS:
        _invalid_path()
    return object_type


def _object_id_components(object_type, object_id):
    raw = str(object_id or "")
    if object_type == "interactives":
        raw = raw.removesuffix(EXTENSIONS[object_type])
        parts = raw.split("/")
        if not parts:
            _invalid_path()
        for part in parts:
            if not OBJ_ID_RE.fullmatch(part) or part in {".", ".."}:
                _invalid_path()
        return parts
    if not OBJ_ID_RE.fullmatch(raw) or raw in {".", ".."}:
        _invalid_path()
    return [raw]


def _resolve_inside(root, *parts):
    try:
        root_path = Path(root).resolve(strict=False)
        candidate = (root_path / Path(*parts)).resolve(strict=False)
    except (OSError, RuntimeError):
        _invalid_path()
    if not candidate.is_relative_to(root_path):
        _invalid_path()
    return candidate


def _emit_rollback_completed_event(course_id, object_type, object_id, timestamp):
    """Best-effort rollback event emission that never interrupts restore."""
    if timeline_logger is None:
        logging.warning("timeline_logger unavailable; skipping rollback_completed for course %s",
                        course_id)
        return
    timeline_logger.emit_event(course_id, "rollback_completed", {
        "object_type": object_type,
        "object_id": str(object_id),
        "restored_from": timestamp,
    })


def _backup_dir(course_id, object_type):
    """Return the backup directory for a course/type, creating it if needed."""
    course_id = _validate_course_id(course_id)
    object_type = _validate_object_type(object_type)
    courses_root = Path(get_plugin_root()) / "courses"
    path = _resolve_inside(courses_root, course_id, "backups", object_type)
    os.makedirs(path, exist_ok=True)
    return str(path)


def _timestamp():
    """Return an ISO 8601 UTC timestamp for filenames (no colons)."""
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def _checksum(content):
    """SHA256 hex digest of content."""
    if isinstance(content, str):
        content = content.encode("utf-8")
    return hashlib.sha256(content).hexdigest()


def _atomic_write(filepath, content_str):
    """Write content_str to filepath atomically via temp file + os.replace.

    A plain truncating open("w") + write() can leave a half-written file on
    disk if the process crashes or the disk fills up mid-write, silently
    corrupting the backup. Writing to a temp file in the same directory
    first, then calling os.replace() (atomic on POSIX), means a reader only
    ever sees the previous file (or nothing) or the fully-written new one —
    never a partial one. Cleans up the temp file on any failure.
    """
    filepath = Path(filepath)
    fd, tmp_path = tempfile.mkstemp(
        dir=str(filepath.parent), prefix=f".{filepath.name}.", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content_str)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_path, filepath)
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def save_backup(course_id, object_type, object_id, content, diff_summary=""):
    """Save a backup of current Canvas content before overwriting.

    Args:
        course_id: Canvas course ID
        object_type: 'pages', 'assignments', 'discussions', 'quizzes'
        object_id: Slug (pages) or numeric ID (others)
        content: Current HTML string or dict (quizzes)
        diff_summary: Optional human-readable description of the change

    Returns:
        dict with ok, path, timestamp, checksum — or {"ok": False, "error": ...}
        when course_id / object_type / object_id fail path-component validation.
    """
    try:
        object_type = _validate_object_type(object_type)
        components = _object_id_components(object_type, object_id)
    except ValueError as e:
        return {"ok": False, "error": str(e)}

    ts = _timestamp()
    ext = EXTENSIONS[object_type]
    filename = f"{ts}_{components[-1]}{ext}"
    dirpath = _backup_dir(course_id, object_type)
    filepath = _resolve_inside(dirpath, *components[:-1], filename)
    filepath.parent.mkdir(parents=True, exist_ok=True)

    # Write content
    if object_type == "quizzes" and isinstance(content, (dict, list)):
        content_str = json.dumps(content, indent=2, ensure_ascii=False)
    else:
        content_str = content if isinstance(content, str) else str(content)

    _atomic_write(filepath, content_str)

    chk = _checksum(content_str)

    # Write sidecar metadata
    meta_path = str(filepath) + ".meta.json"
    meta = {
        "course_id": str(course_id),
        "object_type": object_type,
        "object_id": str(object_id),
        "timestamp": ts,
        "checksum": chk,
        "size_bytes": len(content_str.encode("utf-8")),
        "diff_summary": diff_summary,
    }
    _atomic_write(meta_path, json.dumps(meta, indent=2))

    return {
        "ok": True,
        "path": str(filepath),
        "timestamp": ts,
        "checksum": chk,
    }


def list_backups(course_id, object_type=None, object_id=None):
    """List backups for a course, optionally filtered by type and/or object.

    Returns list of dicts sorted newest-first:
        [{timestamp, object_type, object_id, path, size_bytes, diff_summary, checksum}]
    """
    results = []
    types_to_check = [object_type] if object_type else VALID_TYPES

    for otype in types_to_check:
        dirpath = _backup_dir(course_id, otype)
        if not os.path.isdir(dirpath):
            continue

        ext = EXTENSIONS[otype]
        for fname in os.listdir(dirpath):
            if not fname.endswith(ext) or fname.endswith(".meta.json"):
                continue

            # Parse: {timestamp}_{object_id}{ext}
            stem = fname[: -len(ext)]
            parts = stem.split("_", 1)
            if len(parts) != 2:
                continue
            ts, obj_id = parts

            if object_id and obj_id != str(object_id):
                continue

            fpath = os.path.join(dirpath, fname)

            # Read sidecar metadata if available
            meta_path = fpath + ".meta.json"
            meta = {}
            if os.path.exists(meta_path):
                try:
                    with open(meta_path, "r", encoding="utf-8") as f:
                        meta = json.load(f)
                except (json.JSONDecodeError, OSError):
                    pass

            results.append({
                "timestamp": ts,
                "object_type": otype,
                "object_id": obj_id,
                "path": fpath,
                "size_bytes": meta.get("size_bytes", os.path.getsize(fpath)),
                "diff_summary": meta.get("diff_summary", ""),
                "checksum": meta.get("checksum", ""),
            })

    # Sort newest first
    results.sort(key=lambda x: x["timestamp"], reverse=True)
    return results


def find_backup(course_id, object_type, object_id, timestamp=None):
    """Find a specific backup, or the most recent one if no timestamp given.

    Returns dict with backup metadata + path, or None.
    """
    backups = list_backups(course_id, object_type, object_id)
    if not backups:
        return None

    if timestamp:
        for b in backups:
            if b["timestamp"] == timestamp:
                return b
        return None

    return backups[0]  # Most recent


def get_backup_content(course_id, object_type, object_id, timestamp):
    """Read the content of a specific backup.

    Recomputes the SHA256 checksum of the on-disk backup and compares it to
    the checksum recorded in the .meta.json sidecar at save time. On a
    mismatch, the backup was corrupted or truncated after it was written, so
    this raises instead of silently handing back bad content. Legacy backups
    saved before checksums were recorded (no "checksum" in their sidecar)
    skip verification so existing backups on disk keep working.

    Returns content string or parsed dict (quizzes), or None if no backup
    is found.

    Raises:
        ValueError: if the backup's on-disk content fails checksum
            verification.
    """
    backup = find_backup(course_id, object_type, object_id, timestamp)
    if not backup:
        return None

    with open(backup["path"], "r", encoding="utf-8") as f:
        raw = f.read()

    expected_checksum = backup.get("checksum")
    if expected_checksum and _checksum(raw) != expected_checksum:
        raise ValueError(
            f"Backup checksum mismatch for {object_type}/{object_id} "
            f"@ {backup['timestamp']}: on-disk content does not match the "
            "checksum recorded at backup time. The backup file may be "
            "corrupted."
        )

    if object_type == "quizzes":
        return json.loads(raw)
    return raw


def restore_backup(config, course_id, object_type, object_id, timestamp=None):
    """Restore a backup to Canvas and emit rollback_completed on success."""
    if object_type not in VALID_TYPES:
        return {"ok": False, "error": f"Invalid type: {object_type}"}

    backup = find_backup(course_id, object_type, object_id, timestamp)
    if not backup:
        return {"ok": False, "error": "Backup not found"}

    try:
        content = get_backup_content(course_id, object_type, object_id,
                                     backup["timestamp"])
    except ValueError as e:
        return {"ok": False, "error": str(e)}
    if content is None:
        return {"ok": False, "error": "Could not read backup content"}

    if object_type == "pages":
        success = canvas_api.update_page(config, object_id, content)
    elif object_type == "assignments":
        success = canvas_api.update_assignment(config, object_id,
                                               description=content)
    elif object_type == "discussions":
        success = canvas_api.update_discussion(config, object_id,
                                               message=content)
    else:
        success = _restore_quiz(config, object_id, content)

    if not success:
        return {"ok": False, "error": "Canvas restore failed"}

    _emit_rollback_completed_event(course_id, object_type, object_id,
                                   backup["timestamp"])
    return {
        "ok": True,
        "course_id": str(course_id),
        "object_type": object_type,
        "object_id": str(object_id),
        "restored_from": backup["timestamp"],
    }


def _restore_quiz(config, quiz_id, content):
    """Best-effort full quiz restore from a backup payload."""
    if not isinstance(content, dict):
        return False

    quiz_fields = content.get("quiz", {})
    if quiz_fields and not canvas_api.update_quiz(config, quiz_id, **quiz_fields):
        return False

    if "questions" not in content:
        return True

    questions = content.get("questions", [])
    if questions is None:
        # A backup can have an explicit "questions": null (e.g. saved while
        # the pre-push question fetch had transiently failed). There is no
        # question data to restore, so leave existing questions untouched
        # instead of crashing by iterating None below.
        return True
    existing_questions = canvas_api.get_quiz_questions(config, quiz_id) or []
    existing_by_id = {q.get("id"): q for q in existing_questions if q.get("id")}
    restored_ids = set()

    for question in questions:
        question_id = question.get("id")
        fields = {k: v for k, v in question.items() if k != "id"}
        if question_id and question_id in existing_by_id:
            if not canvas_api.update_quiz_question(
                    config, quiz_id, question_id, **fields):
                return False
            restored_ids.add(question_id)
        else:
            created = canvas_api.add_quiz_question(
                config, quiz_id,
                question_name=question.get("question_name", "Question"),
                question_text=question.get("question_text", ""),
                question_type=question.get("question_type",
                                           "multiple_choice_question"),
                points_possible=question.get("points_possible", 1),
                answers=question.get("answers"),
                position=question.get("position"),
            )
            if not created:
                return False
            if created.get("id"):
                restored_ids.add(created["id"])

    for existing_id in existing_by_id:
        if existing_id not in restored_ids:
            if not canvas_api.delete_quiz_question(config, quiz_id, existing_id):
                return False

    return True


# ============================================================
# CLI
# ============================================================
def main():
    parser = argparse.ArgumentParser(description="ASUCB Backup Manager")
    parser.add_argument("--course-id", required=True, help="Canvas course ID")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--save", action="store_true", help="Save a backup")
    group.add_argument("--list", action="store_true", help="List backups")
    group.add_argument("--get", action="store_true", help="Get backup content")
    group.add_argument("--find", action="store_true", help="Find latest backup")
    group.add_argument("--restore", action="store_true", help="Restore a backup")

    parser.add_argument("--type", choices=VALID_TYPES, help="Object type")
    parser.add_argument("--id", help="Object ID")
    parser.add_argument("--timestamp", help="Backup timestamp")
    parser.add_argument("--html-file", help="File to back up")
    parser.add_argument("--diff-summary", default="", help="Change description")

    args = parser.parse_args()

    if args.save:
        if not args.type or not args.id:
            parser.error("--save requires --type and --id")
        if args.html_file:
            with open(args.html_file, "r", encoding="utf-8") as f:
                content = f.read()
        else:
            content = sys.stdin.read()
        result = save_backup(args.course_id, args.type, args.id,
                             content, args.diff_summary)
        print(json.dumps(result, indent=2))

    elif args.list:
        backups = list_backups(args.course_id, args.type, args.id)
        print(json.dumps({"ok": True, "backups": backups}, indent=2))

    elif args.find:
        if not args.type or not args.id:
            parser.error("--find requires --type and --id")
        backup = find_backup(args.course_id, args.type, args.id, args.timestamp)
        if backup:
            print(json.dumps({"ok": True, **backup}, indent=2))
        else:
            print(json.dumps({"ok": False, "error": "No backup found"}))
            sys.exit(1)

    elif args.get:
        if not args.type or not args.id or not args.timestamp:
            parser.error("--get requires --type, --id, and --timestamp")
        try:
            content = get_backup_content(args.course_id, args.type,
                                         args.id, args.timestamp)
        except ValueError as e:
            print(json.dumps({"ok": False, "error": str(e)}))
            sys.exit(1)
        if content is None:
            print(json.dumps({"ok": False, "error": "Backup not found"}))
            sys.exit(1)
        if isinstance(content, dict):
            print(json.dumps(content, indent=2))
        else:
            print(content)

    elif args.restore:
        if not args.type or not args.id:
            parser.error("--restore requires --type and --id")
        config = canvas_api.get_config(course_id=args.course_id)
        canvas_api.require_course_id(config)
        result = restore_backup(config, args.course_id, args.type,
                                args.id, args.timestamp)
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)


if __name__ == "__main__":
    main()
