import sys
from contextlib import contextmanager
from enum import Enum, auto

import click
import logging
import getpass

from certvalidator import ValidationContext
from pyhanko.config import (
    init_validation_context_kwargs, parse_cli_config,
    CLIConfig, LogConfig, StdLogOutput, parse_logging_config
)
from pyhanko.pdf_utils import misc
from pyhanko.pdf_utils.config_utils import ConfigurationError

from pyhanko.sign import signers, pkcs11
from pyhanko.sign.general import (
    SigningError, SignatureValidationError, load_certs_from_pemder
)
from pyhanko.sign.signers import DEFAULT_SIGNER_KEY_USAGE
from pyhanko.sign.timestamps import HTTPTimeStamper
from pyhanko.sign import validation, beid, fields
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.pdf_utils.writer import copy_into_new_writer
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.pdf_utils import crypt
from pyhanko.sign.validation import (
    RevocationInfoValidationType
)
from pyhanko.stamp import QRStampStyle, text_stamp_file, qr_stamp_file
from pyhanko import __version__

__all__ = ['cli']


logger = logging.getLogger(__name__)


def logging_setup(log_configs):
    log_config: LogConfig
    for module, log_config in log_configs.items():
        cur_logger = logging.getLogger(module)
        cur_logger.setLevel(log_config.level)
        if isinstance(log_config.output, StdLogOutput):
            if StdLogOutput == StdLogOutput.STDOUT:
                handler = logging.StreamHandler(sys.stdout)
            else:
                handler = logging.StreamHandler()
        else:
            handler = logging.FileHandler(log_config.output)
        formatter = logging.Formatter(
            '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
        )
        handler.setFormatter(formatter)
        cur_logger.addHandler(handler)


@contextmanager
def pyhanko_exception_manager():
    msg = exception = None
    try:
        yield
    except click.ClickException:
        raise
    except misc.PdfReadError as e:
        exception = e
        msg = "Failed to read PDF file."
    except misc.PdfWriteError as e:
        exception = e
        msg = "Failed to write PDF file."
    except SigningError as e:
        exception = e
        msg = "Error raised while producing signed file."
    except Exception as e:
        exception = e
        msg = "Generic processing error."

    if exception is not None:
        logger.error(msg, exc_info=exception)
        raise click.ClickException(msg)


DEFAULT_CONFIG_FILE = 'pyhanko.yml'


class Ctx(Enum):
    SIG_META = auto()
    EXISTING_ONLY = auto()
    TIMESTAMP_URL = auto()
    CLI_CONFIG = auto()
    STAMP_STYLE = auto()
    STAMP_URL = auto()
    NEW_FIELD_SPEC = auto()
    PREFER_PSS = auto()


@click.group()
@click.version_option(prog_name='pyHanko', version=__version__)
@click.option('--config',
              help=(
                  'YAML file to load configuration from'
                  f'[default: {DEFAULT_CONFIG_FILE}]'
              ), required=False, type=click.File('r'))
@click.option('--verbose', help='Run in verbose mode', required=False,
              default=False, type=bool, is_flag=True)
@click.pass_context
def cli(ctx, config, verbose):
    config_text = None
    if config is None:
        try:
            with open(DEFAULT_CONFIG_FILE, 'r') as f:
                config_text = f.read()
            config = DEFAULT_CONFIG_FILE
        except FileNotFoundError:
            pass
        except IOError as e:
            raise click.ClickException(
                f"Failed to read {DEFAULT_CONFIG_FILE}: {str(e)}"
            )
    else:
        try:
            config_text = config.read()
        except IOError as e:
            raise click.ClickException(
                f"Failed to read configuration: {str(e)}",
            )

    ctx.ensure_object(dict)
    if config_text is not None:
        ctx.obj[Ctx.CLI_CONFIG] = cfg = parse_cli_config(config_text)
        log_config = cfg.log_config
    else:
        # grab the default
        log_config = parse_logging_config({})

    if verbose:
        # override the root logger's logging level, but preserve the output
        root_logger_config = log_config[None]
        log_config[None] = LogConfig(
            level=logging.DEBUG, output=root_logger_config.output
        )
    elif 'fontTools.subset' not in log_config:
        # the fontTools subsetter has a very noisy INFO log, so
        # set that one to WARNING by default
        log_config['fontTools.subset'] = LogConfig(
            level=logging.WARNING,
            # use the root logger's output settings to populate the default
            output=log_config[None].output
        )

    logging_setup(log_config)

    if verbose:
        logging.debug("Running with --verbose")
    if config_text is not None:
        logging.debug(f'Finished reading configuration from {config}.')
    else:
        logging.debug('There was no configuration to parse.')


@cli.group(help='sign PDF files', name='sign')
def signing():
    pass


readable_file = click.Path(exists=True, readable=True, dir_okay=False)


