#!/usr/bin/env python3
"""ASUCB Backlog Manager — persist audit findings in course-memory.md."""

from __future__ import annotations

import argparse
import builtins
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4

sys.path.insert(0, os.path.dirname(__file__))
try:
    from canvas_api import get_plugin_root, validate_course_id
except Exception:
    def get_plugin_root():
        return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

    def validate_course_id(course_id):
        cid = str(course_id).strip()
        if not re.match(r"^\d+$", cid):
            raise ValueError(f"Invalid course_id (must be numeric): {course_id!r}")
        return cid


PENDING_HEADING = "## Pending"
CAN_DO_HEADING = "### Can do now"
BLOCKED_HEADING = "### Blocked — need instructor input"
DEFAULT_FIX_PROMPT = "Tell Claude: Review this finding and stage the smallest safe fix."
B_FIX_PATH_TYPES = {
    "empty_rubric",
    "mlo_rewrite",
    "clo_rewrite",
    "title_alignment",
    "file_a11y",
}
FIX_PATH_PARAM_KEYS = (
    "course_id",
    "module",
    "rubric_name",
    "assignment_name",
    "clo_text",
    "mlo_text",
    "page_title",
    "page_id",
    "file_name",
    "issue",
)
FIX_PATH_TEMPLATES = {
    "empty_rubric": (
        "Tell Claude: /rubric-creator for course {course_id}. Populate rubric "
        "'{rubric_name}' for assignment '{assignment_name}', then stage the "
        "rubric for ID preview."
    ),
    "mlo_rewrite": (
        "Tell Claude: /update-module for course {course_id}, module '{module}'. "
        "Rewrite the module learning objectives using this MLO context: "
        "{mlo_text}. Stage the smallest safe update for preview."
    ),
    "clo_rewrite": (
        "Tell Claude: /update-module for course {course_id}, module '{module}'. "
        "Rewrite the course learning objective using this CLO context: "
        "{clo_text}. Stage the smallest safe update for preview."
    ),
    "title_alignment": (
        "Tell Claude: page-edit via /update-module for course {course_id}. "
        "Align the page title '{page_title}' (page_id: {page_id}) with the "
        "approved course/module naming pattern, then stage for preview."
    ),
    "file_a11y": (
        "Tell Claude: Run file-remediation assess/remediate on file "
        "'{file_name}' for course {course_id} — {issue}. Stage the smallest "
        "safe fix for preview before push."
    ),
}

FINDING_RE = re.compile(
    r"^\s*-\s+\[(?P<severity>[^\]]+)\]\s+\[(?P<finding_id>[^\]]+)\]\s+"
    r"(?P<text>.*?)(?:\s+\|\s+fix(?:_path)?:\s*(?P<fix>.*))?\s*$",
    re.IGNORECASE,
)
META_RE = re.compile(r"^\s*<!--\s*asucb-finding:\s*(?P<meta>\{.*\})\s*-->\s*$")


class _FixPathParams(dict):
    def __missing__(self, key):
        return ""


def _now_iso():
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def _memory_path(course_id):
    return Path(get_plugin_root()) / "courses" / str(course_id) / "course-memory.md"


def _backlog_path(course_id):
    return Path(get_plugin_root()) / "courses" / str(course_id) / "backlog.json"


def _read_text(path):
    try:
        return path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return ""


