"""Tests for scripts/remediate_assess.py — no network, no Java, no downloads."""

import base64
import io
import os
import sys
import zipfile

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import remediate_assess as ra


# ── Raw PDF-byte fixture builders ───────────────────────────────────────
# Hand-rolled per the plan (docs/plan-file-remediation-skill.md): a minimal PDF
# gives full control over the catalog (/MarkInfo, /StructTreeRoot) that
# higher-level writers like pypdf.PdfWriter don't expose directly.

def _minimal_pdf(tagged=False):
    """One blank untagged/tagged page — no text, no images."""
    mark_info = "  /MarkInfo << /Marked true >>\n" if tagged else ""
    objects = [
        "1 0 obj\n<< /Type /Catalog /Pages 2 0 R\n" + mark_info + ">>\nendobj\n",
        "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
        "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] "
        "/Resources << >> >>\nendobj\n",
    ]
    return _assemble(objects)


def _assemble(objects, catalog_num=1):
    body = "%PDF-1.7\n"
    offsets = [0]
    for obj in objects:
        offsets.append(len(body))
        body += obj
    xref_offset = len(body)
    n = len(objects) + 1
    xref = f"xref\n0 {n}\n0000000000 65535 f \n"
    for off in offsets[1:]:
        xref += f"{off:010d} 00000 n \n"
    trailer = f"trailer\n<< /Size {n} /Root {catalog_num} 0 R >>\nstartxref\n{xref_offset}\n%%EOF"
    return (body + xref + trailer).encode("latin-1")


def _multi_page_pdf(page_types):
    """page_types: list of 'image', 'text', or 'blank'. 'image' pages carry an
    image XObject and no text; 'text' pages carry a >100-char Tj content
    stream; 'blank' pages carry neither (no XObject, no text) — used to prove
    the non-text heuristic doesn't require a detectable image XObject."""
    num = iter(range(1, 999))
    catalog_num, pages_num, font_num = next(num), next(num), next(num)
    page_nums, content_nums, image_nums = [], [], []
    for _ in page_types:
        page_nums.append(next(num))
        content_nums.append(next(num))

    objs = {
        catalog_num: f"<< /Type /Catalog /Pages {pages_num} 0 R >>",
        pages_num: "<< /Type /Pages /Kids [%s] /Count %d >>" % (
            " ".join(f"{p} 0 R" for p in page_nums), len(page_nums)
        ),
        font_num: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
    }
    for i, pt in enumerate(page_types):
        pn, cn = page_nums[i], content_nums[i]
        if pt == "image":
            imgn = next(num)
            image_nums.append(imgn)
            resources = f"<< /XObject << /Im0 {imgn} 0 R >> >>"
            content = "q Q"
            img_bytes = "\x00" * 4
            objs[imgn] = (
                "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 "
                f"/ColorSpace /DeviceGray /BitsPerComponent 8 /Length {len(img_bytes)} >>\n"
                f"stream\n{img_bytes}\nendstream"
            )
        elif pt == "text":
            image_nums.append(None)
            resources = f"<< /Font << /F1 {font_num} 0 R >> >>"
            text = ("Hello world, this is a real text page with more than one "
                    "hundred characters of extractable content for testing.")
            content = f"BT /F1 12 Tf 10 100 Td ({text}) Tj ET"
        else:  # blank: no text, no image XObject at all
            image_nums.append(None)
            resources = "<< >>"
            content = "q Q"
        objs[pn] = (
            f"<< /Type /Page /Parent {pages_num} 0 R /MediaBox [0 0 200 200] "
            f"/Resources {resources} /Contents {cn} 0 R >>"
        )
        objs[cn] = f"<< /Length {len(content)} >>\nstream\n{content}\nendstream"

    header = "%PDF-1.7\n"
    body = header
    offsets = {}
    for n_ in sorted(objs):
        offsets[n_] = len(body)
        body += f"{n_} 0 obj\n{objs[n_]}\nendobj\n"
    xref_offset = len(body)
    top = max(objs) + 1
    xref = f"xref\n0 {top}\n0000000000 65535 f \n"
    for i in range(1, top):
        off = offsets.get(i)
        xref += (f"{off:010d} 00000 n \n" if off is not None else "0000000000 00000 f \n")
    trailer = f"trailer\n<< /Size {top} /Root {catalog_num} 0 R >>\nstartxref\n{xref_offset}\n%%EOF"
    return (body + xref + trailer).encode("latin-1")


# ── PDF classification ───────────────────────────────────────────────────

def test_classify_pdf_untagged(tmp_path):
    # A real digital-text page (>100 extractable chars) — the scan heuristic
    # (FIX 3) treats text-length alone as the non-text signal, so a fixture
    # representing genuine digital-untagged content must carry real text,
    # not just an empty page.
    p = tmp_path / "untagged.pdf"
    p.write_bytes(_multi_page_pdf(["text"]))
    result = ra.classify_file(p)
    assert result["class"] == "digital-untagged"
    assert result["route"] == "E2-insitu"
    assert result["type"] == "pdf"


