#!/usr/bin/env python3
"""Canvas plugin setup validation helper.

Used by the /course-concierge skill's inline setup to test API connectivity,
validate configuration, list courses, verify course IDs,
sync the course registry, and migrate from single-course to multi-course.

Usage:
    python setup_env.py --test              Test API token connectivity
    python setup_env.py --validate          Check .env completeness
    python setup_env.py --list-courses      List user's courses
    python setup_env.py --verify-course ID  Verify a specific course ID
    python setup_env.py --sync-registry     Sync course registry from Canvas API
    python setup_env.py --migrate           Migrate from single-course .env to multi-course registry
"""

import argparse
import getpass
import json
import os
import shutil
import sys
from datetime import datetime, timezone

import requests

# Load .env from plugin root (backward-compatible)
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
try:
    from dotenv import load_dotenv
    load_dotenv(os.path.join(PLUGIN_ROOT, '.env'))
except ImportError:
    pass  # Fall back to environment variables

# canvas_api is a sibling module in the scripts/ directory.
import canvas_api

COURSES_DIR = os.path.join(PLUGIN_ROOT, 'courses')
REGISTRY_PATH = os.path.join(COURSES_DIR, 'registry.json')

# Canvas hosts the plugin is permitted to send the access token to.
# Prod is always canvas.asu.edu or asu.instructure.com; dev is always
# asu-dev.instructure.com. A mistyped or hostile hostname must never receive
# the Bearer token. Adding a host is a code change (reviewed), on purpose — a
# runtime override env var would reopen the exact hole this closes.
ALLOWED_DOMAINS = frozenset({
    "canvas.asu.edu",
    "asu.instructure.com",
    "asu-dev.instructure.com",
})


def normalize_domain(raw):
    """Normalize a Canvas domain and pin it to the ASU allowlist.

    Strips scheme, path, and trailing slash, lowercases, then confirms the host
    is one the plugin may send the Canvas token to. Raises ValueError for
    anything off-allowlist so the Bearer token is never sent to a typo'd or
    hostile host. Mirrors canvas_api._validate_canvas_response_url, which pins
    pagination next-links the same way.
    """
    host = (raw or "").strip().lower()
    if "://" in host:                 # user pasted a full URL
        host = host.split("://", 1)[1]
    host = host.split("/", 1)[0].strip()  # drop any path / trailing slash
    if host not in ALLOWED_DOMAINS:
        raise ValueError(
            f"Refusing to send Canvas token to '{raw}': not an allowed ASU "
            f"Canvas host ({', '.join(sorted(ALLOWED_DOMAINS))})"
        )
    return host


def get_env():
    """Load Canvas credentials from environment."""
    return {
        "token": os.environ.get("CANVAS_TOKEN"),
        "domain": os.environ.get("CANVAS_DOMAIN", "canvas.asu.edu"),
        "course_id": os.environ.get("CANVAS_COURSE_ID"),
    }


def _get_headers(env):
    """Build authorization headers."""
    return {"Authorization": f"Bearer {env['token']}"}


def _fetch_all_courses(env):
    """Fetch all active courses from Canvas API with pagination.

    The configured domain is pinned to the ASU allowlist before the token is
    sent (normalize_domain), so a mistyped/hostile CANVAS_DOMAIN reaches
    list_courses/sync_registry/migrate as an error string, never a token send.
    Pagination then routes through canvas_api.paginated_get so every
    Link: rel="next" URL is domain-validated too.
    """
    params = {
        "per_page": 100,
        "enrollment_state": "active",
        "include[]": "term",
    }
    headers = _get_headers(env)

    try:
        domain = normalize_domain(env["domain"])
        url = f"https://{domain}/api/v1/courses"
        courses = canvas_api.paginated_get(
            url, headers, params=params, canvas_domain=domain
        )
        return courses, None
    except (ValueError, requests.RequestException) as e:
        return None, str(e)


def test_connection():
    """Test API token by fetching authenticated user info."""
    env = get_env()
    if not env["token"]:
        print(json.dumps({"ok": False, "error": "CANVAS_TOKEN not set"}))
        return False
    try:
        env["domain"] = normalize_domain(env["domain"])
    except ValueError as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False

    try:
        resp = requests.get(
            f"https://{env['domain']}/api/v1/users/self",
            headers=_get_headers(env),
            timeout=15,
        )
        if resp.status_code == 200:
            user = resp.json()
            print(json.dumps({
                "ok": True,
                "user_name": user.get("name", "Unknown"),
                "user_id": user.get("id"),
                "domain": env["domain"],
            }))
            return True
        else:
            print(json.dumps({
                "ok": False,
                "error": f"HTTP {resp.status_code}",
                "detail": canvas_api._sanitize_error_text(resp.text),
            }))
            return False
    except requests.exceptions.ConnectionError:
        print(json.dumps({"ok": False, "error": f"Cannot connect to {env['domain']}"}))
        return False
    except requests.exceptions.Timeout:
        print(json.dumps({"ok": False, "error": "Request timed out"}))
        return False


