#!/usr/bin/env python3
"""
Download Crowdmark student assessments as PDFs through an existing Chrome CDP session.

Chrome must already be running with remote debugging enabled. The script reuses the
existing browser context, waits for manual login when needed, and saves PDFs with:
{YYYY}{X}_{CourseCode}_{CourseName} - {AssessmentTitle}.pdf
"""

from __future__ import annotations

import argparse
import asyncio
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from urllib.parse import urljoin, urlparse

from playwright.async_api import Page, TimeoutError as PlaywrightTimeoutError
from playwright.async_api import async_playwright

try:
    from tqdm import tqdm
except ImportError:
    tqdm = None


if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(line_buffering=True)
if hasattr(sys.stderr, "reconfigure"):
    sys.stderr.reconfigure(line_buffering=True)

ACTIVE_COURSES_URL = "https://app.crowdmark.com/student/courses"
ARCHIVED_COURSES_URL = "https://app.crowdmark.com/student/course-archive"
DEFAULT_OUTPUT_DIR = "crowdmark_assessment_pdfs"
INVALID_FILENAME_CHARS = re.compile(r"""[/\\:*?"<>|]""")
COURSE_CODE_RE = re.compile(r"\b([A-Z]{2,4}\d{3}(?:[A-Z]\d)?)(?:[FSY])?\b")
SPACED_COURSE_CODE_RE = re.compile(r"\b([A-Z]{2,4})\s+(\d{3})(?:\s*([A-Z]\d))?(?:[FSY])?\b")
PREFIX_RE = re.compile(r"^\((?P<year>\d{4})\s+(?P<semester>Winter|Fall|Summer)\)\s*(?P<rest>.*)$", re.I)
LEADING_YEAR_SEMESTER_RE = re.compile(r"^(?P<year>\d{4})\s+(?P<semester>Winter|Fall|Summer)\s+(?P<rest>.*)$", re.I)
SESSION_CODE_RE = re.compile(r"\b(?P<year>20\d{2})(?P<term>[159])\b")
YEAR_RE = re.compile(r"\b(20\d{2}|19\d{2})\b")


@dataclass(frozen=True)
class CourseInfo:
    raw_title: str
    year: str
    semester: str
    code: str
    name: str


@dataclass(frozen=True)
class LinkItem:
    title: str
    url: str


@dataclass(frozen=True)
class DownloadJob:
    course: CourseInfo
    assessment: LinkItem
    destination: Path


def sanitize_filename_part(value: str, fallback: str = "") -> str:
    value = INVALID_FILENAME_CHARS.sub("_", value)
    value = re.sub(r"\s+", " ", value).strip(" .")
    return value or fallback


def normalize_semester(value: str | None) -> str:
    if not value:
        return "UNKNOWN"
    value = value.strip().lower()
    if value.startswith("win"):
        return "Winter"
    if value.startswith("fall"):
        return "Fall"
    if value.startswith("sum"):
        return "Summer"
    if value == "f":
        return "Fall"
    if value == "s":
        return "Winter"
    if value == "y":
        return "FullYear"
    return "UNKNOWN"


def infer_course_code(texts: Iterable[str]) -> str:
    for text in texts:
        match = COURSE_CODE_RE.search(text.upper())
        if match:
            return match.group(1)
        spaced_match = SPACED_COURSE_CODE_RE.search(text.upper())
        if spaced_match:
            suffix = spaced_match.group(3) or ""
            return f"{spaced_match.group(1)}{spaced_match.group(2)}{suffix}"
    return "UNKNOWN"


def strip_course_noise(text: str, course_code: str) -> str:
    cleaned = text
    if course_code != "UNKNOWN":
        cleaned = re.sub(rf"\b{re.escape(course_code)}[FSY]?\b", " ", cleaned, flags=re.I)
    cleaned = re.sub(r"\b[A-Z]{2,4}\s+\d{3}(?:\s*[A-Z]\d)?[FSY]?\b", " ", cleaned, flags=re.I)
    cleaned = re.sub(r"\b(?:LEC|TUT|PRA)\d+\b", " ", cleaned, flags=re.I)
    cleaned = re.sub(r"\b\d{5}\b", " ", cleaned)
    cleaned = re.sub(r"\((?:All Sections|[^)]*Section[^)]*)\)", " ", cleaned, flags=re.I)
    cleaned = re.sub(r"\b[FSY]\b", " ", cleaned)
    cleaned = re.sub(r"\b(20\d{2}|19\d{2})\b\s*$", " ", cleaned)
    cleaned = re.sub(r"\s+", " ", cleaned).strip(" :-")
    return cleaned