def _build_vc_kwargs(ctx, validation_context, trust,
                     trust_replace, other_certs, retroactive_revinfo,
                     allow_fetching=None):
    cli_config: CLIConfig = ctx.obj.get(Ctx.CLI_CONFIG, None)
    try:
        if validation_context is not None:
            if any((trust, other_certs)):
                raise click.ClickException(
                    "--validation-context is incompatible with --trust "
                    "and --other-certs"
                )
            # load the desired context from config
            if cli_config is None:
                raise click.ClickException("No config file specified.")
            try:
                result = cli_config.get_validation_context(
                    validation_context, as_dict=True
                )
            except ConfigurationError as e:
                msg = (
                    "Configuration problem. Are you sure that the validation "
                    f"context '{validation_context}' is properly defined in the"
                    " configuration file?"
                )
                logger.error(msg, exc_info=e)
                raise click.ClickException(msg)
        elif trust or other_certs:
            # load a validation profile using command line kwargs
            result = init_validation_context_kwargs(
                trust=trust, trust_replace=trust_replace,
                other_certs=other_certs,
                retroactive_revinfo=retroactive_revinfo
            )
        elif cli_config is not None:
            # load the default settings from the CLI config
            try:
                result = cli_config.get_validation_context(as_dict=True)
            except ConfigurationError as e:
                msg = (
                    "Failed to load default validation context."
                )
                logger.error(msg, exc_info=e)
                raise click.ClickException(msg)
        else:
            result = {}

        if allow_fetching is not None:
            result['allow_fetching'] = allow_fetching
        else:
            result.setdefault('allow_fetching', True)

        # allow CLI --retroactive-revinfo flag to override settings
        # if necessary
        if retroactive_revinfo:
            result['retroactive_revinfo'] = True
        return result
    except click.ClickException:
        raise
    except IOError as e:
        msg = "I/O problem while reading validation config"
        logger.error(msg, exc_info=e)
        raise click.ClickException(msg)
    except Exception as e:
        msg = "Generic processing problem while reading validation config"
        logger.error(msg, exc_info=e)
        raise click.ClickException(msg)


def _get_key_usage_settings(ctx, validation_context):
    cli_config: CLIConfig = ctx.obj.get(Ctx.CLI_CONFIG, None)
    if cli_config is None:
        return None

    # note: validation_context can be None, this triggers fallback to the
    # default validation context specified in the configuration file
    # If we add support for specifying key usage settings as CLI arguments,
    # using the same fallbacks as _build_cli_kwargs would probably be cleaner
    return cli_config.get_signer_key_usages(name=validation_context)


def trust_options(f):
    f = click.option(
        '--validation-context', help='use validation context from config',
        required=False, type=str
    )(f)
    f = click.option(
        '--trust', help='list trust roots (multiple allowed)',
        required=False, multiple=True, type=readable_file
    )(f)
    f = click.option(
        '--trust-replace',
        help='listed trust roots supersede OS-provided trust store',
        required=False, type=bool, is_flag=True, default=False,
        show_default=True
    )(f)
    f = click.option(
        '--other-certs', help='other certs relevant for validation',
        required=False, multiple=True, type=readable_file
    )(f)
    return f


def _select_style(ctx, style_name, url):
    try:
        cli_config: CLIConfig = ctx.obj[Ctx.CLI_CONFIG]
    except KeyError:
        if not style_name:
            return None
        raise click.ClickException(
            "Using stamp styles requires a configuration file "
            f"({DEFAULT_CONFIG_FILE} by default)."
        )
    try:
        style = cli_config.get_stamp_style(style_name)
    except ConfigurationError as e:
        msg = (
            "Configuration problem. Are you sure that the style "
            f"'{style_name}' is properly defined in the configuration file?"
        )
        logger.error(msg, exc_info=e)
        raise click.ClickException(msg)
    if url and not isinstance(style, QRStampStyle):
        raise click.ClickException(
            "The --stamp-url parameter is only meaningful for QR stamp styles."
        )
    elif not url and isinstance(style, QRStampStyle):
        raise click.ClickException(
            "QR stamp styles require the --stamp-url option."
        )

    return style


def _signature_status(ltv_profile, force_revinfo, soft_revocation_check,
                      pretty_print, vc_kwargs, key_usage_settings,
                      executive_summary, embedded_sig):
    try:
        if ltv_profile is None:
            if soft_revocation_check and force_revinfo:
                raise click.ClickException(
                    "--soft-revocation-check is incompatible with "
                    "--force-revinfo"
                )
            if force_revinfo:
                rev_mode = 'require'
            elif soft_revocation_check:
                rev_mode = 'soft-fail'
            else:
                rev_mode = 'hard-fail'
            vc_kwargs['revocation_mode'] = rev_mode
            vc = ValidationContext(**vc_kwargs)
            status = validation.validate_pdf_signature(
                embedded_sig, key_usage_settings=key_usage_settings,
                signer_validation_context=vc
            )
        else:
            status = validation.validate_pdf_ltv_signature(
                embedded_sig, ltv_profile,
                key_usage_settings=key_usage_settings,
                force_revinfo=force_revinfo,
                validation_context_kwargs=vc_kwargs
            )
        if executive_summary and not pretty_print:
            return 'VALID' if status.bottom_line else 'INVALID'
        elif pretty_print:
            return status.pretty_print_details()
        else:
            return status.summary()
    except validation.ValidationInfoReadingError as e:
        msg = (
            'An error occurred while parsing the revocation information '
            'for this signature: ' + str(e)
        )
        logger.error(msg)
        if pretty_print:
            return msg
        else:
            return 'REVINFO_FAILURE'
    except SignatureValidationError as e:
        msg = 'An error occurred while validating this signature: ' + str(e)
        logger.error(msg, exc_info=e)
        if pretty_print:
            return msg
        else:
            return 'INVALID'


