#!/usr/bin/env python3
"""
MCRO PDF Digital Signature Extraction Tool
===========================================
Processes a directory of PDF files and extracts the complete digital
signature package from every signed document, including:

  - Adobe PKCS#7 (adbe.pkcs7.detached / adbe.pkcs7.sha1)
  - ETSI CAdES  (ETSI.CAdES.detached — PAdES / European standard)
  - ETSI RFC3161 timestamp tokens
  - Any other /Sig field variant

For each signature found, the tool extracts:
  - Signature metadata (reason, location, signer, timestamp)
  - Every certificate in the PKCS7/CMS chain (leaf + intermediates + root)
  - Certificate details (subject, issuer, serial, validity, fingerprints,
    key usage, SAN, CRL/OCSP endpoints, SKI/AKI)
  - Raw DER-encoded certificate files (exportable .cer)
  - Raw PKCS7/CMS blob (exportable .p7b)
  - Signed attributes / authenticated attributes if present
  - Timestamp token details if embedded

Outputs:
  - Per-PDF JSON report with all signature details
  - Master JSON index across all PDFs
  - Exported certificate and PKCS7 files
  - Human-readable summary report (text)

Usage:
  python3 mcro_sig_extract.py /path/to/pdf/directory [/path/to/output/directory]

Dependencies:
  pip install pypdf cryptography asn1crypto
"""

import sys
import os
import json
import hashlib
import traceback
from pathlib import Path
from datetime import datetime, timezone
from typing import Any

from pypdf import PdfReader
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7 as pkcs7_mod
from cryptography.x509.oid import NameOID, ExtensionOID

try:
    import asn1crypto.cms as asn1_cms
    import asn1crypto.core as asn1_core
    import asn1crypto.tsp as asn1_tsp
    import asn1crypto.x509 as asn1_x509
    HAS_ASN1CRYPTO = True
except ImportError:
    HAS_ASN1CRYPTO = False
    print("[WARN] asn1crypto not available — deep CMS/CAdES parsing disabled")


# Common OID friendly names (supplement asn1crypto where it falls short)
OID_NAMES = {
    "1.2.840.113549.1.7.1": "data (id-data)",
    "1.2.840.113549.1.7.2": "signedData (id-signedData)",
    "1.2.840.113549.1.7.3": "envelopedData",
    "1.2.840.113549.1.7.5": "digestedData",
    "1.2.840.113549.1.7.6": "encryptedData",
    "1.2.840.113549.1.9.3": "contentType",
    "1.2.840.113549.1.9.4": "messageDigest",
    "1.2.840.113549.1.9.5": "signingTime",
    "1.2.840.113549.1.9.6": "countersignature",
    "1.2.840.113549.1.9.16.2.12": "signingCertificate",
    "1.2.840.113549.1.9.16.2.14": "timeStampToken",
    "1.2.840.113549.1.9.16.2.47": "signingCertificateV2",
    "1.2.840.113549.1.1.1": "rsaEncryption",
    "1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
    "1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
    "1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
    "1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
    "2.16.840.1.101.3.4.2.1": "sha-256",
    "2.16.840.1.101.3.4.2.2": "sha-384",
    "2.16.840.1.101.3.4.2.3": "sha-512",
    "1.3.6.1.4.1.311.2.1.4": "spcIndirectDataContent (Microsoft)",
    "1.3.6.1.5.5.7.48.1": "OCSP (id-ad-ocsp)",
    "1.3.6.1.5.5.7.48.2": "caIssuers (id-ad-caIssuers)",
    "0.4.0.1733.2.4": "ETSI CAdES (id-smime-ct-contentInfo)",
}


def oid_friendly(dotted: str) -> str:
    """Return a human-readable name for an OID, falling back to dotted notation."""
    return OID_NAMES.get(dotted, dotted)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def sha256hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha1hex(data: bytes) -> str:
    return hashlib.sha1(data).hexdigest()


def safe_str(val) -> str:
    """Convert misc PDF types to a clean string."""
    if val is None:
        return None
    if isinstance(val, bytes):
        try:
            return val.decode("utf-8", errors="replace")
        except Exception:
            return repr(val)
    return str(val)


