# Copyright (c) 2024 Yubico AB
# All rights reserved.
#
#   Redistribution and use in source and binary forms, with or
#   without modification, are permitted provided that the following
#   conditions are met:
#
#    1. Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#    2. Redistributions in binary form must reproduce the above
#       copyright notice, this list of conditions and the following
#       disclaimer in the documentation and/or other materials provided
#       with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

import logging
import sys
from typing import Any

import click
from cryptography import x509
from cryptography.hazmat.primitives import serialization

from yubikit.core.smartcard import SW, ApduError, SmartCardConnection
from yubikit.core.smartcard.scp import (
    KeyRef,
    Scp03KeyParams,
    Scp11KeyParams,
    ScpKid,
    StaticKeys,
)
from yubikit.management import CAPABILITY
from yubikit.securitydomain import SecurityDomainSession

from ..util import (
    InvalidPasswordError,
    parse_certificates,
    parse_private_key,
)
from .util import (
    CliFail,
    HexIntParamType,
    click_callback,
    click_force_option,
    click_group,
    click_postpone_execution,
    click_prompt,
    get_scp_params,
    log_or_echo,
    organize_scp11_certificates,
    pretty_print,
)

logger = logging.getLogger(__name__)


@click_group(
    "sd", connections=[SmartCardConnection], hidden="--full-help" not in sys.argv
)
@click.pass_context
@click_postpone_execution
def securitydomain(ctx):
    """
    Manage the Security Domain application, which holds keys for SCP.
    """

    dev = ctx.obj["device"]
    conn = dev.open_connection(SmartCardConnection)
    ctx.call_on_close(conn.close)

    session = SecurityDomainSession(conn)
    scp_params = get_scp_params(ctx, CAPABILITY(-1), conn)

    if scp_params:
        session.authenticate(scp_params)

    ctx.obj["authenticated"] = (
        isinstance(scp_params, Scp03KeyParams)
        or isinstance(scp_params, Scp11KeyParams)
        and scp_params.ref.kid == ScpKid.SCP11a
    )

    ctx.obj["session"] = session


@securitydomain.command()
@click.pass_context
def info(ctx):
    """
    List keys in the Security Domain of the YubiKey.
    """
    sd = ctx.obj["session"]
    data: list[Any] = []
    cas = sd.get_supported_ca_identifiers()
    for ref in sd.get_key_information().keys():
        if ref.kid == 1:  # SCP03
            data.append(
                {
                    f"SCP03 (KID=0x01-0x03, KVN=0x{ref.kvn:02X})": [
                        "Default key set" if ref.kvn == 0xFF else "Imported key set"
                    ]
                }
            )
        elif ref.kid in (2, 3):  # SCP03 always in full key sets
            continue
        else:  # SCP11
            inner: dict[str, Any] = {}
            if ref in cas:
                inner["CA Key Identifier"] = ":".join(f"{b:02X}" for b in cas[ref])
            try:
                inner["Certificate chain"] = [
                    c.subject.rfc4514_string() for c in sd.get_certificate_bundle(ref)
                ]
            except ApduError:
                pass
            try:
                name = ScpKid(ref.kid).name
            except ValueError:
                name = "SCP11 OCE CA"
            data.append({f"{name} (KID=0x{ref.kid:02X}, KVN=0x{ref.kvn:02X})": inner})

    click.echo("\n".join(pretty_print({"SCP keys": data})))


@securitydomain.command()
@click.pass_context
@click_force_option
def reset(ctx, force):
    """
    Reset all Security Domain data.

    This action will wipe all keys and restore factory settings for
    the Security Domain on the YubiKey.
    """
    if "scp" in ctx.obj:
        raise CliFail("Reset must be performed without an active SCP session.")

    force or click.confirm(
        "WARNING! This will delete all stored Security Domain data and restore factory "
        "settings. Proceed?",
        abort=True,
        err=True,
    )

    click.echo("Resetting Security Domain data...")
    ctx.obj["session"].reset()

    click.echo(
        "Reset complete. Security Domain data has been cleared from the YubiKey."
    )
    click.echo("Your YubiKey now has the default SCP key set")