# TODO add an option to do LTV, but guess the profile
@signing.command(name='validate', help='validate signatures')
@click.argument('infile', type=click.File('rb'))
@click.option('--executive-summary',
              help='only print final judgment on signature validity',
              type=bool, is_flag=True, default=False, show_default=True)
@click.option('--pretty-print',
              help='render a prettier summary for the signatures in the file',
              type=bool, is_flag=True, default=False, show_default=True)
@trust_options
@click.option('--ltv-profile',
              help='LTV signature validation profile',
              type=click.Choice(RevocationInfoValidationType.as_tuple()),
              required=False)
@click.option('--force-revinfo',
              help='Fail trust validation if a certificate has no known CRL '
                   'or OCSP endpoints.',
              type=bool, is_flag=True, default=False, show_default=True)
@click.option('--soft-revocation-check',
              help='Do not fail validation on revocation checking failures '
                   '(meaningless for LTV validation)',
              type=bool, is_flag=True, default=False, show_default=True)
@click.option('--no-revocation-check',
              help='Do not attempt to check revocation status '
                   '(meaningless for LTV validation)',
              type=bool, is_flag=True, default=False, show_default=True)
@click.option('--retroactive-revinfo',
              help='Treat revocation info as retroactively valid '
                   '(i.e. ignore thisUpdate timestamp)',
              type=bool, is_flag=True, default=False, show_default=True)
@click.option('--password', required=False, type=str,
              help='password to access the file (can also be read from stdin)')
@click.pass_context
def validate_signatures(ctx, infile, executive_summary,
                        pretty_print, validation_context, trust, trust_replace,
                        other_certs, ltv_profile, force_revinfo,
                        soft_revocation_check, no_revocation_check, password,
                        retroactive_revinfo):

    if no_revocation_check:
        soft_revocation_check = True

    if pretty_print and executive_summary:
        raise click.ClickException(
            "--pretty-print is incompatible with --executive-summary."
        )

    if ltv_profile is not None:
        ltv_profile = RevocationInfoValidationType(ltv_profile)

    vc_kwargs = _build_vc_kwargs(
        ctx, validation_context, trust, trust_replace, other_certs,
        retroactive_revinfo,
        allow_fetching=False if no_revocation_check else None
    )

    key_usage_settings = _get_key_usage_settings(ctx, validation_context)
    with pyhanko_exception_manager():
        r = PdfFileReader(infile)
        sh = r.security_handler
        if isinstance(sh, crypt.StandardSecurityHandler):
            if password is None:
                password = getpass.getpass(prompt='File password: ')
            auth_result = r.decrypt(password)
            if auth_result.status == crypt.AuthStatus.FAILED:
                raise click.ClickException("Password didn't match.")
        elif sh is not None:
            raise click.ClickException(
                "The CLI supports only password-based encryption when "
                "validating (for now)"
            )

        # function to filter out timestamps
        def is_sig(sig):
            try:
                return sig.sig_object['/Type'] == '/Sig'
            except KeyError:
                return True

        sigs = filter(is_sig, r.embedded_signatures)
        for ix, embedded_sig in enumerate(sigs):
            fingerprint: str = embedded_sig.signer_cert.sha256.hex()
            status_str = _signature_status(
                ltv_profile, force_revinfo, soft_revocation_check,
                pretty_print, vc_kwargs, key_usage_settings,
                executive_summary, embedded_sig
            )
            name = embedded_sig.field_name

            if pretty_print:
                header = f'Field {ix + 1}: {name}'
                line = '=' * len(header)
                print(line)
                print(header)
                print(line)
                print('\n\n' + status_str)
            else:
                print('%s:%s:%s' % (name, fingerprint, status_str))


@signing.command(name='list', help='list signature fields')
@click.argument('infile', type=click.File('rb'))
@click.option('--skip-status', help='do not print status', required=False,
              type=bool, is_flag=True, default=False, show_default=True)
def list_sigfields(infile, skip_status):

    with pyhanko_exception_manager():
        r = PdfFileReader(infile)
        field_info = fields.enumerate_sig_fields(r)
        for ix, (name, value, field_ref) in enumerate(field_info):
            if skip_status:
                print(name)
                continue
            print(f"{name}:{'EMPTY' if value is None else 'FILLED'}")