def oid_name(oid) -> str:
    """Return dotted-string or known name for an OID."""
    try:
        return oid.dotted_string + " (" + (oid._name or "unknown") + ")"
    except Exception:
        return str(oid)


def extract_name_attrs(name: x509.Name) -> dict:
    """Pull all RDN attributes from an x509 Name into a dict."""
    out = {}
    for attr in name:
        key = attr.oid._name or attr.oid.dotted_string
        val = attr.value
        if key in out:
            if isinstance(out[key], list):
                out[key].append(val)
            else:
                out[key] = [out[key], val]
        else:
            out[key] = val
    return out


def extract_extensions(cert: x509.Certificate) -> dict:
    """Extract key extensions into a serializable dict."""
    ext_dict = {}

    # Key Usage
    try:
        ku = cert.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value
        usages = []
        for attr in ["digital_signature", "content_commitment", "key_encipherment",
                      "data_encipherment", "key_agreement", "key_cert_sign",
                      "crl_sign", "encipher_only", "decipher_only"]:
            try:
                if getattr(ku, attr):
                    usages.append(attr)
            except Exception:
                pass
        ext_dict["key_usage"] = usages
    except x509.ExtensionNotFound:
        pass

    # Extended Key Usage
    try:
        eku = cert.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value
        ext_dict["extended_key_usage"] = [oid_name(u) for u in eku]
    except x509.ExtensionNotFound:
        pass

    # Subject Alternative Name
    try:
        san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value
        san_list = []
        for name in san:
            san_list.append(f"{type(name).__name__}: {name.value}")
        ext_dict["subject_alternative_name"] = san_list
    except x509.ExtensionNotFound:
        pass

    # Basic Constraints
    try:
        bc = cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value
        ext_dict["basic_constraints"] = {"ca": bc.ca, "path_length": bc.path_length}
    except x509.ExtensionNotFound:
        pass

    # Subject Key Identifier
    try:
        ski = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_KEY_IDENTIFIER).value
        ext_dict["subject_key_identifier"] = ski.digest.hex(":")
    except x509.ExtensionNotFound:
        pass

    # Authority Key Identifier
    try:
        aki = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_KEY_IDENTIFIER).value
        ext_dict["authority_key_identifier"] = aki.key_identifier.hex(":") if aki.key_identifier else None
    except x509.ExtensionNotFound:
        pass

    # CRL Distribution Points
    try:
        cdp = cert.extensions.get_extension_for_oid(ExtensionOID.CRL_DISTRIBUTION_POINTS).value
        urls = []
        for dp in cdp:
            if dp.full_name:
                for gn in dp.full_name:
                    urls.append(str(gn.value))
        ext_dict["crl_distribution_points"] = urls
    except x509.ExtensionNotFound:
        pass

    # Authority Information Access (OCSP + CA Issuers)
    try:
        aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS).value
        aia_dict = {}
        for desc in aia:
            method = desc.access_method._name or desc.access_method.dotted_string
            loc = str(desc.access_location.value)
            if method in aia_dict:
                if isinstance(aia_dict[method], list):
                    aia_dict[method].append(loc)
                else:
                    aia_dict[method] = [aia_dict[method], loc]
            else:
                aia_dict[method] = loc
        ext_dict["authority_information_access"] = aia_dict
    except x509.ExtensionNotFound:
        pass

    # Catch remaining extensions by OID
    for ext in cert.extensions:
        oid_str = ext.oid.dotted_string
        if oid_str not in [e.dotted_string for e in [
            ExtensionOID.KEY_USAGE, ExtensionOID.EXTENDED_KEY_USAGE,
            ExtensionOID.SUBJECT_ALTERNATIVE_NAME, ExtensionOID.BASIC_CONSTRAINTS,
            ExtensionOID.SUBJECT_KEY_IDENTIFIER, ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
            ExtensionOID.CRL_DISTRIBUTION_POINTS, ExtensionOID.AUTHORITY_INFORMATION_ACCESS,
        ]]:
            ext_dict[f"ext_{oid_str}"] = {
                "name": ext.oid._name or "unknown",
                "critical": ext.critical,
                "value_repr": repr(ext.value)[:500],
            }

    return ext_dict


