#!/usr/bin/env python3
"""
PDF crash-risk scanner + automatic safe rasterizer.

Default behavior:
- If run with no arguments, scans recursively from the current Terminal
  directory: Path.cwd(). This is the directory you cd'd into before calling
  the script, not the folder where this script lives.
- If run with file/folder/list arguments, scans those targets recursively.
- Prints every PDF it finds and scans.
- Does NOT create a manifest.
- Does NOT copy PDFs to ~/Desktop/PROBLEMPDFS.
- When a PDF meets the configured risk threshold, it:
    1. Creates a rasterized copy next to the original:
           Exhibit 42.pdf -> Exhibit 42_rstr.pdf
    2. Renames the original by appending .DANGER:
           Exhibit 42.pdf -> Exhibit 42.pdf.DANGER

Important Ghostscript safety behavior:
- Ghostscript is NOT run directly against the original path.
- Each input PDF is staged to a safe temporary path like /tmp/.../input.pdf.
- Ghostscript writes to a safe temporary path like /tmp/.../output.pdf.
- Python then copies the successful output back next to the original.
- The original is renamed to .DANGER only after the output exists and is non-empty.

This avoids Ghostscript failures caused by literal quotes, colons, dollar signs,
parentheses, and other hostile path characters in the original folder/file names.

Requires:
    python3 -m pip install pymupdf
    Ghostscript installed and available as gs, or in one of the common paths below.

Optional:
    qpdf in PATH for structural checks.
"""

from pathlib import Path
import concurrent.futures as cf
import os
import shutil
import subprocess
import sys
import tempfile
import traceback
import uuid

try:
    import fitz  # PyMuPDF
except ImportError:
    print("Missing dependency: PyMuPDF")
    print("Install it with:")
    print("    python3 -m pip install pymupdf")
    sys.exit(1)


# ============================================================
# CONFIGURATION
# ============================================================

# Change this to "HIGH" if you only want HIGH risk PDFs rasterized.
# Leave as "MEDIUM" if you want both MEDIUM and HIGH risk PDFs rasterized.
RASTERIZE_RISK_LEVEL = "MEDIUM"  # "HIGH" or "MEDIUM"

# Ghostscript rasterization settings.
DPI = 300
JPEGQ = 95

# Keep True by default. If your Ghostscript build fails with a font-directory
# permission problem such as "Unable to revert mtime", you may try False.
# False uses -dNOSAFER and should be used only on files you trust.
GS_USE_SAFER = True

# Raster engine:
# - "PYMUPDF" avoids Ghostscript entirely and is the best choice when Ghostscript
#   fails broadly on otherwise readable PDFs.
# - "GHOSTSCRIPT" uses Ghostscript only.
# - "GHOSTSCRIPT_THEN_PYMUPDF" tries Ghostscript first, then PyMuPDF if gs fails.
RASTER_ENGINE = "PYMUPDF"

# The run log showed most failed rasterization attempts were from Windows
# $RECYCLE.BIN folders and temporary Office lock files. Those are usually
# metadata/deleted-file artifacts, not useful review PDFs.
SKIP_RECYCLE_BIN_FOLDERS = True
SKIP_OFFICE_TEMP_PDFS = True

# If PyMuPDF cannot open a file, the PyMuPDF raster engine cannot render it.
# Leave False to report those as HIGH risk but not queue them for rasterization.
RASTERIZE_UNOPENABLE_PDFS = False

# Parallelism settings.
# Scan workers inspect PDFs with PyMuPDF/qpdf. Raster workers launch Ghostscript.
SCAN_WORKERS = 10
RASTER_WORKERS = 4
PROGRESS_EVERY = 25

# Risk scoring thresholds.
HIGH_RISK_SCORE = 7
MEDIUM_RISK_SCORE = 3

# Names that should not be re-rasterized.
RASTER_SUFFIXES = ("_rstr.pdf", "_rstr_ocr.pdf")

# Plain-text files containing one PDF/folder path per line.
# Blank lines and lines beginning with # are ignored.
PATH_LIST_SUFFIXES = (".txt", ".list", ".lst", ".paths")


# ============================================================
# TOOL DISCOVERY
# ============================================================

def find_ghostscript():
    candidates = [
        "/opt/homebrew/bin/gs",
        "/usr/local/bin/gs",
        "/opt/local/bin/gs",
        "/usr/bin/gs",
    ]

    for candidate in candidates:
        if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
            return candidate

    found = shutil.which("gs")
    if found:
        return found

    return None