@signing.command(name='ltaupdate', help='update LTA timestamp')
@click.argument('infile', type=click.File('r+b'))
@click.option('--timestamp-url', help='URL for timestamp server',
              required=True, type=str, default=None)
@click.option('--retroactive-revinfo',
              help='Treat revocation info as retroactively valid '
                   '(i.e. ignore thisUpdate timestamp)',
              type=bool, is_flag=True, default=False, show_default=True)
@trust_options
@click.pass_context
def lta_update(ctx, infile, validation_context, trust, trust_replace,
               other_certs, timestamp_url, retroactive_revinfo):
    with pyhanko_exception_manager():
        vc_kwargs = _build_vc_kwargs(
            ctx, validation_context, trust, trust_replace, other_certs,
            retroactive_revinfo
        )
        timestamper = HTTPTimeStamper(timestamp_url)
        r = PdfFileReader(infile)
        signers.PdfTimeStamper(timestamper).update_archival_timestamp_chain(
            r, ValidationContext(**vc_kwargs)
        )


# TODO perhaps add an option here to fix the lack of a timestamp and/or
#  warn if none is present

@signing.command(name='ltvfix',
                 help='add revocation information for a signature to the '
                      'document security store')
@click.argument('infile', type=click.File('r+b'))
@click.option('--field', help='name of the signature field', required=True)
@click.option('--timestamp-url', help='URL for timestamp server',
              required=False, type=str, default=None)
@click.option('--apply-lta-timestamp',
              help='Apply a document timestamp after adding revocation info.',
              required=False, type=bool, default=False, is_flag=True,
              show_default=True)
@trust_options
@click.pass_context
def ltv_fix(ctx, infile, field, timestamp_url, apply_lta_timestamp,
            validation_context, trust_replace, trust, other_certs):
    if apply_lta_timestamp and not timestamp_url:
        raise click.ClickException(
            "Please specify a timestamp server using --timestamp-url."
        )

    vc_kwargs = _build_vc_kwargs(
        ctx, validation_context, trust, trust_replace, other_certs,
        retroactive_revinfo=False, allow_fetching=True
    )
    vc_kwargs['revocation_mode'] = 'hard-fail'
    r = PdfFileReader(infile)

    try:
        emb_sig = next(
            s for s in r.embedded_signatures
            if s.sig_object.get('/Type', None) == '/Sig'
            and s.field_name == field
        )
    except StopIteration:
        raise click.ClickException(
            f"Could not find a PDF signature labelled {field}."
        )

    output = validation.add_validation_info(
        emb_sig, ValidationContext(**vc_kwargs), in_place=True
    )

    if apply_lta_timestamp:
        timestamper = HTTPTimeStamper(timestamp_url)
        signers.PdfTimeStamper(timestamper).timestamp_pdf(
            IncrementalPdfFileWriter(output), signers.DEFAULT_MD,
            ValidationContext(**vc_kwargs), in_place=True
        )


@signing.group(name='addsig', help='add a signature')
@click.option('--field', help='name of the signature field', required=False)
@click.option('--name', help='explicitly specify signer name', required=False)
@click.option('--reason', help='reason for signing', required=False)
@click.option('--location', help='location of signing', required=False)
@click.option('--certify', help='add certification signature', required=False, 
              default=False, is_flag=True, type=bool, show_default=True)
@click.option('--existing-only', help='never create signature fields', 
              required=False, default=False, is_flag=True, type=bool, 
              show_default=True)
@click.option('--timestamp-url', help='URL for timestamp server',
              required=False, type=str, default=None)
@click.option('--use-pades', help='sign PAdES-style [level B/B-T/B-LT]',
              required=False, default=False, is_flag=True, type=bool,
              show_default=True)
@click.option('--prefer-pss', is_flag=True, default=False, type=bool,
              help='prefer RSASSA-PSS to PKCS#1 v1.5 padding, if available')
@click.option('--with-validation-info', help='embed revocation info',
              required=False, default=False, is_flag=True, type=bool,
              show_default=True)
@click.option(
    '--style-name', help='stamp style name for signature appearance',
    required=False, type=str
)
@click.option(
    '--stamp-url', help='QR code URL to use in QR stamp style',
    required=False, type=str
)
@trust_options
@click.option('--retroactive-revinfo',
              help='Treat revocation info as retroactively valid '
                   '(i.e. ignore thisUpdate timestamp)',
              type=bool, is_flag=True, default=False, show_default=True)