@securitydomain.group()
def keys():
    """Manage SCP keys."""


def _require_auth(ctx):
    if not ctx.obj["authenticated"]:
        raise CliFail("This command requires authentication, invoke ykman with --scp.")


def _fname(fobj):
    return getattr(fobj, "name", fobj)


@click_callback()
def click_parse_scp_ref(ctx, param, val):
    try:
        return KeyRef(*val)
    except AttributeError:
        raise ValueError(val)


class ScpKidParamType(HexIntParamType):
    name = "kid"

    def convert(self, value, param, ctx):
        if isinstance(value, int):
            return value
        try:
            name = value.upper()[:-1] + value[-1].lower()
            return ScpKid[name]
        except KeyError:
            try:
                if value.lower().startswith("0x"):
                    return int(value[2:], 16)
                if ":" in value:
                    return int(value.replace(":", ""), 16)
                return int(value)
            except ValueError:
                self.fail(f"{value!r} is not a valid integer", param, ctx)


click_key_argument = click.argument(
    "key",
    metavar="KID KVN",
    type=(ScpKidParamType(), HexIntParamType()),
    callback=click_parse_scp_ref,
)


@keys.command("generate")
@click.pass_context
@click_key_argument
@click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY")
@click.option(
    "-r",
    "--replace-kvn",
    type=HexIntParamType(),
    default=0,
    help="replace an existing key of the same type (same KID)",
)
def generate_key(ctx, key, public_key_output, replace_kvn):
    """
    Generate an asymmetric key pair.

    The private key is generated on the YubiKey, and written to one of the slots.

    \b
    KID KVN     key reference for the new key
    PUBLIC-KEY  file containing the generated public key (use '-' to use stdout)
    """

    _require_auth(ctx)
    valid = (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c)
    if key.kid not in valid:
        values_str = ", ".join(f"0x{v:x} ({v.name})" for v in valid)
        raise CliFail(f"KID must be one of {values_str}.")

    session = ctx.obj["session"]

    try:
        public_key = session.generate_ec_key(key, replace_kvn=replace_kvn)
    except ApduError as e:
        if e.sw == SW.NO_SPACE:
            raise CliFail("No space left for SCP keys.")
        raise

    key_encoding = serialization.Encoding.PEM
    public_key_output.write(
        public_key.public_bytes(
            encoding=key_encoding,
            format=serialization.PublicFormat.SubjectPublicKeyInfo,
        )
    )
    log_or_echo(
        f"Private key generated for {key}, public key written to "
        f"{_fname(public_key_output)}",
        logger,
        public_key_output,
    )