def _write_env_lines(lines, env_path):
    """Atomically write full .env contents with 0600 perms.

    Shared by _write_env_vars (merge) and migrate() (delete-key rewrite) so
    every full rewrite of a file that may contain CANVAS_TOKEN goes through
    the same permission-safe path. A plain open(path, 'w') only sets mode at
    *creation* time (under umask) — for a pre-existing file it leaves whatever
    mode the file already had, so a rewrite via bare open() can silently leave
    a token-bearing .env group/world-readable. Creating with 0600 up front
    avoids the file ever being briefly world-readable under a permissive
    umask; the trailing chmod also normalizes a pre-existing file's perms.
    """
    fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        f.writelines(lines)
    os.chmod(env_path, 0o600)
    return env_path


def _write_env_vars(updates, env_path=None):
    """Merge key=value pairs into .env, preserving unrelated lines. Writes 0600.

    The first line for a key in `updates` is replaced in place; any later
    duplicate lines for that key are dropped, so a malformed .env with repeated
    keys collapses to one (avoids a stale dup shadowing the new value under
    dotenv's last-wins load). Keys not already present are appended. Lines for
    keys not in `updates` (comments, a user's own extra vars) are left untouched.
    """
    if env_path is None:
        env_path = os.path.join(PLUGIN_ROOT, ".env")
    lines = []
    if os.path.exists(env_path):
        with open(env_path) as f:
            lines = f.readlines()
    out = []
    written = set()
    for line in lines:
        key = line.split("=", 1)[0].strip()
        if key in updates:
            if key not in written:
                out.append(f"{key}={updates[key]}\n")
                written.add(key)
            # drop later duplicate lines for a key we're setting
        else:
            out.append(line)
    if out and not out[-1].endswith("\n"):
        out[-1] += "\n"
    for key, val in updates.items():
        if key not in written:
            out.append(f"{key}={val}\n")
    return _write_env_lines(out, env_path)