GS = find_ghostscript()
QPDF = shutil.which("qpdf")


# ============================================================
# HELPERS
# ============================================================

def add_reason(reasons, text):
    if text not in reasons:
        reasons.append(text)


def run_text_command(args, timeout=None, cwd=None, env=None):
    """Run a command and safely decode stdout/stderr as text.

    Ghostscript and qpdf can emit bytes that are not valid UTF-8. If
    subprocess.run(text=True) is used without errors="replace", Python can
    raise UnicodeDecodeError before the script has a chance to inspect the
    actual command result. That can cause false rasterization failures.
    """
    return subprocess.run(
        args,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
        timeout=timeout,
        cwd=cwd,
        env=env,
    )


def clean_tool_output(text, limit=4000):
    text = (text or "").strip()
    if len(text) <= limit:
        return text
    return text[-limit:]


def is_probable_raster_copy(pdf_path: Path) -> bool:
    lower_name = pdf_path.name.lower()
    lower_stem = pdf_path.stem.lower()

    if lower_name.endswith(RASTER_SUFFIXES):
        return True

    if lower_stem.endswith("_rstr"):
        return True

    if lower_stem.endswith("_rstr_ocr"):
        return True

    # Covers outputs like Exhibit 42_rstr_2.pdf.
    if "_rstr_" in lower_stem:
        return True

    return False


def should_skip_path(pdf_path: Path) -> str:
    """Return a human-readable skip reason, or an empty string."""
    parts_upper = {part.upper() for part in pdf_path.parts}

    if SKIP_RECYCLE_BIN_FOLDERS and "$RECYCLE.BIN" in parts_upper:
        return "inside $RECYCLE.BIN"

    if SKIP_OFFICE_TEMP_PDFS and pdf_path.name.startswith("~$"):
        return "temporary Office lock file"

    return ""


def should_rasterize_risk(risk: str) -> bool:
    risk = (risk or "").upper()
    configured = RASTERIZE_RISK_LEVEL.upper().strip()

    if configured == "HIGH":
        return risk == "HIGH"

    if configured == "MEDIUM":
        return risk in ("MEDIUM", "HIGH")

    raise ValueError('RASTERIZE_RISK_LEVEL must be "HIGH" or "MEDIUM"')


def unique_raster_output_path(pdf_path: Path) -> Path:
    output = pdf_path.with_name(f"{pdf_path.stem}_rstr.pdf")

    if not output.exists():
        return output

    n = 2
    while True:
        candidate = pdf_path.with_name(f"{pdf_path.stem}_rstr_{n}.pdf")
        if not candidate.exists():
            return candidate
        n += 1


def unique_danger_path(pdf_path: Path) -> Path:
    danger = pdf_path.with_name(pdf_path.name + ".DANGER")

    if not danger.exists():
        return danger

    n = 2
    while True:
        candidate = pdf_path.with_name(f"{pdf_path.name}.DANGER_{n}")
        if not candidate.exists():
            return candidate
        n += 1


def stage_input(original: Path, staged: Path) -> str:
    """Stage input to a simple temp filename.

    Copy instead of hard-linking. It is slower, but it avoids any odd behavior
    from Ghostscript or filesystem security rules involving hard-linked inputs.
    """
    shutil.copy2(original, staged)
    return "copy"


def copy_output_atomically(staged_output: Path, final_output: Path) -> None:
    """Copy staged Ghostscript output to final output via sibling temp file."""
    final_tmp = final_output.with_name(
        final_output.name + f".tmp.{os.getpid()}.{uuid.uuid4().hex}"
    )

    try:
        shutil.copyfile(staged_output, final_tmp)

        if not final_tmp.exists() or final_tmp.stat().st_size == 0:
            raise RuntimeError("Final temp output was not created or is empty")

        # unique_raster_output_path chose a non-existing destination. Refuse to
        # overwrite if something appeared between selection and replacement.
        if final_output.exists():
            raise FileExistsError(f"Output already exists: {final_output}")

        os.replace(final_tmp, final_output)
    except Exception:
        try:
            if final_tmp.exists():
                final_tmp.unlink()
        except Exception:
            pass
        raise


def qpdf_check(pdf_path: Path):
    if not QPDF:
        return None

    try:
        result = run_text_command(
            [QPDF, "--check", str(pdf_path)],
            timeout=20,
        )
        combined = (result.stdout or "") + "\n" + (result.stderr or "")
        lower = combined.lower()

        if result.returncode != 0:
            return "qpdf reports structural errors"

        if "warning" in lower or "damaged" in lower or "repaired" in lower:
            return "qpdf reports structural warnings"

        return None
    except subprocess.TimeoutExpired:
        return "qpdf check timed out"
    except Exception:
        return "qpdf check failed"