@click.pass_context
def addsig(ctx, field, name, reason, location, certify, existing_only,
           timestamp_url, use_pades, with_validation_info,
           validation_context, trust_replace, trust, other_certs,
           style_name, stamp_url, prefer_pss, retroactive_revinfo):
    ctx.obj[Ctx.EXISTING_ONLY] = existing_only or field is None
    ctx.obj[Ctx.TIMESTAMP_URL] = timestamp_url
    ctx.obj[Ctx.PREFER_PSS] = prefer_pss

    if use_pades:
        subfilter = fields.SigSeedSubFilter.PADES
    else:
        subfilter = fields.SigSeedSubFilter.ADOBE_PKCS7_DETACHED

    key_usage = DEFAULT_SIGNER_KEY_USAGE
    if with_validation_info:
        vc_kwargs = _build_vc_kwargs(
            ctx, validation_context, trust, trust_replace, other_certs,
            retroactive_revinfo, allow_fetching=True
        )
        vc = ValidationContext(**vc_kwargs)
        key_usage_sett = _get_key_usage_settings(ctx, validation_context)
        if key_usage_sett is not None and key_usage_sett.key_usage is not None:
            key_usage = key_usage_sett.key_usage
    else:
        vc = None
    field_name, new_field_spec = parse_field_location_spec(
        field, require_full_spec=False
    )
    ctx.obj[Ctx.SIG_META] = signers.PdfSignatureMetadata(
        field_name=field_name, location=location, reason=reason, name=name,
        certify=certify, subfilter=subfilter,
        embed_validation_info=with_validation_info,
        validation_context=vc, signer_key_usage=key_usage
    )
    ctx.obj[Ctx.NEW_FIELD_SPEC] = new_field_spec
    ctx.obj[Ctx.STAMP_STYLE] = _select_style(ctx, style_name, stamp_url)
    ctx.obj[Ctx.STAMP_URL] = stamp_url


def _open_for_signing(infile_path, signer_cert=None, signer_key=None):
    infile = open(infile_path, 'rb')
    writer = IncrementalPdfFileWriter(infile)

    # TODO make this an option higher up the tree
    # TODO mention filename in prompt
    if writer.prev.encrypted:
        sh = writer.prev.security_handler
        if isinstance(sh, crypt.StandardSecurityHandler):
            pdf_pass = getpass.getpass(
                prompt='Password for encrypted file \'%s\': ' % infile_path
            )
            writer.encrypt(pdf_pass)
        elif isinstance(sh, crypt.PubKeySecurityHandler) \
                and signer_key is not None:
            # attempt to decrypt using signer's credentials
            cred = crypt.SimpleEnvelopeKeyDecrypter(signer_cert, signer_key)
            logger.warning(
                "The file \'%s\' appears to be encrypted using public-key "
                "encryption. This is only partially supported in pyHanko's "
                "CLI. PyHanko will attempt to decrypt the document using the "
                "signer's public key, but be aware that using the same key "
                "for both signing and decryption is considered bad practice. "
                "Never use the same RSA key that you use to decrypt messages to"
                "sign hashes that you didn't compute yourself!" % infile_path
            )
            writer.encrypt_pubkey(cred)
        else:
            raise click.ClickException(
                "Input file appears to be encrypted, but appropriate "
                "credentials are not available."
            )
    return writer


def get_text_params(ctx):
    text_params = None
    stamp_url = ctx.obj[Ctx.STAMP_URL]
    if stamp_url is not None:
        text_params = {'url': stamp_url}
    return text_params


def addsig_simple_signer(signer: signers.SimpleSigner, infile_path, outfile,
                         timestamp_url, signature_meta, existing_fields_only,
                         style, text_params, new_field_spec):
    with pyhanko_exception_manager():
        if timestamp_url is not None:
            timestamper = HTTPTimeStamper(timestamp_url)
        else:
            timestamper = None
        writer = _open_for_signing(
            infile_path, signer_cert=signer.signing_cert,
            signer_key=signer.signing_key
        )

        generic_sign(
            writer=writer, outfile=outfile,
            signature_meta=signature_meta, signer=signer,
            timestamper=timestamper, style=style, new_field_spec=new_field_spec,
            existing_fields_only=existing_fields_only, text_params=text_params
        )


def generic_sign(*, writer, outfile, signature_meta, signer, timestamper,
                 style, new_field_spec, existing_fields_only, text_params):
    result = signers.PdfSigner(
        signature_meta, signer=signer, timestamper=timestamper,
        stamp_style=style, new_field_spec=new_field_spec
    ).sign_pdf(
        writer, existing_fields_only=existing_fields_only,
        appearance_text_params=text_params
    )

    buf = result.getbuffer()
    outfile.write(buf)
    buf.release()

    writer.prev.stream.close()
    outfile.close()


@addsig.command(name='pemder', help='read key material from PEM/DER files')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.File('wb'))
@click.option('--key', help='file containing the private key (PEM/DER)', 
              type=readable_file, required=True)
@click.option('--cert', help='file containing the signer\'s certificate '
              '(PEM/DER)', type=readable_file, required=True)
@click.option('--chain', type=readable_file, multiple=True,
              help='file(s) containing the chain of trust for the '
                   'signer\'s certificate (PEM/DER). May be '
                   'passed multiple times.')
# TODO allow reading the passphrase from a specific file descriptor
#  (for advanced scripting setups)
@click.option('--passfile', help='file containing the passphrase '
              'for the private key', required=False, type=click.File('rb'),
              show_default='stdin')