def cert_to_dict(cert: x509.Certificate, der_bytes: bytes = None) -> dict:
    """Serialize a full x509 certificate to a JSON-safe dict."""
    if der_bytes is None:
        der_bytes = cert.public_bytes(serialization.Encoding.DER)

    return {
        "subject": extract_name_attrs(cert.subject),
        "issuer": extract_name_attrs(cert.issuer),
        "serial_number_hex": format(cert.serial_number, "x"),
        "serial_number_int": cert.serial_number,
        "not_before_utc": cert.not_valid_before_utc.isoformat(),
        "not_after_utc": cert.not_valid_after_utc.isoformat(),
        "signature_algorithm": cert.signature_algorithm_oid._name or cert.signature_algorithm_oid.dotted_string,
        "public_key_algorithm": cert.public_key().__class__.__name__,
        "public_key_size_bits": cert.public_key().key_size,
        "fingerprints": {
            "sha256": sha256hex(der_bytes),
            "sha1": sha1hex(der_bytes),
        },
        "extensions": extract_extensions(cert),
        "is_self_signed": cert.issuer == cert.subject,
        "version": cert.version.name,
    }


# ---------------------------------------------------------------------------
# ASN1 / CMS deep parsing (via asn1crypto)
# ---------------------------------------------------------------------------

def parse_signed_attrs(signer_info) -> list:
    """Extract signed/authenticated attributes from a CMS SignerInfo."""
    attrs = []
    sa = signer_info.get("signed_attrs") or signer_info.get("authenticated_attrs")
    if sa is None:
        return attrs
    for attr in sa:
        oid = attr["type"].dotted
        try:
            vals = [str(v.native) for v in attr["values"]]
        except Exception:
            vals = [repr(attr["values"].contents[:200])]
        try:
            attr_name = attr["type"].human_friendly
        except (AttributeError, ValueError):
            attr_name = oid_friendly(oid)
        attrs.append({"oid": oid, "name": attr_name, "values": vals})
    return attrs


def parse_unsigned_attrs(signer_info) -> list:
    """Extract unsigned/unauthenticated attributes (often contains timestamp tokens)."""
    attrs = []
    ua = signer_info.get("unsigned_attrs") or signer_info.get("unauthenticated_attrs")
    if ua is None:
        return attrs
    for attr in ua:
        oid = attr["type"].dotted
        try:
            attr_name = attr["type"].human_friendly
        except (AttributeError, ValueError):
            attr_name = oid_friendly(oid)
        entry = {"oid": oid, "name": attr_name}
        # Check for embedded timestamp token (RFC 3161)
        if oid == "1.2.840.113549.1.9.16.2.14":  # id-smime-aa-timeStampToken
            try:
                tst_bytes = attr["values"][0].parsed.dump()
                tst_info = parse_timestamp_token(tst_bytes)
                entry["timestamp_token"] = tst_info
            except Exception as e:
                entry["timestamp_token_error"] = str(e)
        else:
            try:
                entry["values"] = [str(v.native) for v in attr["values"]]
            except Exception:
                entry["values_raw"] = repr(attr["values"].contents[:200])
        attrs.append(entry)
    return attrs


def parse_timestamp_token(tst_bytes: bytes) -> dict:
    """Parse an RFC 3161 TimeStampToken (ContentInfo wrapping SignedData)."""
    info = {}
    try:
        ci = asn1_cms.ContentInfo.load(tst_bytes)
        sd = ci["content"]
        # The encapContentInfo contains a TSTInfo
        econtent = sd["encap_content_info"]["content"]
        if econtent is not None:
            tst_info = asn1_tsp.TSTInfo.load(econtent.native)
            info["version"] = tst_info["version"].native
            info["policy"] = tst_info["policy"].dotted
            info["hash_algorithm"] = tst_info["message_imprint"]["hash_algorithm"]["algorithm"].dotted
            info["message_digest_hex"] = tst_info["message_imprint"]["hashed_message"].native.hex()
            info["serial_number"] = tst_info["serial_number"].native
            info["gen_time_utc"] = str(tst_info["gen_time"].native)
            info["ordering"] = tst_info["ordering"].native
            if tst_info["tsa"].native:
                info["tsa"] = str(tst_info["tsa"].native)
        # Extract TST signer certs
        tst_certs = []
        for c in sd["certificates"]:
            try:
                cert_der = c.chosen.dump()
                cert_obj = x509.load_der_x509_certificate(cert_der)
                tst_certs.append(cert_to_dict(cert_obj, cert_der))
            except Exception:
                pass
        if tst_certs:
            info["tst_signer_certificates"] = tst_certs
    except Exception as e:
        info["parse_error"] = str(e)
    return info