# ============================================================
# PDF INSPECTION
# ============================================================

def inspect_pdf(pdf_path: Path):
    reasons = []
    score = 0

    row = {
        "risk": "",
        "score": 0,
        "path": str(pdf_path),
        "pages": 0,
        "image_placements": 0,
        "max_images_on_page": 0,
        "avg_images_per_page": 0,
        "max_effective_ppi": 0,
        "max_page_decoded_megapixels": 0,
        "full_page_image_pages": 0,
        "rasterized_to": "",
        "original_renamed_to": "",
        "can_open": False,
        "can_rasterize": False,
        "reasons": "",
    }

    try:
        doc = fitz.open(str(pdf_path))
        row["can_open"] = True
        row["can_rasterize"] = True
    except Exception as e:
        row["risk"] = "HIGH"
        row["score"] = 99
        row["can_open"] = False
        row["can_rasterize"] = False
        row["reasons"] = "Cannot open PDF with PyMuPDF: " + str(e)
        return row

    try:
        page_count = doc.page_count
        row["pages"] = page_count

        if page_count == 0:
            row["risk"] = "HIGH"
            row["score"] = 99
            row["can_rasterize"] = False
            row["reasons"] = "PDF has zero pages"
            return row

        if getattr(doc, "is_repaired", False):
            score += 3
            add_reason(reasons, "PyMuPDF says file required repair while opening")

        if doc.is_encrypted:
            score += 2
            add_reason(reasons, "PDF is encrypted or permission-restricted")

        qpdf_reason = qpdf_check(pdf_path)
        if qpdf_reason:
            score += 3
            add_reason(reasons, qpdf_reason)

        total_images = 0
        max_images_on_page = 0
        max_effective_ppi = 0.0
        max_page_decoded_pixels = 0
        full_page_image_pages = 0
        high_ppi_count = 0
        very_high_ppi_count = 0
        skinny_high_detail_count = 0
        huge_decode_pages = 0

        for page_index in range(page_count):
            page = doc.load_page(page_index)
            page_rect = page.rect
            page_area = max(page_rect.width * page_rect.height, 1)

            try:
                image_infos = page.get_image_info(hashes=False, xrefs=True)
            except Exception:
                image_infos = []

            images_on_page = len(image_infos)
            total_images += images_on_page
            max_images_on_page = max(max_images_on_page, images_on_page)

            page_decoded_pixels = 0
            full_page_candidates = 0

            for img in image_infos:
                width_px = int(img.get("width", 0) or 0)
                height_px = int(img.get("height", 0) or 0)
                page_decoded_pixels += max(width_px, 0) * max(height_px, 0)

                bbox_raw = img.get("bbox")
                if not bbox_raw:
                    continue

                try:
                    bbox = fitz.Rect(bbox_raw)
                except Exception:
                    continue

                display_w_in = bbox.width / 72.0 if bbox.width > 0 else 0
                display_h_in = bbox.height / 72.0 if bbox.height > 0 else 0

                if display_w_in <= 0 or display_h_in <= 0:
                    score += 1
                    add_reason(
                        reasons,
                        f"page {page_index + 1}: image has invalid or near-zero display box",
                    )
                    continue

                effective_ppi_x = width_px / display_w_in
                effective_ppi_y = height_px / display_h_in
                effective_ppi = max(effective_ppi_x, effective_ppi_y)
                max_effective_ppi = max(max_effective_ppi, effective_ppi)

                area_fraction = (bbox.width * bbox.height) / page_area
                if area_fraction >= 0.92:
                    full_page_candidates += 1

                narrow_dimension_in = min(display_w_in, display_h_in)
                long_dimension_in = max(display_w_in, display_h_in)
                displayed_aspect = long_dimension_in / max(narrow_dimension_in, 0.01)
                pixel_aspect = max(width_px, height_px) / max(min(width_px, height_px), 1)

                if effective_ppi >= 750:
                    very_high_ppi_count += 1
                elif effective_ppi >= 450:
                    high_ppi_count += 1

                if (
                    narrow_dimension_in < 1.5
                    and max(width_px, height_px) >= 1000
                    and effective_ppi >= 350
                ):
                    skinny_high_detail_count += 1

                if (
                    displayed_aspect >= 7
                    and pixel_aspect >= 3
                    and narrow_dimension_in < 2.0
                    and max(width_px, height_px) >= 1500
                ):
                    skinny_high_detail_count += 1

            max_page_decoded_pixels = max(max_page_decoded_pixels, page_decoded_pixels)

            if page_decoded_pixels >= 60_000_000:
                huge_decode_pages += 1

            if images_on_page == 1 and full_page_candidates == 1:
                full_page_image_pages += 1

        avg_images = total_images / page_count if page_count else 0
        max_mpx = max_page_decoded_pixels / 1_000_000.0

        row["image_placements"] = total_images
        row["max_images_on_page"] = max_images_on_page
        row["avg_images_per_page"] = round(avg_images, 2)
        row["max_effective_ppi"] = round(max_effective_ppi, 1)
        row["max_page_decoded_megapixels"] = round(max_mpx, 1)
        row["full_page_image_pages"] = full_page_image_pages

        if max_images_on_page >= 12:
            score += 4
            add_reason(reasons, f"many image tiles on a page: max {max_images_on_page}")
        elif max_images_on_page >= 8:
            score += 3
            add_reason(reasons, f"multiple image tiles on a page: max {max_images_on_page}")

        if avg_images >= 6:
            score += 2
            add_reason(reasons, f"high average image placements per page: {avg_images:.1f}")

        if very_high_ppi_count > 0:
            score += 4
            add_reason(reasons, f"very high effective image PPI detected: max {max_effective_ppi:.0f}")
        elif high_ppi_count > 0:
            score += 2
            add_reason(reasons, f"high effective image PPI detected: max {max_effective_ppi:.0f}")

        if skinny_high_detail_count > 0:
            score += 5
            add_reason(reasons, "skinny/high-detail image placement detected")

        if huge_decode_pages > 0:
            score += 3
            add_reason(reasons, f"large decoded pixel load on at least one page: max {max_mpx:.1f} MP")

        looks_like_safe_raster = (
            full_page_image_pages == page_count
            and max_images_on_page == 1
            and 125 <= max_effective_ppi <= 325
        )

        if looks_like_safe_raster:
            score = max(0, score - 6)
            add_reason(reasons, "looks like safe raster PDF: one full-page image per page at sane PPI")

        if is_probable_raster_copy(pdf_path) and looks_like_safe_raster:
            score = 0
            add_reason(reasons, "filename and structure both look like prior rasterized review copy")

        if score >= HIGH_RISK_SCORE:
            risk = "HIGH"
        elif score >= MEDIUM_RISK_SCORE:
            risk = "MEDIUM"
        else:
            risk = "LOW"

        row["risk"] = risk
        row["score"] = score
        row["reasons"] = "; ".join(reasons) if reasons else "No major crash-risk pattern detected"
        return row

    except Exception:
        row["risk"] = "HIGH"
        row["score"] = 99
        row["reasons"] = "Inspection crashed: " + traceback.format_exc(limit=2).replace("\n", " ")
        return row
    finally:
        try:
            doc.close()
        except Exception:
            pass