def parse_course_title(raw_title: str, assessment_titles: Iterable[str] = ()) -> CourseInfo:
    title = re.sub(r"\s+", " ", raw_title).strip()
    year = "UNKNOWN"
    semester = "UNKNOWN"
    rest = title

    prefix = PREFIX_RE.match(title)
    if prefix:
        year = prefix.group("year")
        semester = normalize_semester(prefix.group("semester"))
        rest = prefix.group("rest").strip()
    else:
        leading = LEADING_YEAR_SEMESTER_RE.match(title)
        if leading:
            year = leading.group("year")
            semester = normalize_semester(leading.group("semester"))
            rest = leading.group("rest").strip()

        session = SESSION_CODE_RE.search(title)
        if session:
            if year == "UNKNOWN":
                year = session.group("year")
            if semester == "UNKNOWN":
                semester = {"1": "Winter", "5": "Summer", "9": "Fall"}[session.group("term")]

        year_match = YEAR_RE.search(title)
        if year == "UNKNOWN" and year_match:
            year = year_match.group(1)

    if ":" in rest:
        before_colon, after_colon = rest.split(":", 1)
        name_source = after_colon.strip()
        code_source = before_colon
    else:
        name_source = rest
        code_source = rest

    course_code = infer_course_code([code_source, title, *assessment_titles])

    if semester == "UNKNOWN":
        term_match = re.search(rf"\b{re.escape(course_code)}([FSY])\b", code_source, re.I) if course_code != "UNKNOWN" else None
        if term_match:
            semester = normalize_semester(term_match.group(1))

    course_name = strip_course_noise(name_source, course_code)
    if not course_name and ":" not in rest:
        course_name = strip_course_noise(rest, course_code)

    return CourseInfo(
        raw_title=title,
        year=sanitize_filename_part(year),
        semester=sanitize_filename_part(semester),
        code=sanitize_filename_part(course_code),
        name=sanitize_filename_part(course_name),
    )


def build_cdp_ws_url(devtools_file: Path | None) -> str:
    if devtools_file is None:
        devtools_file = default_devtools_path()
    if not devtools_file.exists():
        raise FileNotFoundError(
            f"Could not find {devtools_file}. Start Chrome with remote debugging enabled first."
        )
    lines = [line.strip() for line in devtools_file.read_text(encoding="utf-8").splitlines() if line.strip()]
    if len(lines) < 2:
        raise ValueError(f"{devtools_file} did not contain the expected port and WebSocket path.")
    return f"ws://127.0.0.1:{lines[0]}{lines[1]}"


def default_devtools_path() -> Path:
    home = Path.home()
    if sys.platform == "darwin":
        return home / "Library/Application Support/Google/Chrome/DevToolsActivePort"
    if sys.platform.startswith("win"):
        local_app_data = os.environ.get("LOCALAPPDATA")
        if local_app_data:
            return Path(local_app_data) / "Google/Chrome/User Data/DevToolsActivePort"
    return home / ".config/google-chrome/DevToolsActivePort"


def valid_url(value: str) -> bool:
    if not value:
        return False
    parsed = urlparse(value)
    return parsed.scheme in {"http", "https"} and parsed.netloc == "app.crowdmark.com"


async def wait_for_authentication(page: Page) -> None:
    await page.goto(ACTIVE_COURSES_URL, wait_until="domcontentloaded")
    if "/sign-in" not in page.url and "/login" not in page.url:
        await page.wait_for_load_state("networkidle", timeout=30_000)
        return

    print("Crowdmark sign-in is open in Chrome. Please log in there; I will continue automatically.")
    try:
        await page.wait_for_url(
            lambda url: "/sign-in" not in url and "/login" not in url and "app.crowdmark.com" in url,
            timeout=10 * 60 * 1000,
        )
        await page.wait_for_load_state("networkidle", timeout=60_000)
    except PlaywrightTimeoutError as exc:
        raise TimeoutError("Timed out waiting for Crowdmark login.") from exc


async def scrape_course_links(page: Page, courses_url: str) -> list[LinkItem]:
    await page.goto(courses_url, wait_until="domcontentloaded")
    await page.wait_for_load_state("networkidle", timeout=60_000)

    raw_links = await page.evaluate(
        """
        () => Array.from(document.querySelectorAll('main a'))
          .map((a) => {
            const h2 = a.querySelector('h2');
            const title = (h2 ? h2.innerText : a.innerText || '').trim();
            return { title, href: a.href || a.getAttribute('href') || '' };
          })
        """
    )

    seen: set[str] = set()
    links: list[LinkItem] = []
    for item in raw_links:
        title = re.sub(r"\s+", " ", item.get("title", "")).strip()
        href = item.get("href", "")
        url = urljoin(courses_url, href)
        path = urlparse(url).path.rstrip("/")
        if not title or not valid_url(url):
            continue
        if path in {"/student/courses", "/student/course-archive"}:
            continue
        if not path.startswith("/student/courses/"):
            continue
        if url in seen:
            continue
        seen.add(url)
        links.append(LinkItem(title=title, url=url))
    return links