def deep_parse_cms(pkcs7_der: bytes) -> dict:
    """Use asn1crypto for deep CMS/CAdES parsing beyond what cryptography lib provides."""
    result = {}
    try:
        ci = asn1_cms.ContentInfo.load(pkcs7_der)
        result["content_type"] = ci["content_type"].dotted
        try:
            result["content_type_name"] = ci["content_type"].human_friendly
        except (AttributeError, ValueError):
            result["content_type_name"] = oid_friendly(ci["content_type"].dotted)

        sd = ci["content"]
        result["cms_version"] = sd["version"].native
        result["digest_algorithms"] = [
            oid_friendly(da["algorithm"].dotted) for da in sd["digest_algorithms"]
        ]

        # Encapsulated content
        eci = sd["encap_content_info"]
        result["encap_content_type"] = eci["content_type"].dotted
        try:
            result["encap_content_type_name"] = eci["content_type"].human_friendly
        except (AttributeError, ValueError):
            result["encap_content_type_name"] = oid_friendly(eci["content_type"].dotted)
        if eci["content"] is not None:
            try:
                native = eci["content"].native
                result["encap_content_size"] = len(native) if native is not None else 0
            except Exception:
                result["encap_content_size"] = "detached"

        # Signer infos
        signers = []
        for si in sd["signer_infos"]:
            si_dict = {}
            try:
                si_dict["version"] = si["version"].native
            except Exception:
                si_dict["version"] = "unknown"
            try:
                si_dict["digest_algorithm"] = oid_friendly(si["digest_algorithm"]["algorithm"].dotted)
            except Exception:
                si_dict["digest_algorithm"] = "unknown"
            try:
                si_dict["signature_algorithm"] = oid_friendly(si["signature_algorithm"]["algorithm"].dotted)
            except Exception:
                si_dict["signature_algorithm"] = "unknown"

            # Signer identifier
            try:
                sid = si["sid"]
                if sid.name == "issuer_and_serial_number":
                    si_dict["signer_id_type"] = "issuer_and_serial_number"
                    try:
                        si_dict["signer_issuer"] = str(sid.chosen["issuer"].human_friendly)
                    except (AttributeError, ValueError):
                        si_dict["signer_issuer"] = str(sid.chosen["issuer"])
                    si_dict["signer_serial_hex"] = format(sid.chosen["serial_number"].native, "x")
                elif sid.name == "subject_key_identifier":
                    si_dict["signer_id_type"] = "subject_key_identifier"
                    si_dict["signer_ski"] = sid.chosen.native.hex()
            except Exception as e:
                si_dict["signer_id_error"] = str(e)

            # Signed attributes
            try:
                signed_attrs = si["signed_attrs"]
                sa_list = []
                if signed_attrs is not None and signed_attrs.native is not None:
                    for attr in signed_attrs:
                        oid = attr["type"].dotted
                        try:
                            attr_name = attr["type"].human_friendly
                        except (AttributeError, ValueError):
                            attr_name = oid_friendly(oid)
                        try:
                            vals = [str(v.native) for v in attr["values"]]
                        except Exception:
                            vals = [repr(attr["values"].contents[:200])]
                        sa_list.append({"oid": oid, "name": attr_name, "values": vals})
                si_dict["signed_attributes"] = sa_list
            except Exception as e:
                si_dict["signed_attributes"] = []
                si_dict["signed_attrs_error"] = str(e)

            # Unsigned attributes (timestamp tokens, countersignatures)
            try:
                unsigned_attrs = si["unsigned_attrs"]
                ua_list = []
                if unsigned_attrs is not None and unsigned_attrs.native is not None:
                    for attr in unsigned_attrs:
                        oid = attr["type"].dotted
                        try:
                            attr_name = attr["type"].human_friendly
                        except (AttributeError, ValueError):
                            attr_name = oid_friendly(oid)
                        entry = {"oid": oid, "name": attr_name}
                        if oid == "1.2.840.113549.1.9.16.2.14":
                            try:
                                tst_bytes = attr["values"][0].parsed.dump()
                                entry["timestamp_token"] = parse_timestamp_token(tst_bytes)
                            except Exception as e:
                                entry["timestamp_token_error"] = str(e)
                        else:
                            try:
                                entry["values"] = [str(v.native) for v in attr["values"]]
                            except Exception:
                                entry["values_raw"] = repr(attr["values"].contents[:200])
                        ua_list.append(entry)
                si_dict["unsigned_attributes"] = ua_list
            except Exception as e:
                si_dict["unsigned_attributes"] = []
                si_dict["unsigned_attrs_error"] = str(e)

            signers.append(si_dict)
        result["signer_infos"] = signers

    except Exception as e:
        result["deep_parse_error"] = str(e)

    return result