@keys.command("import")
@click.pass_context
@click_key_argument
@click.argument("input", metavar="INPUT")
@click.option("-p", "--password", help="password used to decrypt the file (if needed)")
@click.option(
    "-r",
    "--replace-kvn",
    type=HexIntParamType(),
    default=0,
    help="replace an existing key of the same type (same KID)",
)
def import_key(ctx, key, input, password, replace_kvn):
    """
    Import a key or certificate.

    KID 0x01 expects the input to be a ":"-separated triple of K-ENC:K-MAC:K-DEK.

    KID 0x11, 0x13, and 0x15 expect the input to be a file containing a private key and
    (optionally) a certificate chain.

    KID 0x10, 0x20-0x2F expect the file to contain a CA-KLOC certificate.

    \b
    KID KVN     key reference for the new key
    INPUT       SCP03 keyset, or input file (use '-' to use stdin)
    """

    _require_auth(ctx)
    session = ctx.obj["session"]

    if key.kid == ScpKid.SCP03:
        session.put_key(key, StaticKeys(*[bytes.fromhex(k) for k in input.split(":")]))
        return

    file = click.File("rb").convert(input, None, ctx)
    data = file.read()
    if key.kid in (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c):
        # Expect a private key
        while True:
            if password is not None:
                password = password.encode()
            try:
                target = parse_private_key(data, password)
                break
            except InvalidPasswordError:
                logger.debug("Error parsing file", exc_info=True)
                if password is None:
                    password = click_prompt(
                        "Enter password to decrypt file",
                        default="",
                        hide_input=True,
                        show_default=False,
                    )
                else:
                    password = None
                    click.echo("Wrong password.")

        ca, bundle, leaf = organize_scp11_certificates(
            parse_certificates(data, password)
        )
        if leaf:
            bundle = list(bundle) + [leaf]

    elif key.kid in (0x10, *range(0x20, 0x30)):  # Public CA key
        ca, inter, leaf = organize_scp11_certificates(parse_certificates(data, None))
        if not ca:
            raise CliFail("Input does not contain a valid CA-KLOC certificate.")
        target = ca.public_key()
        bundle = None

    else:
        raise CliFail(f"Invalid value for KID={key.kid:x}.")

    try:
        session.put_key(key, target, replace_kvn)
        click.echo(f"Key stored for {target}.")
    except ApduError as e:
        if e.sw == SW.NO_SPACE:
            raise CliFail("No space left for SCP keys.")
        raise

    # If we have a bundle of intermediate certificates, store them
    if bundle:
        session.store_certificate_bundle(key, bundle)
        click.echo("Certificate bundle stored.")

    # If the CA has a Subject Key Identifer we should store it
    if ca:
        ski = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
        session.store_ca_issuer(key, ski.value.digest)
        click.echo("CA key identifier stored.")


@keys.command()
@click.pass_context
@click_key_argument
@click.argument("certificates-output", type=click.File("wb"), metavar="OUTPUT")
def export(ctx, key, certificates_output):
    """
    Export certificate chain for a key.

    \b
    KID KVN     key reference to output certificate chain for
    OUTPUT      file to write the certificate chain to (use '-' to use stdout)
    """
    session = ctx.obj["session"]
    pems = [
        cert.public_bytes(encoding=serialization.Encoding.PEM)
        for cert in reversed(session.get_certificate_bundle(key))
    ]
    if pems:
        certificates_output.write(b"".join(pems))
        log_or_echo(
            f"Certificate chain for {key} written to {_fname(certificates_output)}",
            logger,
            certificates_output,
        )
    else:
        raise CliFail(f"No certificate chain stored for {key}.")


@keys.command("delete")
@click.pass_context
@click_key_argument
@click_force_option
def delete_key(ctx, key, force):
    """
    Delete a key or keyset.

    Deletes the key or keyset with the given KID and KVN. Set either KID or KVN to 0 to
    use it as a wildcard and delete all keys matching the specific KID or KVN

    \b
    KID KVN     key reference for the key to delete
    """
    _require_auth(ctx)
    session = ctx.obj["session"]

    force or click.confirm(
        "WARNING! This will delete all matching SCP keys. Proceed?",
        abort=True,
        err=True,
    )

    try:
        session.delete_key(key.kid, key.kvn)
        click.echo("SCP key deleted.")
    except ApduError as e:
        if e.sw == SW.REFERENCE_DATA_NOT_FOUND:
            raise CliFail(f"No key stored in {key}.")
        if e.sw == SW.CONDITIONS_NOT_SATISFIED:
            raise CliFail(
                "This would delete ALL SCP keys, use the reset command instead."
            )
        raise


@keys.command("set-allowlist")
@click.pass_context
@click_key_argument
@click.argument("serials", nargs=-1, type=HexIntParamType())
def set_allowlist(ctx, key, serials):
    """
    Set an allowlist of certificate serial numbers for a key.

    Each certificate in the chain used when authenticating an SCP11a/c session will be
    checked and rejected if their serial number is not in this allowlist.

    \b
    KID KVN     key reference to set the allowlist for
    SERIALS     serial numbers of certificates to allow (space separated)
    """
    _require_auth(ctx)
    session = ctx.obj["session"]

    session.store_allowlist(key, serials)
    click.echo(f"SCP serial number allowlist set for {key}.")