@click.pass_context
def addsig_pemder(ctx, infile, outfile, key, cert, chain, passfile):
    signature_meta = ctx.obj[Ctx.SIG_META]
    existing_fields_only = ctx.obj[Ctx.EXISTING_ONLY]
    timestamp_url = ctx.obj[Ctx.TIMESTAMP_URL]

    if passfile is None:
        passphrase = getpass.getpass(prompt='Key passphrase: ').encode('utf-8')
    else:
        passphrase = passfile.read()
        passfile.close()
    
    signer = signers.SimpleSigner.load(
        cert_file=cert, key_file=key, key_passphrase=passphrase,
        ca_chain_files=chain, prefer_pss=ctx.obj[Ctx.PREFER_PSS]
    )
    return addsig_simple_signer(
        signer, infile, outfile, timestamp_url=timestamp_url,
        signature_meta=signature_meta,
        existing_fields_only=existing_fields_only,
        style=ctx.obj[Ctx.STAMP_STYLE], text_params=get_text_params(ctx),
        new_field_spec=ctx.obj[Ctx.NEW_FIELD_SPEC]
    )


@addsig.command(name='pkcs12', help='read key material from a PKCS#12 file')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.File('wb'))
@click.argument('pfx', type=readable_file)
@click.option('--chain', type=readable_file, multiple=True,
              help='PEM/DER file(s) containing extra certificates to embed '
                   '(e.g. chain of trust not embedded in the PKCS#12 file)'
                   'May be passed multiple times.')
@click.option('--passfile', help='file containing the passphrase '
                                 'for the PKCS#12 file.', required=False,
              type=click.File('rb'),
              show_default='stdin')
@click.pass_context
def addsig_pkcs12(ctx, infile, outfile, pfx, chain, passfile):
    # TODO add sanity check in case the user gets the arg order wrong
    #  (now it fails with a gnarly DER decoding error, which is not very
    #  user-friendly)
    signature_meta = ctx.obj[Ctx.SIG_META]
    existing_fields_only = ctx.obj[Ctx.EXISTING_ONLY]
    timestamp_url = ctx.obj[Ctx.TIMESTAMP_URL]

    if passfile is None:
        passphrase = getpass.getpass(prompt='PKCS#12 passphrase: ')\
                        .encode('utf-8')
    else:
        passphrase = passfile.read()
        passfile.close()

    signer = signers.SimpleSigner.load_pkcs12(
        pfx_file=pfx, passphrase=passphrase, ca_chain_files=chain,
        prefer_pss=ctx.obj[Ctx.PREFER_PSS]
    )
    return addsig_simple_signer(
        signer, infile, outfile, timestamp_url=timestamp_url,
        signature_meta=signature_meta,
        existing_fields_only=existing_fields_only,
        style=ctx.obj[Ctx.STAMP_STYLE], text_params=get_text_params(ctx),
        new_field_spec=ctx.obj[Ctx.NEW_FIELD_SPEC]
    )


# TODO add options to specify extra certs to include

@addsig.command(name='pkcs11', help='use generic PKCS#11 device to sign')
@click.argument('infile', type=click.File('rb'))
@click.argument('outfile', type=click.File('wb'))
@click.option('--lib', help='path to PKCS#11 module',
              type=readable_file, required=True)
@click.option('--token-label', help='PKCS#11 token label', type=str,
              required=True)
@click.option('--cert-label', help='certificate label', type=str, required=True)
@click.option('--key-label', help='key label', type=str, required=False)
@click.option('--slot-no', help='specify PKCS#11 slot to use',
              required=False, type=int, default=None)
@click.option('--skip-user-pin', type=bool, show_default=True,
              default=False, required=False, is_flag=True,
              help='do not prompt for PIN (e.g. if the token has a PIN pad)')
@click.pass_context
def addsig_pkcs11(ctx, infile, outfile, lib, token_label,
                  cert_label, key_label, slot_no, skip_user_pin):
    signature_meta = ctx.obj[Ctx.SIG_META]
    existing_fields_only = ctx.obj[Ctx.EXISTING_ONLY]
    timestamp_url = ctx.obj[Ctx.TIMESTAMP_URL]

    if skip_user_pin:
        user_pin = None
    else:
        user_pin = getpass.getpass(prompt='PKCS#11 user PIN: ')

    session = pkcs11.open_pkcs11_session(
        lib_location=lib, slot_no=slot_no, token_label=token_label,
        user_pin=user_pin
    )
    if timestamp_url is not None:
        timestamper = HTTPTimeStamper(timestamp_url)
    else:
        timestamper = None

    signer = pkcs11.PKCS11Signer(
        session, cert_label=cert_label, key_label=key_label,
    )

    with pyhanko_exception_manager():
        generic_sign(
            writer=IncrementalPdfFileWriter(infile), outfile=outfile,
            signature_meta=signature_meta, signer=signer,
            timestamper=timestamper, style=ctx.obj[Ctx.STAMP_STYLE],
            new_field_spec=ctx.obj[Ctx.NEW_FIELD_SPEC],
            existing_fields_only=existing_fields_only,
            text_params=get_text_params(ctx)
        )