# ---------------------------------------------------------------------------
# PDF signature extraction
# ---------------------------------------------------------------------------

def extract_sig_value_metadata(sig_val: dict) -> dict:
    """Pull the top-level /V signature dictionary metadata."""
    meta = {}
    for key in ["/Filter", "/SubFilter", "/Reason", "/Location",
                "/ContactInfo", "/M", "/Name", "/Type"]:
        val = sig_val.get(key)
        if val is not None:
            meta[key.lstrip("/")] = safe_str(val)

    # ByteRange
    br = sig_val.get("/ByteRange")
    if br:
        meta["ByteRange"] = [int(x) for x in br]

    # Prop_Build
    pb = sig_val.get("/Prop_Build")
    if pb:
        try:
            meta["Prop_Build"] = {safe_str(k): safe_str(v) for k, v in pb.items()}
        except Exception:
            meta["Prop_Build"] = safe_str(pb)

    return meta


def process_signature(sig_val: dict, pdf_path: str, sig_name: str,
                      output_dir: Path) -> dict:
    """Process one PDF signature field and return full details."""
    result = {
        "field_name": sig_name,
        "source_pdf": str(pdf_path),
        "metadata": extract_sig_value_metadata(sig_val),
        "certificates": [],
        "cms_deep_parse": None,
        "errors": [],
    }

    # Get the raw PKCS7/CMS blob
    contents = sig_val.get("/Contents")
    if not contents or not isinstance(contents, bytes):
        result["errors"].append("No /Contents (PKCS7/CMS blob) found")
        return result

    # Strip trailing null padding
    pkcs7_der = contents.rstrip(b"\x00")
    result["pkcs7_blob_size"] = len(pkcs7_der)
    result["pkcs7_blob_sha256"] = sha256hex(pkcs7_der)

    # Export raw PKCS7
    safe_pdf_name = Path(pdf_path).stem[:80]
    safe_sig = sig_name[:40].replace("/", "_")
    p7b_filename = f"{safe_pdf_name}__{safe_sig}.p7b"
    p7b_path = output_dir / "pkcs7_blobs" / p7b_filename
    p7b_path.parent.mkdir(parents=True, exist_ok=True)
    p7b_path.write_bytes(pkcs7_der)
    result["exported_pkcs7"] = str(p7b_path)

    # --- Extract certificates via cryptography lib ---
    try:
        certs = pkcs7_mod.load_der_pkcs7_certificates(pkcs7_der)
        for i, cert in enumerate(certs):
            cert_der = cert.public_bytes(serialization.Encoding.DER)
            cert_dict = cert_to_dict(cert, cert_der)

            # Export individual cert
            cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
            cn_str = cn[0].value if cn else "unknown"
            cn_safe = "".join(c if c.isalnum() or c in "-_ " else "_" for c in cn_str)[:60]
            cert_filename = f"{safe_pdf_name}__{safe_sig}__cert{i}__{cn_safe}.cer"
            cert_path = output_dir / "certificates" / cert_filename
            cert_path.parent.mkdir(parents=True, exist_ok=True)
            cert_path.write_bytes(cert_der)
            cert_dict["exported_cer"] = str(cert_path)

            # Also export PEM
            pem_path = cert_path.with_suffix(".pem")
            pem_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
            cert_dict["exported_pem"] = str(pem_path)

            result["certificates"].append(cert_dict)
    except Exception as e:
        result["errors"].append(f"Certificate extraction error: {e}")

    # --- Deep CMS/CAdES parse via asn1crypto ---
    if HAS_ASN1CRYPTO:
        try:
            result["cms_deep_parse"] = deep_parse_cms(pkcs7_der)
        except Exception as e:
            result["errors"].append(f"CMS deep parse error: {e}")

    return result