# ============================================================
# RASTERIZE + QUARANTINE ORIGINAL
# ============================================================

def rasterize_with_pymupdf_to(pdf_path: Path, output_path: Path) -> None:
    """Render each original page to a JPEG image and place it into a new PDF."""
    zoom = DPI / 72.0
    matrix = fitz.Matrix(zoom, zoom)

    src = fitz.open(str(pdf_path))
    out = fitz.open()

    try:
        if src.page_count == 0:
            raise RuntimeError("PyMuPDF opened the file but found zero pages")

        for page_index in range(src.page_count):
            page = src.load_page(page_index)
            rect = page.rect

            pix = page.get_pixmap(
                matrix=matrix,
                colorspace=fitz.csRGB,
                alpha=False,
                annots=True,
            )

            try:
                img_bytes = pix.tobytes("jpeg", jpg_quality=JPEGQ)
            except TypeError:
                img_bytes = pix.tobytes("jpeg")

            new_page = out.new_page(width=rect.width, height=rect.height)
            new_page.insert_image(new_page.rect, stream=img_bytes)

        out.save(str(output_path), garbage=4, deflate=True, clean=True)
    finally:
        try:
            out.close()
        except Exception:
            pass
        try:
            src.close()
        except Exception:
            pass


def rasterize_with_ghostscript_to(pdf_path: Path, output_path: Path):
    if not GS:
        raise RuntimeError("Ghostscript not found")

    with tempfile.TemporaryDirectory(prefix="safe_gs_raster_") as tmpdir_raw:
        tmpdir = Path(tmpdir_raw)
        staged_in = tmpdir / "input.pdf"
        staged_out = tmpdir / "output.pdf"
        tmp_home = tmpdir / "home"
        tmp_home.mkdir(parents=True, exist_ok=True)

        stage_mode = stage_input(pdf_path, staged_in)
        safety_arg = "-dSAFER" if GS_USE_SAFER else "-dNOSAFER"

        cmd = [
            GS,
            safety_arg,
            "-dBATCH",
            "-dNOPAUSE",
            "-sDEVICE=pdfimage24",
            f"-r{DPI}",
            f"-dJPEGQ={JPEGQ}",
            f"-sOutputFile={staged_out}",
            str(staged_in),
        ]

        env = os.environ.copy()
        env.pop("GS_OPTIONS", None)
        env["HOME"] = str(tmp_home)
        env["TMPDIR"] = str(tmpdir)

        proc = run_text_command(cmd, cwd=str(tmpdir), env=env)

        if proc.returncode != 0:
            stderr = (proc.stderr or "").strip()
            stdout = (proc.stdout or "").strip()
            detail = clean_tool_output(stderr or stdout or "no Ghostscript output")
            raise RuntimeError(f"Ghostscript failed ({proc.returncode}); stage={stage_mode}: {detail}")

        if not staged_out.exists() or staged_out.stat().st_size == 0:
            raise RuntimeError("Ghostscript finished but did not create a non-empty output PDF")

        copy_output_atomically(staged_out, output_path)