async def scrape_assessment_links(page: Page, course_url: str) -> list[LinkItem]:
    await page.goto(course_url, wait_until="domcontentloaded")
    await page.wait_for_load_state("networkidle", timeout=60_000)
    raw_links = await page.evaluate(
        """
        () => Array.from(document.querySelectorAll('main a[href*="/assessments/"], a[href*="/assessments/"]'))
          .map((a) => ({ title: (a.innerText || a.textContent || '').trim(), href: a.href || a.getAttribute('href') || '' }))
        """
    )

    seen: set[str] = set()
    links: list[LinkItem] = []
    for item in raw_links:
        url = urljoin(course_url, item.get("href", ""))
        parsed = urlparse(url)
        title = re.sub(r"\s+", " ", item.get("title", "")).strip()
        if not title:
            title = parsed.path.rstrip("/").split("/")[-1] or "Assessment"
        if not valid_url(url) or "/assessments/" not in parsed.path:
            continue
        if url in seen:
            continue
        seen.add(url)
        links.append(LinkItem(title=title, url=url))
    return links


def assessment_matches_filters(title: str, filters: list[str]) -> bool:
    if not filters or filters == ["all"]:
        return True
    lowered = title.lower()
    return any(filter_value.lower() in lowered for filter_value in filters)


def course_matches_filters(info: CourseInfo, filters: list[str]) -> bool:
    if not filters or filters == ["all"]:
        return True
    haystack = f"{info.raw_title} {info.code} {info.name}".lower()
    return any(filter_value.lower() in haystack for filter_value in filters)


def pdf_filename(info: CourseInfo, assessment_title: str) -> str:
    year = blank_unknown(info.year)
    term = f"{year}{semester_abbreviation(info.semester)}"
    return (
        f"{term}_{blank_unknown(info.code)}_{blank_unknown(info.name)} - "
        f"{sanitize_filename_part(assessment_title, 'Assessment')}.pdf"
    )


def blank_unknown(value: str) -> str:
    return "" if value == "UNKNOWN" else value


def display_course_code(value: str) -> str:
    code = blank_unknown(value)
    return re.sub(r"H[15]$", "", code)


def semester_abbreviation(value: str) -> str:
    return {
        "Fall": "F",
        "Winter": "W",
        "Summer": "S",
        "FullYear": "Y",
    }.get(value, "")


async def save_assessment_pdf(page: Page, assessment: LinkItem, output_path: Path) -> None:
    await page.goto(assessment.url, wait_until="domcontentloaded")
    await page.wait_for_load_state("networkidle", timeout=60_000)
    await page.wait_for_timeout(3_000)
    await page.pdf(path=str(output_path), print_background=True, format="A4")


class SimpleProgress:
    def __init__(self, total: int, desc: str) -> None:
        self.total = total
        self.desc = desc
        self.count = 0

    def __enter__(self) -> "SimpleProgress":
        print(f"{self.desc}: 0/{self.total}")
        return self

    def __exit__(self, *_args: object) -> None:
        print(f"{self.desc}: {self.count}/{self.total}")

    def set_postfix_str(self, _value: str) -> None:
        return

    def update(self, amount: int = 1) -> None:
        self.count += amount
        print(f"{self.desc}: {self.count}/{self.total}")


def progress_bar(total: int, desc: str):
    if tqdm is None:
        return SimpleProgress(total, desc)
    return tqdm(total=total, desc=desc, unit="pdf", dynamic_ncols=True)