def process_pdf(pdf_path: str, output_dir: Path) -> dict:
    """Process one PDF file and extract all signatures."""
    pdf_result = {
        "file": str(pdf_path),
        "file_size_bytes": os.path.getsize(pdf_path),
        "file_sha256": sha256hex(Path(pdf_path).read_bytes()),
        "processed_at_utc": datetime.now(timezone.utc).isoformat(),
        "signatures": [],
        "total_signatures": 0,
        "errors": [],
    }

    try:
        reader = PdfReader(pdf_path)
    except Exception as e:
        pdf_result["errors"].append(f"Failed to open PDF: {e}")
        return pdf_result

    # --- Method 1: Form fields with /FT = /Sig ---
    try:
        fields = reader.get_fields()
        if fields:
            for field_name, field_obj in fields.items():
                ft = field_obj.get("/FT")
                if ft and str(ft) == "/Sig":
                    sig_val = field_obj.get("/V")
                    if sig_val and isinstance(sig_val, dict):
                        sig_data = process_signature(
                            sig_val, pdf_path, field_name, output_dir
                        )
                        pdf_result["signatures"].append(sig_data)
    except Exception as e:
        pdf_result["errors"].append(f"Form field scan error: {e}")

    # --- Method 2: Page annotations (catch sigs not in AcroForm) ---
    try:
        seen_names = {s["field_name"] for s in pdf_result["signatures"]}
        for page_num, page in enumerate(reader.pages):
            annots = page.get("/Annots")
            if not annots:
                continue
            for annot in annots:
                try:
                    a = annot.get_object()
                    if a.get("/FT") != "/Sig":
                        continue
                    aname = safe_str(a.get("/T")) or f"page{page_num}_annot"
                    if aname in seen_names:
                        continue
                    sig_val = a.get("/V")
                    if sig_val and isinstance(sig_val, dict):
                        sig_data = process_signature(
                            sig_val, pdf_path, aname, output_dir
                        )
                        sig_data["found_via"] = f"page_annotation (page {page_num})"
                        pdf_result["signatures"].append(sig_data)
                        seen_names.add(aname)
                except Exception:
                    pass
    except Exception as e:
        pdf_result["errors"].append(f"Annotation scan error: {e}")

    pdf_result["total_signatures"] = len(pdf_result["signatures"])
    return pdf_result


# ---------------------------------------------------------------------------
# Summary report
# ---------------------------------------------------------------------------