def rasterize_to_temp_output(pdf_path: Path, staged_output: Path) -> str:
    engine = RASTER_ENGINE.upper().strip()

    if engine == "PYMUPDF":
        rasterize_with_pymupdf_to(pdf_path, staged_output)
        return "PyMuPDF"

    if engine == "GHOSTSCRIPT":
        rasterize_with_ghostscript_to(pdf_path, staged_output)
        return "Ghostscript"

    if engine == "GHOSTSCRIPT_THEN_PYMUPDF":
        try:
            rasterize_with_ghostscript_to(pdf_path, staged_output)
            return "Ghostscript"
        except Exception as gs_error:
            try:
                if staged_output.exists():
                    staged_output.unlink()
            except Exception:
                pass
            rasterize_with_pymupdf_to(pdf_path, staged_output)
            return f"PyMuPDF fallback after Ghostscript failure: {gs_error}"

    raise RuntimeError('RASTER_ENGINE must be "PYMUPDF", "GHOSTSCRIPT", or "GHOSTSCRIPT_THEN_PYMUPDF"')


def rasterize_and_mark_danger(pdf_path: Path):
    if is_probable_raster_copy(pdf_path):
        return {
            "ok": False,
            "skipped": True,
            "message": "Skipped because filename looks like an existing rasterized review copy",
            "output": "",
            "danger": "",
        }

    skip_reason = should_skip_path(pdf_path)
    if skip_reason:
        return {
            "ok": False,
            "skipped": True,
            "message": f"Skipped because path is {skip_reason}",
            "output": "",
            "danger": "",
        }

    output = unique_raster_output_path(pdf_path)
    danger = unique_danger_path(pdf_path)
    staged_final = output.with_name(output.name + f".tmp.{os.getpid()}.{uuid.uuid4().hex}")

    try:
        engine_used = rasterize_to_temp_output(pdf_path, staged_final)

        if not staged_final.exists() or staged_final.stat().st_size == 0:
            raise RuntimeError(f"{engine_used} finished but did not create a non-empty output PDF")

        if output.exists():
            raise FileExistsError(f"Output already exists: {output}")

        os.replace(staged_final, output)
        pdf_path.rename(danger)

        return {
            "ok": True,
            "skipped": False,
            "message": f"Rasterized with {engine_used}; original renamed .DANGER",
            "output": str(output),
            "danger": str(danger),
        }

    except Exception as e:
        try:
            if staged_final.exists():
                staged_final.unlink()
        except Exception:
            pass

        return {
            "ok": False,
            "skipped": False,
            "message": str(e),
            "output": str(output),
            "danger": str(danger),
        }


# ============================================================
# TARGET DISCOVERY
# ============================================================