def test_classify_pdf_tagged(tmp_path):
    p = tmp_path / "tagged.pdf"
    p.write_bytes(_minimal_pdf(tagged=True))
    result = ra.classify_file(p)
    assert result["class"] == "digital-tagged"
    assert result["route"] == "E3-metadata-patch"
    assert result["detail"]["page_count"] == 1


def test_classify_pdf_encrypted_never_opened(tmp_path):
    from pypdf import PdfWriter
    w = PdfWriter()
    w.add_blank_page(width=200, height=200)
    w.encrypt("secret-password")
    p = tmp_path / "encrypted.pdf"
    with p.open("wb") as f:
        w.write(f)
    result = ra.classify_file(p)
    assert result["class"] == "encrypted"
    assert result["route"] == "flag"
    assert result["flags"] == ["encrypted"]
    # Never attempts to decrypt: no page/structure data leaks into detail.
    assert result["detail"] == {}


def test_classify_pdf_corrupt_is_graceful(tmp_path):
    p = tmp_path / "corrupt.pdf"
    p.write_bytes(b"%PDF-1.4\nthis is not a real pdf body, truncated")
    result = ra.classify_file(p)
    assert result["class"] == "corrupt"
    assert result["route"] == "flag"
    assert "error" in result["detail"]


def test_classify_pdf_scanned(tmp_path):
    p = tmp_path / "scanned.pdf"
    p.write_bytes(_multi_page_pdf(["image"]))
    result = ra.classify_file(p)
    assert result["class"] == "scanned"
    assert result["route"] == "OCR+E2"
    assert result["detail"]["image_only_pages"] == 1


def test_classify_pdf_non_text_page_without_image_still_counts_non_text(tmp_path):
    """Scan heuristic is text-length-only — a page with no extractable text but
    no *detectable* image XObject either (e.g. the image is nested inside a
    Form XObject our census can't see, per the STP 530 fixture) must still be
    treated as non-text, not misclassified as digital-untagged."""
    p = tmp_path / "blank-scan.pdf"
    p.write_bytes(_multi_page_pdf(["blank"]))
    result = ra.classify_file(p)
    assert result["class"] == "scanned"
    assert result["route"] == "OCR+E2"
    assert result["detail"]["image_only_pages"] == 1
    assert result["detail"]["image_total"] == 0


def test_classify_pdf_hybrid(tmp_path):
    p = tmp_path / "hybrid.pdf"
    p.write_bytes(_multi_page_pdf(["image", "image", "text", "text"]))
    result = ra.classify_file(p)
    assert result["class"] == "hybrid"
    assert result["route"] == "E2+OCR-pages"
    assert result["detail"]["image_only_pages"] == 2
    assert result["detail"]["text_pages"] == 2


def test_classify_pdf_oversized_flag_still_classifies(tmp_path, monkeypatch):
    monkeypatch.setattr(ra, "MAX_PAGES", 1)
    p = tmp_path / "oversized.pdf"
    p.write_bytes(_multi_page_pdf(["text", "text"]))
    result = ra.classify_file(p)
    assert "oversized" in result["flags"]
    # Oversized doesn't block classification — still routed normally.
    assert result["class"] == "digital-untagged"


def test_classify_pdf_verapdf_not_installed_by_default(tmp_path, monkeypatch):
    # Isolate from the real machine's vendor state (file-remediation Phase 2
    # bootstraps veraPDF locally on dev/reviewer machines) — this test covers
    # the absent-vendor code path specifically, not whatever happens to be
    # installed on whoever runs the suite.
    monkeypatch.setattr(ra.setup_remediation, "get_verapdf", lambda: None)
    p = tmp_path / "tagged.pdf"
    p.write_bytes(_minimal_pdf(tagged=True))
    result = ra.classify_file(p)
    assert result["detail"]["verapdf"] == "not installed"


# ── DOCX classification ───────────────────────────────────────────────────

def test_classify_docx_clean(tmp_path):
    from docx import Document
    from docx.oxml.ns import qn

    doc = Document()
    doc.core_properties.title = "Clean Test Document"
    doc.add_heading("Section One", level=1)
    doc.add_paragraph("Body text.")
    table = doc.add_table(rows=2, cols=2)
    table.rows[0].cells[0].text = "Header A"
    table.rows[0].cells[1].text = "Header B"
    trPr = table.rows[0]._tr.get_or_add_trPr()
    trPr.append(trPr.makeelement(qn("w:tblHeader"), {}))
    p = tmp_path / "clean.docx"
    doc.save(p)

    result = ra.classify_file(p)
    assert result["class"] == "docx"
    assert result["route"] == "E1-office-inplace"
    assert result["detail"]["has_heading_style"] is True
    assert result["detail"]["has_title"] is True
    assert result["detail"]["tables_missing_header"] == 0
    assert result["flags"] == []