def build_summary(master: dict) -> str:
    """Build a human-readable text summary."""
    lines = []
    lines.append("=" * 90)
    lines.append("MCRO PDF DIGITAL SIGNATURE EXTRACTION REPORT")
    lines.append(f"Generated: {master['run_timestamp_utc']}")
    lines.append(f"Source directory: {master['source_directory']}")
    lines.append(f"PDFs processed: {master['total_pdfs_processed']}")
    lines.append(f"Total signatures found: {master['total_signatures_found']}")
    lines.append("=" * 90)

    # Unique certificate summary
    all_certs = {}
    for pdf_rec in master["pdfs"]:
        for sig in pdf_rec["signatures"]:
            for cert in sig["certificates"]:
                fp = cert["fingerprints"]["sha256"]
                if fp not in all_certs:
                    all_certs[fp] = {
                        "cert": cert,
                        "seen_in": [],
                        "sig_types": set(),
                    }
                all_certs[fp]["seen_in"].append(Path(pdf_rec["file"]).name)
                all_certs[fp]["sig_types"].add(sig["metadata"].get("SubFilter", "unknown"))

    lines.append(f"\nUNIQUE CERTIFICATES FOUND: {len(all_certs)}")
    lines.append("-" * 90)

    for i, (fp, info) in enumerate(all_certs.items(), 1):
        c = info["cert"]
        subj = c["subject"]
        cn = subj.get("commonName", subj.get("Common Name", "N/A"))
        org = subj.get("organizationName", subj.get("Organization Name", ""))
        issuer_cn = c["issuer"].get("commonName", c["issuer"].get("Common Name", "N/A"))

        lines.append(f"\n  CERT #{i}")
        lines.append(f"    CN:           {cn}")
        if org:
            lines.append(f"    Org:          {org}")
        lines.append(f"    Issuer CN:    {issuer_cn}")
        lines.append(f"    Serial:       {c['serial_number_hex']}")
        lines.append(f"    Valid:        {c['not_before_utc']}  ->  {c['not_after_utc']}")
        lines.append(f"    Sig Algo:     {c['signature_algorithm']}")
        lines.append(f"    Key:          {c['public_key_algorithm']} {c['public_key_size_bits']}-bit")
        lines.append(f"    SHA-256:      {c['fingerprints']['sha256']}")
        lines.append(f"    SHA-1:        {c['fingerprints']['sha1']}")
        lines.append(f"    Self-signed:  {c['is_self_signed']}")

        # CRL / OCSP
        exts = c.get("extensions", {})
        crls = exts.get("crl_distribution_points", [])
        if crls:
            lines.append(f"    CRL:          {', '.join(crls)}")
        aia = exts.get("authority_information_access", {})
        if aia:
            for method, url in aia.items():
                lines.append(f"    AIA ({method}): {url}")

        lines.append(f"    Sig types:    {', '.join(info['sig_types'])}")
        lines.append(f"    Seen in {len(info['seen_in'])} PDF(s)")

    # Per-PDF detail
    lines.append("\n" + "=" * 90)
    lines.append("PER-PDF SIGNATURE DETAILS")
    lines.append("=" * 90)

    for pdf_rec in master["pdfs"]:
        fname = Path(pdf_rec["file"]).name
        lines.append(f"\n{'─' * 90}")
        lines.append(f"  FILE: {fname}")
        lines.append(f"  SHA-256: {pdf_rec['file_sha256']}")
        lines.append(f"  Size: {pdf_rec['file_size_bytes']:,} bytes")
        lines.append(f"  Signatures: {pdf_rec['total_signatures']}")

        if pdf_rec["errors"]:
            for err in pdf_rec["errors"]:
                lines.append(f"  [ERROR] {err}")

        for sig in pdf_rec["signatures"]:
            meta = sig["metadata"]
            lines.append(f"\n    SIG FIELD: {sig['field_name']}")
            lines.append(f"      Filter:    {meta.get('Filter', 'N/A')}")
            lines.append(f"      SubFilter: {meta.get('SubFilter', 'N/A')}")
            lines.append(f"      Reason:    {meta.get('Reason', 'N/A')}")
            lines.append(f"      Location:  {meta.get('Location', 'N/A')}")
            lines.append(f"      Timestamp: {meta.get('M', 'N/A')}")
            lines.append(f"      Signer:    {meta.get('Name', 'N/A')}")
            lines.append(f"      PKCS7 size: {sig.get('pkcs7_blob_size', 'N/A')} bytes")
            lines.append(f"      Certs in chain: {len(sig['certificates'])}")

            # CMS details
            cms = sig.get("cms_deep_parse")
            if cms and not cms.get("deep_parse_error"):
                lines.append(f"      CMS content type: {cms.get('content_type_name', 'N/A')}")
                lines.append(f"      CMS version: {cms.get('cms_version', 'N/A')}")
                lines.append(f"      Digest algos: {cms.get('digest_algorithms', [])}")
                for si_idx, si in enumerate(cms.get("signer_infos", [])):
                    lines.append(f"      Signer #{si_idx}:")
                    lines.append(f"        Digest algo: {si.get('digest_algorithm', 'N/A')}")
                    lines.append(f"        Sig algo:    {si.get('signature_algorithm', 'N/A')}")
                    if si.get("signer_serial_hex"):
                        lines.append(f"        Signer serial: {si['signer_serial_hex']}")
                    sa = si.get("signed_attributes", [])
                    if sa:
                        lines.append(f"        Signed attrs ({len(sa)}):")
                        for a in sa:
                            lines.append(f"          {a['name']}: {a.get('values', ['(complex)'])}")
                    ua = si.get("unsigned_attributes", [])
                    if ua:
                        lines.append(f"        Unsigned attrs ({len(ua)}):")
                        for a in ua:
                            if "timestamp_token" in a:
                                tst = a["timestamp_token"]
                                lines.append(f"          TIMESTAMP TOKEN:")
                                lines.append(f"            Time: {tst.get('gen_time_utc', 'N/A')}")
                                lines.append(f"            Policy: {tst.get('policy', 'N/A')}")
                                lines.append(f"            Hash algo: {tst.get('hash_algorithm', 'N/A')}")
                                lines.append(f"            TSA: {tst.get('tsa', 'N/A')}")
                            else:
                                lines.append(f"          {a['name']}: {a.get('values', ['(complex)'])}")

            for ci, cert in enumerate(sig["certificates"]):
                cn = cert["subject"].get("commonName", cert["subject"].get("Common Name", "N/A"))
                lines.append(f"      Cert[{ci}]: {cn}  (valid {cert['not_before_utc'][:10]} -> {cert['not_after_utc'][:10]})")

            if sig["errors"]:
                for err in sig["errors"]:
                    lines.append(f"      [ERROR] {err}")

    lines.append("\n" + "=" * 90)
    lines.append("END OF REPORT")
    lines.append("=" * 90)
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <pdf_directory> [output_directory]")
        sys.exit(1)

    src_dir = Path(sys.argv[1])
    if not src_dir.is_dir():
        print(f"Error: {src_dir} is not a directory")
        sys.exit(1)

    if len(sys.argv) >= 3:
        out_dir = Path(sys.argv[2])
    else:
        out_dir = src_dir / "sig_extraction_output"

    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "certificates").mkdir(exist_ok=True)
    (out_dir / "pkcs7_blobs").mkdir(exist_ok=True)

    # Find all PDFs
    pdf_files = sorted(src_dir.glob("*.pdf")) + sorted(src_dir.glob("*.PDF"))
    # Deduplicate (case-insensitive filesystems)
    seen = set()
    unique_pdfs = []
    for p in pdf_files:
        rp = str(p.resolve())
        if rp not in seen:
            seen.add(rp)
            unique_pdfs.append(p)

    print(f"Found {len(unique_pdfs)} PDF files in {src_dir}")
    print(f"Output directory: {out_dir}\n")

    master = {
        "run_timestamp_utc": datetime.now(timezone.utc).isoformat(),
        "source_directory": str(src_dir.resolve()),
        "output_directory": str(out_dir.resolve()),
        "total_pdfs_processed": 0,
        "total_signatures_found": 0,
        "pdfs": [],
    }

    for i, pdf_path in enumerate(unique_pdfs, 1):
        print(f"[{i}/{len(unique_pdfs)}] Processing: {pdf_path.name} ... ", end="", flush=True)
        try:
            pdf_result = process_pdf(str(pdf_path), out_dir)
            master["pdfs"].append(pdf_result)
            master["total_signatures_found"] += pdf_result["total_signatures"]
            print(f"{pdf_result['total_signatures']} signature(s) found")

            # Write per-PDF JSON
            per_pdf_json = out_dir / f"{pdf_path.stem}__signatures.json"
            with open(per_pdf_json, "w") as f:
                json.dump(pdf_result, f, indent=2, default=str)

        except Exception as e:
            print(f"FATAL ERROR: {e}")
            master["pdfs"].append({
                "file": str(pdf_path),
                "error": traceback.format_exc(),
            })

    master["total_pdfs_processed"] = len(unique_pdfs)

    # Write master JSON
    master_json_path = out_dir / "master_signature_index.json"
    with open(master_json_path, "w") as f:
        json.dump(master, f, indent=2, default=str)
    print(f"\nMaster index:  {master_json_path}")

    # Write summary report
    summary = build_summary(master)
    summary_path = out_dir / "signature_extraction_report.txt"
    summary_path.write_text(summary)
    print(f"Summary report: {summary_path}")

    # Quick stats
    print(f"\n{'=' * 60}")
    print(f"PDFs processed:      {master['total_pdfs_processed']}")
    print(f"Signatures found:    {master['total_signatures_found']}")
    certs_exported = len(list((out_dir / "certificates").glob("*.cer")))
    print(f"Certificates exported: {certs_exported}")
    print(f"PKCS7 blobs exported:  {len(list((out_dir / 'pkcs7_blobs').glob('*.p7b')))}")
    print(f"{'=' * 60}")


if __name__ == "__main__":
    main()
