#!/usr/bin/env bash
# Firma digital incorporada al PDF. No exporta la clave privada del token.
# Uso: bash firma_PDF_sinsello_multiple.sh *.pdf
# Salidas: NOMBRE_firmado.pdf en la carpeta de ejecución. Un PIN por lote.
# Preparación, una vez: bash firma_PDF_sinsello_multiple.sh --instalar
set +x
set -euo pipefail
umask 077
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
VENV="$SCRIPT_DIR/.venv-firma-pdf"
if [[ ${1:-} == --instalar ]]; then
    [[ $# == 1 ]] || { echo 'Uso: bash firma_PDF_sinsello_multiple.sh --instalar' >&2; exit 1; }
    python3 -m venv "$VENV"
    "$VENV/bin/python" -m pip install 'pyHanko[pkcs11]==0.31.0'
    printf '\nDependencias instaladas. Ya podés firmar.\n'
    exit 0
fi
PYTHON_BIN=${FIRMA_PYTHON:-"$VENV/bin/python"}
if [[ ! -x $PYTHON_BIN ]]; then
    printf 'Primero ejecutá: bash "%s" --instalar\n' "${BASH_SOURCE[0]}" >&2
    exit 1
fi
"$PYTHON_BIN" -c 'from importlib.metadata import version; import pkcs11; assert version("pyHanko") == "0.31.0"' \
    || { echo 'Dependencias ausentes o versión distinta. Ejecutá --instalar.' >&2; exit 1; }
exec "$PYTHON_BIN" - "$@" <<'PYTHON_FIRMA_PDF'
import argparse
import getpass
import hashlib
import logging
import os
from pathlib import Path
import re
import sys
import tempfile
import uuid
from datetime import datetime, timezone

import pkcs11
from asn1crypto import x509
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.sign import fields, signers
from pyhanko.sign.pkcs11 import PKCS11Signer
from pyhanko.sign.validation import validate_pdf_signature, validate_pdf_timestamp
from pyhanko.sign.validation.settings import KeyUsageConstraints
from pyhanko.sign.validation.status import SignatureCoverageLevel
from pyhanko_certvalidator import ValidationContext


def read_pdf(stream):
    """Habilita compatibilidad solo cuando se detectan xrefs híbridas."""
    stream.seek(0)
    reader = PdfFileReader(stream, strict=True)
    if reader.xrefs.hybrid_xrefs_present:
        # pyHanko exige strict=False tanto para firmar como para validar híbridos.
        # Se mantienen validación criptográfica, DocMDP/FieldMDP y comparación de bytes.
        stream.seek(0)
        reader = PdfFileReader(stream, strict=False)
    return reader


def check_signatures(reader):
    """Control criptográfico y de modificaciones; NO establece confianza en una AC."""
    results = []
    for sig in reader.embedded_signatures:
        # Anclar aquí permite revisar la integridad aun con una AC privada/desconocida.
        # NO se usa 'trusted' ni se presenta esto como validación de confianza/revocación.
        vc = ValidationContext(trust_roots=[sig.signer_cert], allow_fetching=False,
                               revocation_mode='soft-fail')
        if sig.sig_object.get('/Type') == '/DocTimeStamp':
            status = validate_pdf_timestamp(sig, validation_context=vc)
        else:
            status = validate_pdf_signature(
                sig, signer_validation_context=vc, ts_validation_context=vc,
                key_usage_settings=KeyUsageConstraints())
        if not status.intact or not status.valid:
            raise ValueError(f'Firma inválida o alterada: {sig.field_name}.')
        if status.coverage not in (SignatureCoverageLevel.ENTIRE_REVISION,
                                   SignatureCoverageLevel.ENTIRE_FILE):
            raise ValueError(f'Cobertura no segura: {sig.field_name}.')
        if status.docmdp_ok is not True:
            raise ValueError(f'Modificaciones incompatibles o no verificables: {sig.field_name}.')
        if status.modification_level is not None and status.modification_level.value > 2:
            raise ValueError(f'Cambios previos no admitidos: {sig.field_name}.')
        results.append((sig.field_name, hashlib.sha256(sig.signed_data.dump()).hexdigest()))
    return results


def select_field(reader, requested):
    all_fields = list(fields.enumerate_sig_fields(reader))
    empty = [name for name, value, _ in all_fields if value is None]
    signed = list(reader.embedded_signatures)
    restricted = any(s.docmdp_level is not None for s in signed)
    for sig in signed:
        if sig.docmdp_level == fields.MDPPerm.NO_CHANGES:
            raise ValueError('El PDF prohíbe cambios (DocMDP=1). No se puede agregar una firma.')
    if requested:
        name = requested
        if any(n == name and value is not None for n, value, _ in all_fields):
            raise ValueError(f'El campo {name} ya está firmado.')
    elif len(empty) == 1:
        name = empty[0]
    elif restricted:
        raise ValueError('PDF con restricciones: indicá con --campo un campo de firma vacío existente.')
    else:
        name = 'Firma_' + uuid.uuid4().hex
    existing = name in empty
    if restricted and not existing:
        raise ValueError('En un PDF con restricciones solo se permite usar un campo de firma vacío existente.')
    for sig in signed:
        if sig.fieldmdp is not None and sig.fieldmdp.is_locked(name):
            raise ValueError(f'Una firma anterior bloquea el campo {name}.')
    return name, existing


def sign_incrementally(source, output, signer, requested=None, reason=None):
    """No publica la salida hasta verificar original, firmas previas y nueva firma."""
    source, output = Path(source), Path(output)
    if output.exists() or output.is_symlink():
        raise ValueError('La salida ya existe; no se sobrescribe.')
    with source.open('rb') as original:
        reader = read_pdf(original)
        if reader.security_handler is not None:
            raise ValueError('PDF cifrado: esta versión no admite archivos protegidos con contraseña.')
        before = check_signatures(reader)
        name, existing = select_field(reader, requested)
        print(f'Firmas previas verificadas: {len(before)}. Campo: {name}', flush=True)
        fd, tmp = tempfile.mkstemp(prefix='.firma-pdf-', suffix='.pdf', dir=output.parent)
        try:
            with os.fdopen(fd, 'w+b') as target:
                original.seek(0)
                writer = IncrementalPdfFileWriter(original, prev=reader, strict=reader.strict)
                meta = signers.PdfSignatureMetadata(
                    field_name=name, md_algorithm='sha256', certify=False,
                    subfilter=fields.SigSeedSubFilter.PADES, reason=reason)
                signers.PdfSigner(meta, signer=signer).sign_pdf(
                    writer, existing_fields_only=existing, output=target)
                target.flush()
                # Prefijo idéntico: ninguna reescritura de bytes del PDF original.
                original.seek(0)
                target.seek(0)
                while True:
                    chunk = original.read(1024 * 1024)
                    if not chunk:
                        break
                    if target.read(len(chunk)) != chunk:
                        raise ValueError('La salida no conserva los bytes originales.')
                target.seek(0)
                after_reader = read_pdf(target)
                after = check_signatures(after_reader)
                if len(after) != len(before) + 1 or after[:len(before)] != before:
                    raise ValueError('Cambió una firma anterior o falta la nueva firma.')
                new = after_reader.embedded_signatures[-1]
                if new.field_name != name or new.signer_cert.dump() != signer.signing_cert.dump():
                    raise ValueError('La firma nueva no corresponde al certificado seleccionado.')
                if new.evaluate_signature_coverage() != SignatureCoverageLevel.ENTIRE_FILE:
                    raise ValueError('La firma nueva no cubre todo el PDF final.')
                os.fsync(target.fileno())
            # Publicación atómica sin sobreescritura; temporal en el mismo filesystem.
            os.link(tmp, output)
        finally:
            os.unlink(tmp)
    print(f'PDF firmado: {output}\nFirmas conservadas: {len(before)}; nueva firma: {name}.')
    print('Integridad verificada. Confianza en la AC y revocación se verifican aparte en el lector PDF.')


def safe_text(value):
    return ''.join(c if c.isprintable() else ' ' for c in str(value))


def choose(items, describe, prompt):
    # stdin contiene el Python incorporado; las respuestas se leen de /dev/tty.
    with open('/dev/tty', 'w') as out, open('/dev/tty', 'r') as inp:
        for number, item in enumerate(items, 1):
            print(f'{number}. {describe(item)}', file=out)
        while True:
            print(f'{prompt} (0 para cancelar): ', end='', file=out, flush=True)
            answer = inp.readline()
            if not answer or answer.strip() == '0':
                raise KeyboardInterrupt
            try:
                index = int(answer.strip())
            except ValueError:
                index = -1
            if 1 <= index <= len(items):
                return items[index - 1]
            print('Elegí uno de los números de la lista.', file=out)


def eligible_certificates(session, now):
    cert_objects = list(session.get_objects({pkcs11.Attribute.CLASS: pkcs11.ObjectClass.CERTIFICATE}))
    keys = list(session.get_objects({pkcs11.Attribute.CLASS: pkcs11.ObjectClass.PRIVATE_KEY}))
    key_counts = {}
    sign_ids = set()
    for key in keys:
        ident = bytes(key[pkcs11.Attribute.ID])
        key_counts[ident] = key_counts.get(ident, 0) + 1
        if key[pkcs11.Attribute.SIGN]:
            sign_ids.add(ident)
    records = []
    counts = {}
    for obj in cert_objects:
        ident = bytes(obj[pkcs11.Attribute.ID])
        counts[ident] = counts.get(ident, 0) + 1
        cert = x509.Certificate.load(bytes(obj[pkcs11.Attribute.VALUE]))
        records.append((ident, cert))
    candidates = []
    for ident, cert in records:
        if not ident or counts[ident] != 1 or key_counts.get(ident) != 1 or ident not in sign_ids:
            continue
        bc = cert.basic_constraints_value
        if bc is not None and bc.native['ca']:
            continue
        validity = cert['tbs_certificate']['validity']
        if not validity['not_before'].native <= now <= validity['not_after'].native:
            continue
        ku = cert.key_usage_value
        if ku is not None and not ({'digital_signature', 'non_repudiation'} & ku.native):
            continue
        name = cert.subject.native.get('common_name') or cert.subject.human_friendly
        candidates.append((ident, cert, safe_text(name), validity['not_after'].native))
    candidates.sort(key=lambda item: (item[2].casefold(), item[3], item[0]))
    return candidates, [cert for _, cert in records
                        if cert.basic_constraints_value is not None
                        and cert.basic_constraints_value.native['ca']]


def prepare_batch(inputs):
    import glob
    jobs, skipped, errors = [], [], []
    seen = set()
    for pattern in inputs:
        paths = [pattern] if Path(pattern).exists() else sorted(glob.glob(pattern))
        if not paths:
            errors.append((pattern, 'No existe o el patrón no coincide con archivos.'))
        for raw in paths:
            source = Path(raw)
            identity = source.resolve()
            if identity in seen:
                continue
            seen.add(identity)
            if not source.is_file() or source.suffix.lower() != '.pdf':
                errors.append((str(source), 'La entrada no es un archivo PDF.'))
                continue
            if source.stem.lower().endswith(('_firmado', '_firmado_sin_sello')):
                skipped.append((str(source), 'Ya tiene un sufijo de salida del firmador.'))
                continue
            output = Path.cwd() / (source.stem + '_firmado.pdf')
            if output.exists() or output.is_symlink():
                skipped.append((str(source), f'La salida ya existe: {output}'))
                continue
            if any(previous_output == output for _, previous_output in jobs):
                errors.append((str(source), f'Otro PDF del lote genera la misma salida: {output}'))
                continue
            jobs.append((source, output))
    return jobs, skipped, errors


"""Detección PKCS#11 sin autenticación; los sondeos se aíslan por proceso."""
import json as _td_json
import subprocess as _td_subprocess
import os as _td_os
import sys as _td_sys
from pathlib import Path as _td_Path

_TD_LIBRARIES = {}
_TD_PROBE = r'''
import json, sys, pkcs11
lib = pkcs11.lib(sys.argv[1])
records = []
for slot in lib.get_slots(token_present=True):
    try:
        token = slot.get_token()
        def text(value):
            if isinstance(value, bytes): value = value.decode('utf-8', 'replace')
            return str(value).strip().strip('\x00')
        records.append(dict(slot=int(slot.slot_id), label=text(token.label),
            serial=text(token.serial), manufacturer=text(token.manufacturer_id),
            model=text(token.model)))
    except pkcs11.PKCS11Error:
        continue
print('PFSI_TOKENS=' + json.dumps(records))
'''


def token_module_paths():
    """MODULE conserva el comportamiento de selección exclusiva anterior."""
    exclusive = _td_os.environ.get('MODULE', '').strip()
    if exclusive:
        candidates = [exclusive]
    else:
        candidates = [p for p in _td_os.environ.get('PKCS11_MODULES', '').split(_td_os.pathsep) if p]
        config = _td_Path(_td_os.environ.get('XDG_CONFIG_HOME', str(_td_Path.home()/'.config')))/'pfsi'/'pkcs11.json'
        if config.exists():
            try:
                data = _td_json.loads(config.read_text())
                extra = data['modules']
                if not isinstance(extra, list) or not all(isinstance(p, str) and p for p in extra):
                    raise ValueError('modules debe ser una lista de rutas')
                candidates.extend(extra)
            except (ValueError, KeyError, TypeError, OSError) as exc:
                raise ValueError(f'Configuración PKCS#11 inválida: {config}: {exc}') from exc
        # Nombres habituales; otras rutas se agregan explícitamente en el JSON.
        roots = ['/usr/lib', '/usr/local/lib', '/usr/lib64', '/usr/lib/x86_64-linux-gnu',
                 '/usr/lib/pkcs11', '/usr/lib/x86_64-linux-gnu/pkcs11']
        for name in ['libeToken.so', 'libIDPrimePKCS11.so', 'libcryptoide_pkcs11.so',
                     'liblm_cryptoide_pkcs11.so', 'libcastle.so', 'libcastle.so.1.0.0',
                     'opensc-pkcs11.so']:
            candidates.extend(str(_td_Path(root)/name) for root in roots if (_td_Path(root)/name).is_file())
    unique = []
    seen = set()
    for item in candidates:
        path = _td_Path(item).expanduser()
        if not path.is_absolute():
            raise ValueError(f'El controlador PKCS#11 debe tener ruta absoluta: {item}')
        path = str(path.resolve())
        if path not in seen:
            seen.add(path); unique.append(path)
    return unique


def _token_probe(module):
    # Los paquetes .deb agregan vendor a sys.path en su lanzador; transmitirlo
    # al intérprete hijo sin cargar una segunda versión de python-pkcs11.
    env = dict(_td_os.environ)
    env['PYTHONPATH'] = _td_os.pathsep.join(p for p in _td_sys.path if p and _td_os.path.isabs(p))
    result = _td_subprocess.run([_td_sys.executable, '-c', _TD_PROBE, module],
        text=True, capture_output=True, timeout=12, env=env)
    if result.returncode:
        raise RuntimeError(f'el controlador no pudo consultarse (código {result.returncode})')
    lines = [line for line in result.stdout.splitlines() if line.startswith('PFSI_TOKENS=')]
    if not lines:
        raise RuntimeError('el controlador no devolvió un inventario válido')
    return _td_json.loads(lines[-1].split('=', 1)[1])


def discover_tokens():
    records, errors = [], []
    for module in token_module_paths():
        try:
            for record in _token_probe(module):
                record['module'] = module
                records.append(record)
        except (_td_subprocess.TimeoutExpired, OSError, RuntimeError, ValueError) as exc:
            message = 'tiempo de espera agotado' if isinstance(exc, _td_subprocess.TimeoutExpired) else str(exc)
            errors.append(f'{module}: {message}')
    # Dos bibliotecas pueden enumerar el mismo token. Mantener el primer
    # controlador (MODULE/configuración explícita, luego fabricante y OpenSC).
    # Sin serie fiable no fusionar: las etiquetas por defecto se repiten.
    unique = []
    seen = {}
    for record in records:
        serial = record['serial'].strip()
        identity = (serial, record['manufacturer'].strip(), record['model'].strip())
        reliable = bool(serial) and serial.casefold() not in {
            'unknown', 'none', 'n/a', '0', '00000000', '0000000000000000'}
        previous = seen.get(identity)
        if reliable and previous is not None and previous != record['module']:
            continue
        unique.append(record)
        if reliable:
            seen[identity] = record['module']
    records = unique
    for message in errors:
        print('Aviso PKCS#11: '+message, file=_td_sys.stderr)
    if not records:
        detail = '\n'.join(errors)
        raise ValueError('No se encontró ningún token utilizable. Instalá el controlador PKCS#11 '
            'del fabricante. Podés indicar su ruta absoluta mediante MODULE o configurar '
            '~/.config/pfsi/pkcs11.json.' + ('\n'+detail if detail else ''))
    return records


def token_choice_label(record):
    return (f"{record['label']} | {record['manufacturer']} | serie {record['serial']} | "
            f"{_td_Path(record['module']).name} | slot {record['slot']}")


def open_selected_token(record):
    import pkcs11
    module = record['module']
    lib = _TD_LIBRARIES.get(module)
    if lib is None:
        lib = pkcs11.lib(module)
        _TD_LIBRARIES[module] = lib
    for slot in lib.get_slots(token_present=True):
        if int(slot.slot_id) != record['slot']:
            continue
        token = slot.get_token()
        def text(value):
            if isinstance(value, bytes): value=value.decode('utf-8', 'replace')
            return str(value).strip().strip('\x00')
        if (text(token.serial), text(token.manufacturer_id), text(token.model), text(token.label)) != (
                record['serial'], record['manufacturer'], record['model'], record['label']):
            raise ValueError('El token cambió durante la selección. Volvé a elegirlo antes de ingresar el PIN.')
        return slot, token
    raise ValueError('El token seleccionado fue desconectado. Reconectalo y volvé a elegirlo.')


def main():
    parser = argparse.ArgumentParser(prog='firma_PDF_sinsello_multiple.sh',
        description='Firma varios PDF con una sola sesión del token; genera NOMBRE_firmado.pdf en la carpeta actual.')
    parser.add_argument('entradas', nargs='+', help='Archivos PDF o patrones como *.pdf')
    parser.add_argument('--campo', help='Campo vacío existente que se usará en cada PDF (opcional)')
    args = parser.parse_args()
    jobs, skipped, errors = prepare_batch(args.entradas)
    for source, message in skipped:
        print(f'OMITIDO: {source}: {message}', flush=True)
    ready = []
    for source, output in jobs:
        try:
            with source.open('rb') as inf:
                reader = read_pdf(inf)
                if reader.security_handler is not None:
                    raise ValueError('No se admiten PDF cifrados en esta versión.')
                check_signatures(reader)
                select_field(reader, args.campo)
            ready.append((source, output))
        except Exception as exc:
            errors.append((str(source), str(exc)))
    for source, message in errors:
        print(f'ERROR: {source}: {message}', file=sys.stderr, flush=True)
    if not ready:
        print(f'No hay PDF para firmar. Omitidos: {len(skipped)}; errores: {len(errors)}.')
        return 1 if errors else 0
    print(f'PDF preparados para firmar: {len(ready)}', flush=True)
    available = discover_tokens()
    record = available[0] if len(available) == 1 else choose(
        available, token_choice_label, 'Token y controlador')
    slot, token = open_selected_token(record)
    print(f'Token: {safe_text(token.label)} (slot {slot.slot_id})', flush=True)
    with open('/dev/tty', 'w') as tty:
        pin = getpass.getpass('PIN del token: ', stream=tty)
    if not pin:
        raise ValueError('PIN vacío.')
    session = token.open(user_pin=pin)
    del pin
    try:
        candidates, chain = eligible_certificates(session, datetime.now(timezone.utc))
        if not candidates:
            raise ValueError('No hay certificados vigentes de firma con una clave privada única asociada. '
                             'Se excluyen certificados vencidos, futuros, de CA, sin permiso de firma o con ID ambiguo.')
        print('Certificados vigentes con clave privada disponible:', flush=True)
        ident, cert, name, expires = choose(candidates,
            lambda item: f'{item[2]} | Vence: {item[3].astimezone().strftime("%d/%m/%Y %H:%M %Z")} | ID: {item[0].hex()}',
            'Certificado a utilizar')
        # Volver a comprobar la vigencia por si el menú permaneció abierto.
        if datetime.now(timezone.utc) > expires:
            raise ValueError('El certificado seleccionado acaba de vencer.')
        print(f'Firmando con: {name} | ID: {ident.hex()}', flush=True)
        signer = PKCS11Signer(session, signing_cert=cert, key_id=ident,
                              cert_id=ident, ca_chain=chain, embed_roots=True)
        signed = 0
        for source, output in ready:
            try:
                if datetime.now(timezone.utc) > expires:
                    raise ValueError('El certificado seleccionado venció durante el lote.')
                print(f'\nFirmando: {source}', flush=True)
                sign_incrementally(source, output, signer, args.campo)
                signed += 1
            except Exception as exc:
                errors.append((str(source), str(exc)))
                print(f'ERROR: {source}: {exc}', file=sys.stderr, flush=True)
        print(f'\nResumen: firmados {signed}; omitidos {len(skipped)}; errores {len(errors)}.')
        return 1 if errors else 0
    finally:
        session.close()


if __name__ == '__main__':
    logging.basicConfig(level=logging.ERROR)
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print('\nLote cancelado. Las salidas completadas anteriormente se conservan.', file=sys.stderr)
        sys.exit(130)
    except Exception as exc:
        print(f'ERROR: {exc}\nLos originales y las salidas completadas anteriormente se conservan.', file=sys.stderr)
        sys.exit(1)

PYTHON_FIRMA_PDF