@addsig.command(name='beid', help='use Belgian eID to sign')
@click.argument('infile', type=click.File('rb'))
@click.argument('outfile', type=click.File('wb'))
@click.option('--lib', help='path to libbeidpkcs11 library file',
              type=readable_file, required=True)
@click.option('--use-auth-cert', type=bool, show_default=True,
              default=False, required=False, is_flag=True,
              help='use Authentication cert instead')
@click.option('--slot-no', help='specify PKCS#11 slot to use', 
              required=False, type=int, default=None)
@click.pass_context
def addsig_beid(ctx, infile, outfile, lib, use_auth_cert, slot_no):
    signature_meta = ctx.obj[Ctx.SIG_META]
    existing_fields_only = ctx.obj[Ctx.EXISTING_ONLY]
    timestamp_url = ctx.obj[Ctx.TIMESTAMP_URL]
    session = beid.open_beid_session(lib, slot_no=slot_no)
    if timestamp_url is not None:
        timestamper = HTTPTimeStamper(timestamp_url)
    else:
        timestamper = None

    signer = beid.BEIDSigner(session, use_auth_cert=use_auth_cert)

    with pyhanko_exception_manager():
        generic_sign(
            writer=IncrementalPdfFileWriter(infile), outfile=outfile,
            signature_meta=signature_meta, signer=signer,
            timestamper=timestamper, style=ctx.obj[Ctx.STAMP_STYLE],
            new_field_spec=ctx.obj[Ctx.NEW_FIELD_SPEC],
            existing_fields_only=existing_fields_only,
            text_params=get_text_params(ctx)
        )


def _index_page(page):
    try:
        page_ix = int(page)
        if not page_ix:
            raise ValueError
        if page_ix > 0:
            # subtract 1 from the total, since that's what people expect
            # when referring to a page index
            return page_ix - 1
        else:
            # keep negative indexes as-is.
            return page_ix
    except ValueError:
        raise click.ClickException(
            "Sig field parameter PAGE should be a nonzero integer, "
            "not %s." % page
        )


def parse_field_location_spec(spec, require_full_spec=True):
    if spec is None:
        if require_full_spec:
            raise click.ClickException(
                "A signature field spec was not provided."
            )
        return None, None
    try:
        page, box, name = spec.split('/')
    except ValueError:
        if require_full_spec:
            raise click.ClickException(
                "Sig field spec should be of the form PAGE/X1,Y1,X2,Y2/NAME."
            )
        else:
            # interpret the entire string as a field name
            return spec, None

    page_ix = _index_page(page)

    try:
        x1, y1, x2, y2 = map(int, box.split(','))
    except ValueError:
        raise click.ClickException(
            "Sig field parameters X1,Y1,X2,Y2 should be four integers."
        )

    return name, fields.SigFieldSpec(
        sig_field_name=name, on_page=page_ix, box=(x1, y1, x2, y2)
    )


@signing.command(
    name='addfields', help='add empty signature fields to a PDF field'
)
@click.argument('infile', type=click.File('rb'))
@click.argument('outfile', type=click.File('wb'))
@click.option('--field', metavar='PAGE/X1,Y1,X2,Y2/NAME', multiple=True,
              required=True)
def add_sig_field(infile, outfile, field):
    with pyhanko_exception_manager():
        writer = IncrementalPdfFileWriter(infile)

        for s in field:
            name, spec = parse_field_location_spec(s)
            assert spec is not None
            fields.append_signature_field(writer, spec)

        writer.write(outfile)
        infile.close()
        outfile.close()


# TODO: text_params support

@cli.command(help='stamp PDF files', name='stamp')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
@click.argument('x', type=int)
@click.argument('y', type=int)
@click.option(
    '--style-name', help='stamp style name for stamp appearance',
    required=False, type=str
)
@click.option(
    '--page', help='page on which the stamp should be applied',
    required=False, type=int, default=1, show_default=True
)
@click.option(
    '--stamp-url', help='QR code URL to use in QR stamp style',
    required=False, type=str
)
@click.pass_context
def stamp(ctx, infile, outfile, x, y, style_name, page, stamp_url):
    with pyhanko_exception_manager():
        stamp_style = _select_style(ctx, style_name, stamp_url)
        page_ix = _index_page(page)
        if stamp_url:
            qr_stamp_file(
                infile, outfile, stamp_style, dest_page=page_ix, x=x, y=y,
                url=stamp_url
            )
        else:
            text_stamp_file(
                infile, outfile, stamp_style, dest_page=page_ix, x=x, y=y
            )