def init_env():
    """Interactive first-run: prompt for domain + token, validate, write .env.

    The token is read with getpass, so it never echoes to the terminal, lands
    in shell history, or passes through the Claude chat transcript. Validates
    against /users/self BEFORE writing so a bad token is never persisted.
    """
    default_domain = os.environ.get("CANVAS_DOMAIN", "canvas.asu.edu")
    try:
        domain = input(f"Canvas domain [{default_domain}]: ").strip() or default_domain
        token = getpass.getpass("Canvas access token (input hidden): ").strip()
    except (EOFError, KeyboardInterrupt):
        print(json.dumps({"ok": False, "error": "Cancelled"}))
        return False

    if not token:
        print(json.dumps({"ok": False, "error": "No token entered"}))
        return False

    try:
        domain = normalize_domain(domain)
    except ValueError as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False

    try:
        resp = requests.get(
            f"https://{domain}/api/v1/users/self",
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
    except requests.RequestException as e:
        print(json.dumps({"ok": False, "error": f"Could not reach {domain}: {e}"}))
        return False

    if resp.status_code != 200:
        print(json.dumps({
            "ok": False,
            "error": f"Token rejected (HTTP {resp.status_code}) — not saved",
        }))
        return False

    try:
        user = resp.json()
    except ValueError:
        print(json.dumps({
            "ok": False,
            "error": f"Unexpected non-JSON response from {domain} (HTTP 200) — not saved",
        }))
        return False
    env_path = _write_env_vars({"CANVAS_TOKEN": token, "CANVAS_DOMAIN": domain})
    print(json.dumps({
        "ok": True,
        "message": f"Connected as {user.get('name', 'Unknown')} — credentials saved to .env",
        "domain": domain,
        "env_path": env_path,
    }))
    return True


SUPPORTED_CONFIG_VERSIONS = ["1.0", "1.1"]


def validate_env():
    """Check that .env has required credentials and optionally course-config.json."""
    env = get_env()
    missing = []
    if not env["token"]:
        missing.append("CANVAS_TOKEN")
    if not env["domain"]:
        missing.append("CANVAS_DOMAIN")

    # Check for registry
    registry_status = "found" if os.path.exists(REGISTRY_PATH) else "not_found"

    # Legacy: check if CANVAS_COURSE_ID is still in .env
    legacy_course_id = env.get("course_id")

    # Check for course-config.json in current directory
    config_status = "not_found"
    config_version = None
    config_path = os.path.join(os.getcwd(), "course-config.json")
    if os.path.exists(config_path):
        try:
            with open(config_path) as f:
                config_data = json.load(f)
            config_version = config_data.get("config_version", "1.0")
            if config_version in SUPPORTED_CONFIG_VERSIONS:
                config_status = "valid"
            else:
                config_status = "unsupported_version"
        except (json.JSONDecodeError, OSError):
            config_status = "invalid_json"

    if missing:
        print(json.dumps({
            "ok": False,
            "missing": missing,
            "message": f"Missing: {', '.join(missing)}",
            "registry": registry_status,
            "course_config": config_status,
            "config_version": config_version,
        }))
        return False
    else:
        result = {
            "ok": True,
            "message": "All required credentials set",
            "domain": env["domain"],
            "registry": registry_status,
            "course_config": config_status,
            "config_version": config_version,
        }
        if legacy_course_id:
            result["legacy_course_id"] = legacy_course_id
            result["migration_needed"] = True
        print(json.dumps(result))
        return True


def list_courses():
    """List user's active courses."""
    env = get_env()
    if not env["token"]:
        print(json.dumps({"ok": False, "error": "CANVAS_TOKEN not set"}))
        return False

    try:
        raw_courses, err = _fetch_all_courses(env)
        if err:
            print(json.dumps({"ok": False, "error": err}))
            return False

        # Format course list
        course_list = []
        for c in raw_courses:
            if isinstance(c, dict) and c.get("name"):
                course_list.append({
                    "id": c["id"],
                    "name": c["name"],
                    "code": c.get("course_code", ""),
                    "term": c.get("term", {}).get("name", "") if isinstance(c.get("term"), dict) else "",
                })

        print(json.dumps({"ok": True, "count": len(course_list), "courses": course_list}))
        return True
    except requests.exceptions.RequestException as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False


def verify_course(course_id):
    """Verify a course ID exists and user has access."""
    env = get_env()
    if not env["token"]:
        print(json.dumps({"ok": False, "error": "CANVAS_TOKEN not set"}))
        return False
    try:
        env["domain"] = normalize_domain(env["domain"])
    except ValueError as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False

    try:
        resp = requests.get(
            f"https://{env['domain']}/api/v1/courses/{course_id}",
            headers=_get_headers(env),
            timeout=15,
        )
        if resp.status_code == 200:
            course = resp.json()
            print(json.dumps({
                "ok": True,
                "course_id": course["id"],
                "name": course.get("name", "Unknown"),
                "code": course.get("course_code", ""),
            }))
            return True
        elif resp.status_code == 404:
            print(json.dumps({"ok": False, "error": f"Course {course_id} not found"}))
            return False
        elif resp.status_code == 401:
            print(json.dumps({"ok": False, "error": "Not authorized — check your API token"}))
            return False
        else:
            print(json.dumps({
                "ok": False,
                "error": f"HTTP {resp.status_code}",
                "detail": canvas_api._sanitize_error_text(resp.text),
            }))
            return False
    except requests.exceptions.RequestException as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False


def sync_registry():
    """Sync the course registry with Canvas API.

    Fetches all active courses, merges with existing registry
    (preserving lastAccessed, hasLocalData), and archives removed courses.
    """
    env = get_env()
    if not env["token"]:
        print(json.dumps({"ok": False, "error": "CANVAS_TOKEN not set"}))
        return False

    try:
        raw_courses, err = _fetch_all_courses(env)
        if err:
            print(json.dumps({"ok": False, "error": err}))
            return False

        # Load existing registry if present
        existing = {"courses": [], "archived": []}
        if os.path.exists(REGISTRY_PATH):
            try:
                with open(REGISTRY_PATH) as f:
                    existing = json.load(f)
            except (json.JSONDecodeError, OSError):
                pass

        # Build lookup of existing courses by ID
        existing_by_id = {c["id"]: c for c in existing.get("courses", [])}
        archived_by_id = {c["id"]: c for c in existing.get("archived", [])}

        # Process active courses from Canvas
        active_ids = set()
        new_courses = []
        for c in raw_courses:
            if not isinstance(c, dict) or not c.get("name"):
                continue

            cid = c["id"]
            active_ids.add(cid)

            # Check if course directory exists
            course_dir = os.path.join(COURSES_DIR, str(cid))
            has_local = os.path.isdir(course_dir)

            # Determine enrollment role
            enrollments = c.get("enrollments", [])
            role = "unknown"
            for e in enrollments:
                if isinstance(e, dict):
                    role = e.get("type", "unknown")
                    break

            if cid in existing_by_id:
                # Preserve local data from existing entry
                entry = existing_by_id[cid]
                entry["name"] = c["name"]
                entry["code"] = c.get("course_code", "")
                entry["term"] = c.get("term", {}).get("name", "") if isinstance(c.get("term"), dict) else ""
                entry["enrollmentRole"] = role
                entry["hasLocalData"] = has_local
                new_courses.append(entry)
            elif cid in archived_by_id:
                # Course came back — unarchive it
                entry = archived_by_id[cid]
                entry["name"] = c["name"]
                entry["code"] = c.get("course_code", "")
                entry["term"] = c.get("term", {}).get("name", "") if isinstance(c.get("term"), dict) else ""
                entry["enrollmentRole"] = role
                entry["hasLocalData"] = has_local
                new_courses.append(entry)
            else:
                # Brand new course
                new_courses.append({
                    "id": cid,
                    "name": c["name"],
                    "code": c.get("course_code", ""),
                    "term": c.get("term", {}).get("name", "") if isinstance(c.get("term"), dict) else "",
                    "enrollmentRole": role,
                    "lastAccessed": None,
                    "hasLocalData": has_local,
                })

        # Archive courses that are no longer active
        newly_archived = []
        for old_course in existing.get("courses", []):
            if old_course["id"] not in active_ids:
                newly_archived.append(old_course)

        # Merge archived lists (keep existing archived + newly archived)
        all_archived = [a for a in existing.get("archived", []) if a["id"] not in active_ids]
        all_archived.extend(newly_archived)

        # Build registry
        registry = {
            "lastSync": datetime.now(timezone.utc).isoformat(),
            "courses": sorted(new_courses, key=lambda c: c["name"]),
            "archived": all_archived,
        }

        # Write registry
        os.makedirs(COURSES_DIR, exist_ok=True)
        with open(REGISTRY_PATH, 'w') as f:
            json.dump(registry, f, indent=2)

        added = len(active_ids - set(existing_by_id.keys()) - set(archived_by_id.keys()))
        removed = len(newly_archived)

        print(json.dumps({
            "ok": True,
            "total": len(new_courses),
            "added": added,
            "archived": removed,
            "registry_path": REGISTRY_PATH,
        }))
        return True
    except requests.exceptions.RequestException as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False


def migrate():
    """Migrate from single-course .env to multi-course registry.

    1. If .env has CANVAS_COURSE_ID, creates registry with that course
    2. Creates courses/{id}/ directory
    3. Moves ./course-config.json to courses/{id}/course-config.json if present
    4. Removes CANVAS_COURSE_ID from .env
    """
    env = get_env()
    if not env["token"]:
        print(json.dumps({"ok": False, "error": "CANVAS_TOKEN not set"}))
        return False

    course_id = env.get("course_id")
    if not course_id:
        print(json.dumps({
            "ok": True,
            "message": "No CANVAS_COURSE_ID found — nothing to migrate",
            "already_migrated": True,
        }))
        return True

    # Pin the domain before any token send (bulk fetch + the single-course URL
    # below both use env["domain"]).
    try:
        env["domain"] = normalize_domain(env["domain"])
    except ValueError as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        return False

    actions = []

    # Step 1: Sync registry (will include the current course)
    raw_courses, err = _fetch_all_courses(env)
    if err:
        print(json.dumps({"ok": False, "error": f"Could not fetch courses: {err}"}))
        return False

    # Build registry from API data
    courses = []
    for c in raw_courses:
        if not isinstance(c, dict) or not c.get("name"):
            continue
        enrollments = c.get("enrollments", [])
        role = "unknown"
        for e in enrollments:
            if isinstance(e, dict):
                role = e.get("type", "unknown")
                break
        courses.append({
            "id": c["id"],
            "name": c["name"],
            "code": c.get("course_code", ""),
            "term": c.get("term", {}).get("name", "") if isinstance(c.get("term"), dict) else "",
            "enrollmentRole": role,
            "lastAccessed": None,
            "hasLocalData": False,
        })

    # Step 2: Create course directory
    course_dir = os.path.join(COURSES_DIR, str(course_id))
    os.makedirs(course_dir, exist_ok=True)
    os.makedirs(os.path.join(course_dir, 'reports'), exist_ok=True)
    actions.append(f"Created courses/{course_id}/")

    # Mark the current course as having local data
    migrated_course_matched = False
    now_iso = datetime.now(timezone.utc).isoformat()
    for c in courses:
        if str(c["id"]) == str(course_id):
            c["hasLocalData"] = True
            c["lastAccessed"] = now_iso
            migrated_course_matched = True

    # Handle the case where CANVAS_COURSE_ID points to a non-active course
    # (concluded, archived, or unenrolled) — it won't appear in the active-only
    # _fetch_all_courses result, leaving the created courses/{course_id}/ directory
    # orphaned (no registry entry references it).  Ensure the invariant:
    #   courses/{course_id}/ exists  ⟹  registry contains an entry for course_id.
    if not migrated_course_matched:
        headers = _get_headers(env)
        single_url = f"https://{env['domain']}/api/v1/courses/{course_id}"
        fetched_entry = None
        try:
            resp = canvas_api._request_with_retry(
                requests.get, single_url, headers
            )
            if resp.status_code == 200:
                c = resp.json()
                enrollments = c.get("enrollments", [])
                role = "unknown"
                for e in enrollments:
                    if isinstance(e, dict):
                        role = e.get("type", "unknown")
                        break
                fetched_entry = {
                    "id": c.get("id", int(course_id)),
                    "name": c.get("name", f"Course {course_id}"),
                    "code": c.get("course_code", ""),
                    "term": (
                        c.get("term", {}).get("name", "")
                        if isinstance(c.get("term"), dict)
                        else ""
                    ),
                    "enrollmentRole": role,
                    "lastAccessed": now_iso,
                    "hasLocalData": True,
                }
        except Exception:
            pass  # network error or unexpected — fall through to stub

        if fetched_entry is None:
            # Course is truly inaccessible; add a stub so the dir is referenced
            fetched_entry = {
                "id": int(course_id) if str(course_id).isdigit() else course_id,
                "name": f"Course {course_id}",
                "code": "",
                "term": "",
                "enrollmentRole": "unknown",
                "lastAccessed": now_iso,
                "hasLocalData": True,
            }

        courses.append(fetched_entry)
        actions.append(
            f"Added registry entry for non-active migrated course {course_id}"
        )

    # Step 3: Move course-config.json if it exists in working directory
    config_src = os.path.join(os.getcwd(), "course-config.json")
    config_dst = os.path.join(course_dir, "course-config.json")
    if os.path.exists(config_src) and not os.path.exists(config_dst):
        shutil.copy2(config_src, config_dst)
        actions.append(f"Copied course-config.json → courses/{course_id}/course-config.json")

    # Step 4: Write registry
    os.makedirs(COURSES_DIR, exist_ok=True)
    registry = {
        "lastSync": datetime.now(timezone.utc).isoformat(),
        "courses": sorted(courses, key=lambda c: c["name"]),
        "archived": [],
    }
    with open(REGISTRY_PATH, 'w') as f:
        json.dump(registry, f, indent=2)
    actions.append(f"Created registry with {len(courses)} courses")

    # Step 5: Remove CANVAS_COURSE_ID from .env
    env_path = os.path.join(PLUGIN_ROOT, '.env')
    if os.path.exists(env_path):
        with open(env_path) as f:
            lines = f.readlines()
        new_lines = [l for l in lines if not l.strip().startswith("CANVAS_COURSE_ID")]
        # Route through the same 0600-enforcing path as _write_env_vars — this
        # file holds CANVAS_TOKEN and must never be left group/world-readable.
        _write_env_lines(new_lines, env_path)
        actions.append("Removed CANVAS_COURSE_ID from .env")

    print(json.dumps({
        "ok": True,
        "message": "Migration complete",
        "course_id": course_id,
        "total_courses": len(courses),
        "actions": actions,
    }))
    return True


def main():
    parser = argparse.ArgumentParser(description="Canvas plugin setup helper")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--init", action="store_true", help="Interactive first-run: prompt for token + domain, validate, write .env")
    group.add_argument("--test", action="store_true", help="Test API connectivity")
    group.add_argument("--validate", action="store_true", help="Validate .env completeness")
    group.add_argument("--list-courses", action="store_true", help="List user's courses")
    group.add_argument("--verify-course", metavar="ID", help="Verify a course ID")
    group.add_argument("--sync-registry", action="store_true", help="Sync course registry from Canvas API")
    group.add_argument("--migrate", action="store_true", help="Migrate from single-course to multi-course")

    args = parser.parse_args()

    if args.init:
        success = init_env()
    elif args.test:
        success = test_connection()
    elif args.validate:
        success = validate_env()
    elif args.list_courses:
        success = list_courses()
    elif args.verify_course:
        success = verify_course(args.verify_course)
    elif args.sync_registry:
        success = sync_registry()
    elif args.migrate:
        success = migrate()
    else:
        parser.print_help()
        success = False

    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()