def test_classify_docx_missing_alt_and_heading(tmp_path):
    from docx import Document

    # 1x1 red PNG, hardcoded so CI needs no Pillow (ponytail: bytes over a dep)
    png_1px = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQ"
        "DwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
    )
    doc = Document()
    buf = io.BytesIO(png_1px)
    doc.add_paragraph("Just a paragraph, no heading style used.")
    doc.add_picture(buf, width=100)
    p = tmp_path / "bad.docx"
    doc.save(p)

    result = ra.classify_file(p)
    assert result["class"] == "docx"
    assert result["detail"]["images_missing_alt"] == 1
    assert result["detail"]["has_heading_style"] is False
    assert result["detail"]["has_title"] is False
    assert "images_missing_alt:1" in result["flags"]
    assert "no_heading_styles" in result["flags"]
    assert "missing_title" in result["flags"]


def test_classify_docx_corrupt_is_graceful(tmp_path):
    p = tmp_path / "corrupt.docx"
    p.write_bytes(b"not a zip file at all")
    result = ra.classify_file(p)
    assert result["class"] == "corrupt"
    assert result["route"] == "flag"


# ── PPTX classification ───────────────────────────────────────────────────
# Hand-rolled minimal PPTX (~30 lines): the classifier only reads
# ppt/slides/slideN.xml + docProps/core.xml from the zip, so a full
# [Content_Types].xml/_rels tree isn't required to exercise the logic.

_SLIDE_NS = ('xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
             'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"')


def _slide_xml(has_title, pic_descr):
    ph = '<p:nvPr><p:ph type="title"/></p:nvPr>' if has_title else "<p:nvPr/>"
    pic = ""
    if pic_descr is not None:
        pic = (
            '<p:pic><p:nvPicPr><p:cNvPr id="3" name="Picture 1" '
            f'descr="{pic_descr}"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>'
            "<p:spPr/></p:pic>"
        )
    return (
        f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<p:sld {_SLIDE_NS}>'
        f'<p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title 1"/>'
        f"<p:cNvSpPr/>{ph}</p:nvSpPr><p:spPr/></p:sp>{pic}</p:spTree></p:cSld></p:sld>"
    )


def _build_pptx(dest, slides):
    with zipfile.ZipFile(dest, "w") as z:
        for i, (has_title, pic_descr) in enumerate(slides, start=1):
            z.writestr(f"ppt/slides/slide{i}.xml", _slide_xml(has_title, pic_descr))


def test_classify_pptx_bad_deck(tmp_path):
    p = tmp_path / "bad.pptx"
    _build_pptx(p, [(False, "image1.png"), (True, None)])
    result = ra.classify_file(p)
    assert result["class"] == "pptx"
    assert result["route"] == "E1-office-inplace"
    assert result["detail"]["slides_missing_title"] == 1
    assert result["detail"]["images_bad_alt"] == 1
    assert "images_bad_alt:1" in result["flags"]
    assert "slides_missing_title:1" in result["flags"]


def test_classify_pptx_clean_alt_text(tmp_path):
    p = tmp_path / "clean.pptx"
    _build_pptx(p, [(True, "Bar chart showing quarterly revenue by region")])
    result = ra.classify_file(p)
    assert result["detail"]["images_bad_alt"] == 0
    assert result["detail"]["slides_missing_title"] == 0


def test_classify_pptx_corrupt_is_graceful(tmp_path):
    p = tmp_path / "corrupt.pptx"
    p.write_bytes(b"not a zip file at all")
    result = ra.classify_file(p)
    assert result["class"] == "corrupt"
    assert result["route"] == "flag"


# ── Dispatch / CLI ───────────────────────────────────────────────────────

def test_classify_file_missing_path(tmp_path):
    result = ra.classify_file(tmp_path / "does-not-exist.pdf")
    assert result["class"] == "missing"
    assert result["route"] == "flag"


def test_classify_file_unsupported_type(tmp_path):
    p = tmp_path / "notes.txt"
    p.write_text("hello")
    result = ra.classify_file(p)
    assert result["class"] == "unsupported"
    assert result["route"] == "flag"


def test_collect_paths_expands_directory(tmp_path):
    (tmp_path / "a.pdf").write_bytes(_minimal_pdf())
    (tmp_path / "b.docx").write_bytes(b"not really a docx")
    (tmp_path / "skip.txt").write_text("nope")
    files = ra._collect_paths([str(tmp_path)])
    names = {f.name for f in files}
    assert names == {"a.pdf", "b.docx"}


def test_cli_path_mode_writes_json(tmp_path, monkeypatch, capsys):
    p = tmp_path / "untagged.pdf"
    p.write_bytes(_multi_page_pdf(["text"]))
    monkeypatch.setattr(sys, "argv", ["remediate_assess.py", "--path", str(p)])
    ra.main()
    captured = capsys.readouterr()
    import json
    records = json.loads(captured.out)
    assert len(records) == 1
    assert records[0]["class"] == "digital-untagged"
    assert "Assessed 1 file" in captured.err