@cli.command(help='encrypt PDF files (AES-256 only)', name='encrypt')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
@click.option(
    '--password', help='password to encrypt the file with', required=False,
    type=str
)
@click.option(
    '--recipient', required=False, multiple=True,
    help='certificate(s) corresponding to entities that '
         'can decrypt the output file',
    type=click.Path(readable=True, dir_okay=False)
)
def encrypt_file(infile, outfile, password, recipient):
    if password and recipient:
        raise click.ClickException(
            "Specify either a password or a list of recipients."
        )
    elif not password and not recipient:
        password = getpass.getpass(prompt='Output file password: ')

    recipient_certs = None
    if recipient:
        recipient_certs = list(
            load_certs_from_pemder(cert_files=recipient)
        )

    with pyhanko_exception_manager():
        with open(infile, 'rb') as inf:
            r = PdfFileReader(inf)
            w = copy_into_new_writer(r)

            if recipient_certs:
                w.encrypt_pubkey(recipient_certs)
            else:
                w.encrypt(owner_pass=password)

            with open(outfile, 'wb') as outf:
                w.write(outf)


@cli.group(help='decrypt PDF files (any standard PDF encryption scheme)',
           name='decrypt')
def decrypt():
    pass


decrypt_force_flag = click.option(
    '--force', help='ignore access restrictions (use at your own risk)',
    required=False, type=bool, is_flag=True, default=False
)


@decrypt.command(help='decrypt using password', name='password')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
@click.option(
    '--password', help='password to decrypt the file with', required=False,
    type=str
)
@decrypt_force_flag
def decrypt_with_password(infile, outfile, password, force):
    with pyhanko_exception_manager():
        with open(infile, 'rb') as inf:
            r = PdfFileReader(inf)
            if r.security_handler is None:
                raise click.ClickException("File is not encrypted.")
            if not password:
                password = getpass.getpass(prompt='File password: ')
            auth_result = r.decrypt(password)
            if auth_result.status == crypt.AuthStatus.USER and not force:
                raise click.ClickException(
                    "Password specified was the user password, not "
                    "the owner password. Pass --force to decrypt the "
                    "file anyway."
                )
            elif auth_result.status == crypt.AuthStatus.FAILED:
                raise click.ClickException("Password didn't match.")
            w = copy_into_new_writer(r)
            with open(outfile, 'wb') as outf:
                w.write(outf)


@decrypt.command(help='decrypt using private key (PEM/DER)', name='pemder')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
@click.option('--key', type=readable_file, required=True,
              help='file containing the recipient\'s private key (PEM/DER)')
@click.option('--cert', help='file containing the recipient\'s certificate '
                             '(PEM/DER)', type=readable_file, required=True)
@click.option('--passfile', required=False, type=click.File('rb'),
              help='file containing the passphrase for the private key',
              show_default='stdin')
@decrypt_force_flag
def decrypt_with_pemder(infile, outfile, key, cert, passfile, force):
    if passfile is None:
        passphrase = getpass.getpass(prompt='Key passphrase: ').encode('utf-8')
    else:
        passphrase = passfile.read()
        passfile.close()
    sedk = crypt.SimpleEnvelopeKeyDecrypter.load(
        key, cert, key_passphrase=passphrase
    )

    _decrypt_pubkey(sedk, infile, outfile, force)


def _decrypt_pubkey(sedk: crypt.SimpleEnvelopeKeyDecrypter, infile, outfile,
                    force):
    with pyhanko_exception_manager():
        with open(infile, 'rb') as inf:
            r = PdfFileReader(inf)
            if r.security_handler is None:
                raise click.ClickException("File is not encrypted.")
            if not isinstance(r.security_handler, crypt.PubKeySecurityHandler):
                raise click.ClickException(
                    "File was not encrypted with a public-key security handler."
                )
            auth_result = r.decrypt_pubkey(sedk)
            if auth_result.status == crypt.AuthStatus.USER:
                # TODO read 2nd bit of perms in CMS enveloped data
                #  is the one indicating that change of encryption is OK
                if not force:
                    raise click.ClickException(
                        "Change of encryption is typically not allowed with "
                        "user access. Pass --force to decrypt the file anyway."
                    )
            elif auth_result.status == crypt.AuthStatus.FAILED:
                raise click.ClickException("Failed to decrypt the file.")
            w = copy_into_new_writer(r)
            with open(outfile, 'wb') as outf:
                w.write(outf)


@decrypt.command(help='decrypt using private key (PKCS#12)', name='pkcs12')
@click.argument('infile', type=readable_file)
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
@click.argument('pfx', type=readable_file)
@click.option('--passfile', required=False, type=click.File('rb'),
              help='file containing the passphrase for the PKCS#12 file',
              show_default='stdin')
@decrypt_force_flag
def decrypt_with_pkcs12(infile, outfile, pfx, passfile, force):
    if passfile is None:
        passphrase = getpass.getpass(prompt='Key passphrase: ').encode('utf-8')
    else:
        passphrase = passfile.read()
        passfile.close()
    sedk = crypt.SimpleEnvelopeKeyDecrypter.load_pkcs12(
        pfx, passphrase=passphrase
    )

    _decrypt_pubkey(sedk, infile, outfile, force)