def _write_text(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def _read_backlog(path):
    try:
        raw = path.read_text(encoding="utf-8").strip()
    except FileNotFoundError:
        return []
    if not raw:
        return []
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        items = []
        for line in raw.splitlines():
            line = line.strip()
            if not line:
                continue
            item = json.loads(line)
            if isinstance(item, dict):
                items.append(item)
        return items
    if isinstance(data, builtins.list):
        return [item for item in data if isinstance(item, dict)]
    raise ValueError(f"Backlog must be a JSON array or JSONL file: {path}")


def _write_backlog(path, items):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(items, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def _normalize_finding_type(value):
    finding_type = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
    aliases = {
        "missing_rubric": "empty_rubric",
        "empty_rubric_shell": "empty_rubric",
        "empty_shell_rubric": "empty_rubric",
    }
    return aliases.get(finding_type, finding_type)


def _routing_type(finding):
    for key in ("finding_type", "fix_type", "issue_type", "type"):
        finding_type = _normalize_finding_type(finding.get(key))
        if finding_type in B_FIX_PATH_TYPES:
            return finding_type
    nested = finding.get("finding")
    if isinstance(nested, dict):
        for key in ("finding_type", "fix_type", "issue_type", "type"):
            finding_type = _normalize_finding_type(nested.get(key))
            if finding_type in B_FIX_PATH_TYPES:
                return finding_type
    return ""


def _first_value(*values):
    for value in values:
        if value not in (None, ""):
            return str(value)
    return ""


def _module_from_mlo_id(value):
    match = re.match(r"^\s*M(\d+)(?:\.|\b)", str(value or ""), re.IGNORECASE)
    if not match:
        return ""
    return f"Module {match.group(1)}"


def _fix_path_params(finding, course_id=None):
    nested = finding.get("finding") if isinstance(finding.get("finding"), dict) else {}
    params = {
        "course_id": _first_value(course_id, finding.get("course_id"), nested.get("course_id")),
        "module": _first_value(
            finding.get("module"),
            finding.get("module_name"),
            nested.get("module"),
            nested.get("module_name"),
            _module_from_mlo_id(finding.get("mlo_id") or nested.get("mlo_id")),
            finding.get("module_id"),
            nested.get("module_id"),
        ),
        "rubric_name": _first_value(
            finding.get("rubric_name"),
            nested.get("rubric_name"),
            finding.get("assignment_name"),
            nested.get("assignment_name"),
        ),
        "assignment_name": _first_value(
            finding.get("assignment_name"),
            nested.get("assignment_name"),
            finding.get("name"),
            nested.get("name"),
        ),
        "clo_text": _first_value(finding.get("clo_text"), nested.get("clo_text")),
        "mlo_text": _first_value(finding.get("mlo_text"), nested.get("mlo_text")),
        "page_title": _first_value(
            finding.get("page_title"),
            nested.get("page_title"),
            finding.get("title"),
            nested.get("title"),
        ),
        "page_id": _first_value(
            finding.get("page_id"),
            nested.get("page_id"),
            finding.get("object_id"),
            nested.get("object_id"),
        ),
        "file_name": _first_value(
            finding.get("file_name"),
            nested.get("file_name"),
            finding.get("name"),
            nested.get("name"),
        ),
        "issue": _first_value(
            finding.get("issue"),
            nested.get("issue"),
            finding.get("detail"),
            nested.get("detail"),
        ),
    }
    return _FixPathParams({
        key: _clean_one_line(params.get(key, ""))
        for key in FIX_PATH_PARAM_KEYS
    })


def build_fix_path(finding, course_id=None):
    """Return a parameterized B-routing fix_path prompt, if finding is allowlisted."""
    if not isinstance(finding, dict):
        return ""
    finding_type = _routing_type(finding)
    template = FIX_PATH_TEMPLATES.get(finding_type)
    if not template:
        return ""
    return template.format_map(_fix_path_params(finding, course_id=course_id))


def _with_fix_path(finding, course_id=None):
    record = dict(finding)
    if record.get("fix_path") or record.get("fix"):
        return record
    fix_path = build_fix_path(record, course_id=course_id)
    if fix_path:
        record["fix_path"] = fix_path
    return record


def add(course_id, item):
    """Append an item to courses/{course_id}/backlog.json with id/timestamp."""
    if not isinstance(item, dict):
        raise ValueError("item must be a dict")
    path = _backlog_path(course_id)
    items = _read_backlog(path)
    timestamp = _now_iso()
    record = _with_fix_path(item, course_id=course_id)
    record["id"] = record.get("id") or f"backlog-{uuid4().hex[:12]}"
    record["created_at"] = record.get("created_at") or timestamp
    record.setdefault("resolved", False)
    items.append(record)
    _write_backlog(path, items)
    return record


def list(course_id):  # noqa: A001 - operation name is part of the public API.
    """Return all JSON backlog items for a course."""
    return _read_backlog(_backlog_path(course_id))


def resolve(course_id, item_id):
    """Mark a JSON backlog item resolved by id and return the updated item."""
    item_id = str(item_id)
    path = _backlog_path(course_id)
    items = _read_backlog(path)
    resolved_item = None
    for item in items:
        if str(item.get("id")) != item_id:
            continue
        item["resolved"] = True
        item["resolved_at"] = item.get("resolved_at") or _now_iso()
        resolved_item = item
        break
    if resolved_item is None:
        raise KeyError(f"Backlog item not found: {item_id}")
    _write_backlog(path, items)
    return resolved_item


def clear_resolved(course_id):
    """Remove resolved JSON backlog items and return removal metadata."""
    path = _backlog_path(course_id)
    items = _read_backlog(path)
    kept = [
        item for item in items
        if not (item.get("resolved") or item.get("resolved_at"))
    ]
    removed = len(items) - len(kept)
    _write_backlog(path, kept)
    return {"ok": True, "path": str(path), "removed": removed, "remaining": len(kept)}


def resolve_file_a11y(course_id, file_id=None, file_name=None):
    """Mark unresolved file_a11y JSON backlog findings for a file as resolved.

    Used by push_remediated_file.py after a verified Canvas file replacement.
    Matches primarily by file_id: remediate_assess.py's _emit_backlog stores
    the raw Canvas file id verbatim in the "file_id" field, while its
    "file_name" field is written as "{canvas_file_id}-{display_name}" (the
    local content-cache filename), not the bare display_name — so an exact
    file_name match would almost never hit. file_name is still accepted as a
    fallback (exact match or "{id}-{name}" suffix match) for findings
    recorded without a file_id.

    Returns the list of resolved item ids (empty if nothing matched).
    """
    path = _backlog_path(course_id)
    items = _read_backlog(path)
    resolved_ids = []
    now = _now_iso()
    for item in items:
        if item.get("resolved"):
            continue
        if _normalize_finding_type(item.get("finding_type")) != "file_a11y":
            continue
        item_file_id = item.get("file_id")
        item_file_name = str(item.get("file_name") or "")
        matched = (
            file_id is not None and item_file_id is not None
            and str(item_file_id) == str(file_id)
        ) or (
            file_name and (
                item_file_name == file_name
                or item_file_name.endswith(f"-{file_name}")
            )
        )
        if not matched:
            continue
        item["resolved"] = True
        item["resolved_at"] = item.get("resolved_at") or now
        resolved_ids.append(item.get("id"))
    if resolved_ids:
        _write_backlog(path, items)
    return resolved_ids


def _pending_bounds(lines):
    start = None
    for idx, line in enumerate(lines):
        if re.match(r"^##\s+Pending\b", line, re.IGNORECASE):
            start = idx
            break
    if start is None:
        return None, None
    end = len(lines)
    for idx in range(start + 1, len(lines)):
        if re.match(r"^##\s+", lines[idx]):
            end = idx
            break
    return start, end


def _ensure_pending_structure(text):
    lines = text.splitlines()
    if not lines:
        lines = [PENDING_HEADING, "", CAN_DO_HEADING, "", BLOCKED_HEADING]

    start, end = _pending_bounds(lines)
    if start is None:
        if lines and lines[-1].strip():
            lines.append("")
        lines.extend(["", PENDING_HEADING, "", CAN_DO_HEADING, "", BLOCKED_HEADING])
        start, end = _pending_bounds(lines)

    def has_heading(pattern):
        return any(re.match(pattern, line, re.IGNORECASE) for line in lines[start:end])

    if not has_heading(r"^###\s+Can do now\b"):
        lines[start + 1:start + 1] = ["", CAN_DO_HEADING]
        start, end = _pending_bounds(lines)

    if not has_heading(r"^###\s+Blocked\b"):
        lines[end:end] = ["", BLOCKED_HEADING]

    return lines


def _subsection_bounds(lines, status):
    heading_re = r"^###\s+Blocked\b" if status == "blocked" else r"^###\s+Can do now\b"
    pending_start, pending_end = _pending_bounds(lines)
    if pending_start is None:
        return None, None
    start = None
    for idx in range(pending_start + 1, pending_end):
        if re.match(heading_re, lines[idx], re.IGNORECASE):
            start = idx
            break
    if start is None:
        return None, None
    end = pending_end
    for idx in range(start + 1, pending_end):
        if re.match(r"^#{2,3}\s+", lines[idx]):
            end = idx
            break
    return start, end


def _normalize_status(value):
    status = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
    if status in ("blocked", "need_instructor_input", "needs_instructor_input"):
        return "blocked"
    return "can_do_now"


def _finding_status(finding):
    if finding.get("blocked") or finding.get("needs_instructor_input"):
        return "blocked"
    return _normalize_status(finding.get("status") or finding.get("pending_status"))


def _normalize_fix_prompt(prompt):
    prompt = str(prompt or "").strip() or DEFAULT_FIX_PROMPT
    if prompt.lower().startswith("tell claude"):
        return prompt
    return f"Tell Claude: {prompt}"


def _clean_one_line(value):
    return re.sub(r"\s+", " ", str(value or "").strip())


def _format_finding_line(finding):
    severity = _clean_one_line(finding.get("severity") or "INFO").upper()
    finding_id = _clean_one_line(finding.get("finding_id") or finding.get("id"))
    text = _clean_one_line(
        finding.get("finding_text")
        or finding.get("text")
        or finding.get("description")
        or ""
    )
    fix = _normalize_fix_prompt(finding.get("fix") or finding.get("fix_path"))
    if not finding_id:
        raise ValueError("finding_id is required")
    if not text:
        raise ValueError("finding_text is required")
    return f"- [{severity}] [{finding_id}] {text} | fix: {fix}"


def _metadata_for_finding(finding):
    metadata = {}
    for key in (
        "content_type",
        "object_type",
        "object_id",
        "object_ref",
        "source",
        "detector",
        "status",
    ):
        value = finding.get(key)
        if value not in (None, ""):
            metadata[key] = str(value)
    if not metadata:
        return None
    return "<!-- asucb-finding: " + json.dumps(metadata, sort_keys=True) + " -->"


def _parse_metadata(line):
    match = META_RE.match(line)
    if not match:
        return None
    try:
        data = json.loads(match.group("meta"))
    except json.JSONDecodeError:
        return None
    return data if isinstance(data, dict) else None


def _pending_lines(text):
    lines = text.splitlines()
    start, end = _pending_bounds(lines)
    if start is None:
        return []
    return lines[start:end]


def list_findings(course_id, status=None):
    """Return parsed Pending finding entries for a course.

    ``status`` filters by Pending subsection: ``can_do_now`` or ``blocked``.
    """
    wanted_status = _normalize_status(status) if status else None
    findings = []
    current_status = "can_do_now"
    pending_meta = None
    for line_num, line in enumerate(_pending_lines(_read_text(_memory_path(course_id))), start=1):
        if re.match(r"^###\s+Blocked\b", line, re.IGNORECASE):
            current_status = "blocked"
            pending_meta = None
            continue
        if re.match(r"^###\s+Can do now\b", line, re.IGNORECASE):
            current_status = "can_do_now"
            pending_meta = None
            continue
        meta = _parse_metadata(line)
        if meta is not None:
            pending_meta = meta
            continue
        match = FINDING_RE.match(line)
        if not match:
            pending_meta = None
            continue
        record = {
            "severity": match.group("severity").strip().upper(),
            "finding_id": match.group("finding_id").strip(),
            "finding_text": match.group("text").strip(),
            "fix": (match.group("fix") or "").strip(),
            "fix_path": (match.group("fix") or "").strip(),
            "status": current_status,
            "line": line_num,
        }
        if pending_meta:
            record.update(pending_meta)
        pending_meta = None
        if wanted_status and record["status"] != wanted_status:
            continue
        findings.append(record)
    return findings


def append_finding(course_id, finding):
    """Append a finding to the Pending section without duplicating finding_id."""
    if not isinstance(finding, dict):
        raise ValueError("finding must be a dict")
    finding = _with_fix_path(finding, course_id=course_id)
    finding_id = _clean_one_line(finding.get("finding_id") or finding.get("id"))
    if not finding_id:
        raise ValueError("finding_id is required")

    path = _memory_path(course_id)
    existing = list_findings(course_id)
    if any(item.get("finding_id") == finding_id for item in existing):
        return {"ok": True, "path": str(path), "finding_id": finding_id, "appended": False}

    lines = _ensure_pending_structure(_read_text(path))
    status = _finding_status(finding)
    start, end = _subsection_bounds(lines, status)
    if start is None:
        lines = _ensure_pending_structure("\n".join(lines))
        start, end = _subsection_bounds(lines, status)
    insert_at = end
    while insert_at > start + 1 and lines[insert_at - 1].strip() == "":
        insert_at -= 1

    block = []
    metadata = _metadata_for_finding(finding)
    if metadata:
        block.append(metadata)
    block.append(_format_finding_line(finding))
    if insert_at < len(lines) and lines[insert_at].strip():
        block.append("")
    lines[insert_at:insert_at] = block

    _write_text(path, "\n".join(lines).rstrip() + "\n")
    return {"ok": True, "path": str(path), "finding_id": finding_id, "appended": True}


def close_finding(course_id, finding_id):
    """Remove a finding entry from Pending by finding_id."""
    finding_id = str(finding_id).strip()
    path = _memory_path(course_id)
    lines = _read_text(path).splitlines()
    removed = False
    output = []
    for idx, line in enumerate(lines):
        match = FINDING_RE.match(line)
        if match and match.group("finding_id").strip() == finding_id:
            if output and META_RE.match(output[-1]):
                output.pop()
            removed = True
            continue
        output.append(line)
    if removed:
        _write_text(path, "\n".join(output).rstrip() + "\n")
    return {"ok": True, "path": str(path), "finding_id": finding_id, "removed": removed}


def _normalize_content_type(value):
    value = str(value or "").strip().lower().replace("_", "-")
    aliases = {
        "pages": "page",
        "wiki-page": "page",
        "assignments": "assignment",
        "discussions": "discussion",
        "discussion-topic": "discussion",
        "discussion-topics": "discussion",
        "quizzes": "quiz",
    }
    return aliases.get(value, value)


def _matches_object(finding, content_type=None, object_id=None):
    if object_id is None:
        return False
    wanted_id = str(object_id)
    wanted_type = _normalize_content_type(content_type)
    found_type = _normalize_content_type(
        finding.get("content_type") or finding.get("object_type")
    )
    found_id = str(finding.get("object_id") or "")
    if found_type and wanted_type and found_type != wanted_type:
        return False
    if found_id:
        return found_id == wanted_id
    haystack = " ".join(
        str(finding.get(k, ""))
        for k in ("finding_id", "finding_text", "object_ref")
    )
    return wanted_id in haystack


def _defect_present(finding, verify_result=None, content_type=None, object_id=None):
    """Default detection check.

    Without a verification context, keep findings. With a successful push
    verification and a matching object key, treat the defect as resolved.
    Tests and future detection scripts can monkeypatch or pass detection_check
    for richer re-checks.
    """
    if verify_result and verify_result.get("ok") and _matches_object(
        finding, content_type=content_type, object_id=object_id
    ):
        return False
    return True


def cleanup_resolved(course_id, verify_result=None, content_type=None,
                     object_id=None, detection_check=None):
    """Remove Pending finding entries whose defect is no longer present."""
    detector = detection_check or _defect_present
    removed = []
    kept = []
    for finding in list_findings(course_id):
        if (content_type or object_id) and not _matches_object(
            finding, content_type=content_type, object_id=object_id
        ):
            kept.append(finding["finding_id"])
            continue
        try:
            present = detector(
                finding,
                verify_result=verify_result,
                content_type=content_type,
                object_id=object_id,
            )
        except TypeError:
            present = detector(finding)
        except Exception as exc:
            logging.warning(
                "Failed to re-check backlog finding %s for course %s: %s",
                finding.get("finding_id"), course_id, exc,
            )
            present = True
        if present:
            kept.append(finding["finding_id"])
            continue
        close_finding(course_id, finding["finding_id"])
        removed.append(finding["finding_id"])
    return {
        "ok": True,
        "path": str(_memory_path(course_id)),
        "removed": removed,
        "kept": kept,
        "removed_count": len(removed),
    }


def status_summary(course_id):
    findings = list_findings(course_id)
    by_severity = {}
    by_status = {"can_do_now": 0, "blocked": 0}
    for finding in findings:
        by_severity[finding["severity"]] = by_severity.get(finding["severity"], 0) + 1
        by_status[finding["status"]] = by_status.get(finding["status"], 0) + 1
    return {
        "ok": True,
        "course_id": str(course_id),
        "total": len(findings),
        "by_status": by_status,
        "by_severity": by_severity,
    }


def main():
    parser = argparse.ArgumentParser(description="ASUCB Backlog Manager")
    sub = parser.add_subparsers(dest="command", required=True)

    list_p = sub.add_parser("list", help="List Pending backlog findings")
    list_p.add_argument("--course-id", required=True)
    list_p.add_argument("--status", choices=["can_do_now", "blocked"])
    list_p.add_argument("--json", action="store_true")

    cleanup_p = sub.add_parser("cleanup", help="Remove resolved findings")
    cleanup_p.add_argument("--course-id", required=True)
    cleanup_p.add_argument("--content-type")
    cleanup_p.add_argument("--object-id")
    cleanup_p.add_argument("--json", action="store_true")

    status_p = sub.add_parser("status", help="Summarize Pending backlog findings")
    status_p.add_argument("--course-id", required=True)
    status_p.add_argument("--json", action="store_true")

    args = parser.parse_args()
    try:
        args.course_id = validate_course_id(args.course_id)
    except ValueError as exc:
        parser.error(str(exc))

    if args.command == "list":
        findings = list_findings(args.course_id, status=args.status)
        if args.json:
            print(json.dumps({"ok": True, "findings": findings}, indent=2))
        elif not findings:
            print("(no pending backlog findings)")
        else:
            for finding in findings:
                print(
                    f"{finding['finding_id']} [{finding['severity']}] "
                    f"{finding['status']}: {finding['finding_text']}"
                )
    elif args.command == "cleanup":
        result = cleanup_resolved(
            args.course_id,
            verify_result={"ok": True} if args.object_id else None,
            content_type=args.content_type,
            object_id=args.object_id,
        )
        if args.json:
            print(json.dumps(result, indent=2))
        else:
            print(f"Removed {result['removed_count']} resolved finding(s).")
    elif args.command == "status":
        result = status_summary(args.course_id)
        if args.json:
            print(json.dumps(result, indent=2))
        else:
            print(
                f"{result['total']} pending "
                f"({result['by_status'].get('can_do_now', 0)} can do now, "
                f"{result['by_status'].get('blocked', 0)} blocked)"
            )


if __name__ == "__main__":
    main()