async def run(args: argparse.Namespace) -> int:
    output_dir = Path(args.output_dir).expanduser().resolve()
    output_dir.mkdir(parents=True, exist_ok=True)
    ws_url = args.ws_url or build_cdp_ws_url(Path(args.devtools_file).expanduser() if args.devtools_file else None)

    async with async_playwright() as playwright:
        browser = await playwright.chromium.connect_over_cdp(ws_url)
        context = browser.contexts[0] if browser.contexts else await browser.new_context()
        page = await context.new_page()
        try:
            await wait_for_authentication(page)
            if args.inspect_courses:
                await inspect_course_dom(page)
                return 0

            course_links: list[LinkItem] = []
            course_counts: dict[str, int] = {}
            for label, url in (("active", ACTIVE_COURSES_URL), ("archived", ARCHIVED_COURSES_URL)):
                try:
                    links = await scrape_course_links(page, url)
                    course_counts[label] = len(links)
                    course_links.extend(links)
                except Exception as exc:
                    course_counts[label] = 0
                    print(f"ERROR: Could not scrape {label} courses from {url}: {exc}", file=sys.stderr)

            print(f"Courses: {course_counts.get('active', 0)} active, {course_counts.get('archived', 0)} archived")
            seen_courses: set[str] = set()
            unique_courses = [course for course in course_links if not (course.url in seen_courses or seen_courses.add(course.url))]

            jobs: list[DownloadJob] = []
            total_skipped = 0
            for course in unique_courses:
                try:
                    assessments = await scrape_assessment_links(page, course.url)
                    info = parse_course_title(course.title, [course.url, *[item.title for item in assessments]])
                    if not course_matches_filters(info, args.courses):
                        continue

                    for assessment in assessments:
                        if not assessment_matches_filters(assessment.title, args.assessment_types):
                            continue
                        destination = output_dir / pdf_filename(info, assessment.title)
                        if destination.exists():
                            total_skipped += 1
                            continue
                        jobs.append(DownloadJob(info, assessment, destination))
                except Exception as exc:
                    print(f"ERROR: Failed to process course {course.url}: {exc}", file=sys.stderr)

            queued_course_codes = []
            seen_codes: set[str] = set()
            for job in jobs:
                code = display_course_code(job.course.code)
                if code and code not in seen_codes:
                    seen_codes.add(code)
                    queued_course_codes.append(code)

            print(f"Queued: {len(jobs)} PDF(s); skipped {total_skipped} existing PDF(s).")
            if queued_course_codes:
                print(f"Course codes: {', '.join(queued_course_codes)}")

            total_saved = 0
            with progress_bar(len(jobs), "Saving PDFs") as progress:
                for job in jobs:
                    progress.set_postfix_str(display_course_code(job.course.code))
                    try:
                        await save_assessment_pdf(page, job.assessment, job.destination)
                        total_saved += 1
                    except Exception as exc:
                        print(f"ERROR: Failed to save {job.assessment.url}: {exc}", file=sys.stderr)
                    finally:
                        progress.update(1)

            print(f"Done. Saved {total_saved} PDF(s); skipped {total_skipped} existing PDF(s).")
            print(f"Output directory: {output_dir}")
        finally:
            await page.close()
    return 0


async def inspect_course_dom(page: Page) -> None:
    for label, url in (("active", ACTIVE_COURSES_URL), ("archived", ARCHIVED_COURSES_URL)):
        await page.goto(url, wait_until="domcontentloaded")
        await page.wait_for_load_state("networkidle", timeout=60_000)
        rows = await page.evaluate(
            """
            () => Array.from(document.querySelectorAll('main a')).slice(0, 12).map((a) => ({
              href: a.href || a.getAttribute('href') || '',
              text: (a.innerText || a.textContent || '').trim(),
              parent: a.parentElement ? (a.parentElement.innerText || '').trim() : '',
              grandparent: a.parentElement && a.parentElement.parentElement ? (a.parentElement.parentElement.innerText || '').trim() : '',
              outerHTML: a.outerHTML.slice(0, 800)
            }))
            """
        )
        print(f"\n[{label}] {url}")
        for index, row in enumerate(rows, start=1):
            print(f"\n--- link {index} ---")
            print(f"href: {row['href']}")
            print(f"text: {row['text']}")
            print(f"parent: {row['parent']}")
            print(f"grandparent: {row['grandparent']}")
            print(f"outerHTML: {row['outerHTML']}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Download Crowdmark assessments as PDFs.")
    parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR, help="Directory for downloaded PDFs.")
    parser.add_argument("--devtools-file", help="Path to Chrome's DevToolsActivePort file.")
    parser.add_argument("--ws-url", help="Explicit ws:// CDP browser WebSocket URL.")
    parser.add_argument("--courses", nargs="*", default=["all"], help="Course filters; default: all.")
    parser.add_argument("--assessment-types", nargs="*", default=["all"], help="Assessment title filters; default: all.")
    parser.add_argument("--inspect-courses", action="store_true", help="Print sample course link DOM and exit.")
    return parser.parse_args()


def main() -> int:
    return asyncio.run(run(parse_args()))


if __name__ == "__main__":
    raise SystemExit(main())