def iter_path_list_file(list_path: Path):
    try:
        lines = list_path.read_text(errors="replace").splitlines()
    except Exception as e:
        print(f"  SKIP: could not read path list: {e}", flush=True)
        return

    base = list_path.parent
    for line_no, raw_line in enumerate(lines, start=1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue

        candidate = Path(line).expanduser()
        if not candidate.is_absolute():
            candidate = base / candidate

        print(f"  PATH LIST ENTRY #{line_no}: {candidate}", flush=True)
        yield str(candidate)


def iter_pdf_targets(raw_targets):
    seen = set()
    pending = list(raw_targets)

    while pending:
        raw = pending.pop(0)
        target = Path(raw).expanduser().resolve()
        print(f"TARGET: {target}", flush=True)

        if not target.exists():
            print("  SKIP: target does not exist", flush=True)
            continue

        if target.is_file() and target.suffix.lower() in PATH_LIST_SUFFIXES:
            print("  READING PATH LIST FILE", flush=True)
            entries = list(iter_path_list_file(target))
            pending = entries + pending
            continue

        if target.is_file():
            if target.suffix.lower() == ".pdf":
                skip_reason = should_skip_path(target)
                if skip_reason:
                    print(f"  SKIP: {skip_reason}", flush=True)
                    continue

                if is_probable_raster_copy(target):
                    print("  SKIP: looks like an already-rasterized PDF", flush=True)
                    continue

                key = str(target)
                if key not in seen:
                    seen.add(key)
                    print(f"  FOUND PDF FILE: {target}", flush=True)
                    yield target
            else:
                print("  SKIP: not a PDF file", flush=True)
            continue

        if target.is_dir():
            print(f"  WALKING DIRECTORY RECURSIVELY: {target}", flush=True)
            found_any = False

            for p in target.rglob("*"):
                if not p.is_file():
                    continue

                if p.suffix.lower() != ".pdf":
                    continue

                skip_reason = should_skip_path(p)
                if skip_reason:
                    continue

                if is_probable_raster_copy(p):
                    continue

                found_any = True
                p = p.resolve()
                key = str(p)

                if key in seen:
                    continue

                seen.add(key)
                print(f"  FOUND PDF: {p}", flush=True)
                yield p

            if not found_any:
                print(f"  NO PDFs FOUND UNDER: {target}", flush=True)
            continue

        print("  SKIP: target is neither file nor directory", flush=True)


# ============================================================
# PARALLEL WORKERS
# ============================================================

def inspect_pdf_worker(pdf_path_str):
    pdf_path = Path(pdf_path_str)
    row = inspect_pdf(pdf_path)
    row["path"] = str(pdf_path)
    return row


def rasterize_worker(pdf_path_str):
    pdf_path = Path(pdf_path_str)
    result = rasterize_and_mark_danger(pdf_path)
    result["path"] = str(pdf_path)
    return result


def run_parallel_inspection(targets):
    tasks = [str(path) for path in targets]

    if SCAN_WORKERS <= 1:
        rows = []
        total = len(tasks)
        for completed, path_str in enumerate(tasks, start=1):
            rows.append(inspect_pdf_worker(path_str))
            if completed == total or completed % PROGRESS_EVERY == 0:
                print(f"Scan progress: {completed}/{total} complete", flush=True)
        return sorted(rows, key=lambda row: row["path"].lower())

    rows = []
    total = len(tasks)
    completed = 0

    with cf.ProcessPoolExecutor(max_workers=SCAN_WORKERS) as executor:
        futures = {
            executor.submit(inspect_pdf_worker, path_str): path_str
            for path_str in tasks
        }

        for future in cf.as_completed(futures):
            completed += 1
            path_str = futures[future]
            try:
                row = future.result()
            except Exception as e:
                row = {
                    "risk": "HIGH",
                    "score": 99,
                    "path": path_str,
                    "pages": 0,
                    "image_placements": 0,
                    "max_images_on_page": 0,
                    "avg_images_per_page": 0,
                    "max_effective_ppi": 0,
                    "max_page_decoded_megapixels": 0,
                    "full_page_image_pages": 0,
                    "rasterized_to": "",
                    "original_renamed_to": "",
                    "can_open": False,
                    "can_rasterize": False,
                    "reasons": "Inspection worker crashed: " + str(e),
                }
            rows.append(row)

            if completed == total or completed % PROGRESS_EVERY == 0:
                print(f"Scan progress: {completed}/{total} complete", flush=True)

    return sorted(rows, key=lambda row: row["path"].lower())


def run_parallel_rasterization(pdf_paths):
    tasks = [str(path) for path in pdf_paths]

    if RASTER_WORKERS <= 1:
        results = []
        total = len(tasks)
        for completed, path_str in enumerate(tasks, start=1):
            results.append(rasterize_worker(path_str))
            if completed == total or completed % PROGRESS_EVERY == 0:
                print(f"Raster progress: {completed}/{total} complete", flush=True)
        return sorted(results, key=lambda result: result["path"].lower())

    results = []
    total = len(tasks)
    completed = 0

    # A thread pool is enough here because each task launches a separate
    # Ghostscript process and waits for it. The heavy work occurs in gs.
    with cf.ThreadPoolExecutor(max_workers=RASTER_WORKERS) as executor:
        futures = {
            executor.submit(rasterize_worker, path_str): path_str
            for path_str in tasks
        }

        for future in cf.as_completed(futures):
            completed += 1
            path_str = futures[future]
            try:
                result = future.result()
            except Exception as e:
                result = {
                    "ok": False,
                    "skipped": False,
                    "message": "Raster worker crashed: " + str(e),
                    "output": "",
                    "danger": "",
                    "path": path_str,
                }
            results.append(result)

            if completed == total or completed % PROGRESS_EVERY == 0:
                print(f"Raster progress: {completed}/{total} complete", flush=True)

    return sorted(results, key=lambda result: result["path"].lower())


def print_scan_row(index, total, row):
    print("")
    print(f"SCANNED #{index}/{total}: {row['path']}")
    print(f"  RESULT: {row['risk']} risk, score {row['score']}")
    print(f"  PAGES: {row['pages']}")
    print(f"  IMAGE PLACEMENTS: {row['image_placements']}")
    print(f"  MAX IMAGES ON PAGE: {row['max_images_on_page']}")
    print(f"  MAX EFFECTIVE PPI: {row['max_effective_ppi']}")
    print(f"  REASONS: {row['reasons']}")


def print_raster_result(index, total, result):
    print("")
    print(f"RASTER RESULT #{index}/{total}: {result['path']}")
    if result["ok"]:
        print(f"  CREATED: {result['output']}")
        print(f"  RENAMED ORIGINAL: {result['danger']}")
    elif result["skipped"]:
        print(f"  SKIPPED: {result['message']}")
    else:
        print(f"  RASTERIZE FAILED: {result['message']}")
        print("  ORIGINAL WAS NOT RENAMED.")


# ============================================================
# MAIN
# ============================================================

def main():
    configured = RASTERIZE_RISK_LEVEL.upper().strip()
    if configured not in ("HIGH", "MEDIUM"):
        print('ERROR: RASTERIZE_RISK_LEVEL must be "HIGH" or "MEDIUM".')
        sys.exit(1)

    engine = RASTER_ENGINE.upper().strip()
    if engine not in ("PYMUPDF", "GHOSTSCRIPT", "GHOSTSCRIPT_THEN_PYMUPDF"):
        print('ERROR: RASTER_ENGINE must be "PYMUPDF", "GHOSTSCRIPT", or "GHOSTSCRIPT_THEN_PYMUPDF".')
        sys.exit(1)

    if SCAN_WORKERS < 1 or RASTER_WORKERS < 1:
        print("ERROR: SCAN_WORKERS and RASTER_WORKERS must both be at least 1.")
        sys.exit(1)

    if engine in ("GHOSTSCRIPT", "GHOSTSCRIPT_THEN_PYMUPDF") and not GS:
        print("")
        print("ERROR: Ghostscript not found.")
        print("Install Ghostscript, then run this script again, or set RASTER_ENGINE = \"PYMUPDF\".")
        print("")
        print("Checked common locations:")
        print("  /opt/homebrew/bin/gs")
        print("  /usr/local/bin/gs")
        print("  /opt/local/bin/gs")
        print("  /usr/bin/gs")
        print("")
        sys.exit(1)

    if len(sys.argv) > 1:
        raw_targets = sys.argv[1:]
        mode_description = "argument target(s)"
    else:
        # This is the important behavior: no arguments means recursively scan
        # from the directory where the script was called, i.e. the current
        # Terminal working directory. It does not use __file__.
        raw_targets = [str(Path.cwd())]
        mode_description = "current Terminal directory"

    print("")
    print("PDF CRASH-RISK SCAN + PARALLEL SAFE RASTERIZE STARTING")
    print("Version: CWD-recursive; PyMuPDF raster fallback; recycle/temp skips")
    print(f"Mode: scanning from {mode_description}")
    print(f"Current Terminal directory: {Path.cwd().resolve()}")
    print(f"Rasterize threshold: {configured}" + (" only" if configured == "HIGH" else " and HIGH"))
    print(f"Raster engine: {engine}")
    print(f"Ghostscript: {GS if GS else 'not found'}")
    print(f"Ghostscript safety: {'-dSAFER' if GS_USE_SAFER else '-dNOSAFER'}")
    print(f"Raster settings: DPI={DPI}, JPEGQ={JPEGQ}")
    print(f"Skip $RECYCLE.BIN folders: {SKIP_RECYCLE_BIN_FOLDERS}")
    print(f"Skip Office temp PDFs: {SKIP_OFFICE_TEMP_PDFS}")
    print(f"Rasterize unopenable PDFs: {RASTERIZE_UNOPENABLE_PDFS}")
    print(f"Scan workers: {SCAN_WORKERS}")
    print(f"Raster workers: {RASTER_WORKERS}")
    print(f"qpdf structural check: {'enabled: ' + QPDF if QPDF else 'not found / skipped'}")
    print("")

    # Snapshot the targets before modifying the directory. This prevents the
    # script from discovering and scanning its own newly-created _rstr files.
    targets = list(iter_pdf_targets(raw_targets))

    if not targets:
        print("")
        print("NO PDF FILES WERE SCANNED.")
        print("Try:")
        print('  cd "/folder/with/pdfs" && python3 "/full/path/to/PDFcrashScan_RASTERIZE_parallel_SAFE_CWD.py"')
        print('  python3 PDFcrashScan_RASTERIZE_parallel_SAFE_CWD.py "/full/path/to/problem.pdf"')
        print('  python3 PDFcrashScan_RASTERIZE_parallel_SAFE_CWD.py "/full/path/to/path-list.txt"')
        return

    print("")
    print(f"TARGET SNAPSHOT COMPLETE: {len(targets)} PDF(s)")
    print("Starting parallel inspection...")

    rows = run_parallel_inspection(targets)

    high_count = 0
    medium_count = 0
    low_count = 0
    rasterize_queue = []

    print("")
    print("INSPECTION RESULTS")

    for index, row in enumerate(rows, start=1):
        print_scan_row(index, len(rows), row)

        if row["risk"] == "HIGH":
            high_count += 1
        elif row["risk"] == "MEDIUM":
            medium_count += 1
        else:
            low_count += 1

        if should_rasterize_risk(row["risk"]):
            if not row.get("can_rasterize", False) and not RASTERIZE_UNOPENABLE_PDFS:
                print(f"  ACTION: not queued; cannot rasterize with configured engine ({row['risk']} risk)")
            else:
                rasterize_queue.append(Path(row["path"]))
                print(f"  ACTION: queued for rasterization ({row['risk']} risk)")
        else:
            print("  ACTION: none")

    rasterized_count = 0
    failed_raster_count = 0
    skipped_raster_count = 0

    if rasterize_queue:
        print("")
        print(f"Starting parallel rasterization of {len(rasterize_queue)} PDF(s)...")
        print("Original PDFs are renamed to .DANGER only after the rasterized PDF succeeds.")
        print("Ghostscript input/output paths are staged under safe temporary filenames.")

        raster_results = run_parallel_rasterization(rasterize_queue)

        for index, result in enumerate(raster_results, start=1):
            print_raster_result(index, len(raster_results), result)
            if result["ok"]:
                rasterized_count += 1
            elif result["skipped"]:
                skipped_raster_count += 1
            else:
                failed_raster_count += 1
    else:
        print("")
        print("No PDFs met the configured rasterization threshold.")

    print("")
    print("SCAN COMPLETE")
    print(f"SCANNED:             {len(rows)}")
    print(f"HIGH:                {high_count}")
    print(f"MEDIUM:              {medium_count}")
    print(f"LOW:                 {low_count}")
    print(f"QUEUED TO RASTERIZE: {len(rasterize_queue)}")
    print(f"RASTERIZED/RENAMED:  {rasterized_count}")
    print(f"RASTERIZE SKIPPED:   {skipped_raster_count}")
    print(f"RASTERIZE FAILURES:  {failed_raster_count}")
    print("")
    print("Done. Files ending in .pdf.DANGER are the original risky PDFs.")
    print("The corresponding _rstr.pdf files are the Quick Look-safe review copies.")


if __name__ == "__main__":
    main()
