#!/usr/bin/env python
# License: GPLv3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
import argparse
import base64
import hashlib
import json
import os
import sys
import termios
import time
import tty
from contextlib import contextmanager
from ctypes import (
    CDLL, POINTER, byref, c_char_p, c_int, c_size_t, c_void_p,
    create_string_buffer
)
from ctypes.util import find_library

_plat = sys.platform.lower()
is_macos: bool = 'darwin' in _plat


def build_crypto_tools():  # {{{
    class EVP_PKEY_POINTER(c_void_p):

        algorithm = 0

        def __del__(self):
            EVP_PKEY_free(self)

        @property
        def public(self):
            sz = c_size_t(0)
            EVP_PKEY_get_raw_public_key(self, None, byref(sz))
            buf = create_string_buffer(sz.value)
            EVP_PKEY_get_raw_public_key(self, buf, byref(sz))
            return buf.raw

        def derive_secret(self, pubkey):
            pubkey = EVP_PKEY_new_raw_public_key(self.algorithm, None, pubkey, len(pubkey))
            ctx = EVP_PKEY_CTX_new(self, None)
            EVP_PKEY_derive_init(ctx)
            EVP_PKEY_derive_set_peer(ctx, pubkey)
            sz = c_size_t(0)
            EVP_PKEY_derive(ctx, None, byref(sz))
            buf = create_string_buffer(sz.value)
            EVP_PKEY_derive(ctx, buf, byref(sz))
            return hashlib.sha256(buf.raw).digest()

    class EVP_PKEY_CTX_POINTER(c_void_p):

        def __del__(self):
            EVP_PKEY_CTX_free(self)

    class EVP_CIPHER_CTX_POINTER(c_void_p):

        def __del__(self):
            EVP_CIPHER_CTX_free(self)

    class EVP_CIPHER_POINTER(c_void_p):
        pass

    cl = find_library('crypto')
    if not cl:
        raise SystemExit('Failed to find libcrypto on your system, make sure OpenSSL is installed')
    crypto = CDLL(cl)
    libc = CDLL(None)

    def create_crypto_func(name, *argtypes, restype=c_int, int_return_ok=lambda x: x == 1):

        impl = getattr(crypto, name)
        impl.restype = restype
        impl.argtypes = argtypes

        def func(*a):
            res = impl(*a)
            if restype is c_int:
                if not int_return_ok(res):
                    print('Call to', name, 'failed with return code:', res, file=sys.stderr)
                    abort_on_openssl_error()
            elif restype is not None and issubclass(restype, c_void_p):
                if res.value is None:
                    print('Call to', name, 'failed with NULL return', file=sys.stderr)
                    abort_on_openssl_error()
            return res
        return func

    OBJ_txt2nid = create_crypto_func('OBJ_txt2nid', c_char_p, int_return_ok=bool)
    EVP_PKEY_CTX_new_id = create_crypto_func('EVP_PKEY_CTX_new_id', c_int, c_void_p, restype=EVP_PKEY_CTX_POINTER)
    EVP_PKEY_CTX_new = create_crypto_func('EVP_PKEY_CTX_new', EVP_PKEY_POINTER, c_void_p, restype=EVP_PKEY_CTX_POINTER)
    EVP_PKEY_keygen_init = create_crypto_func('EVP_PKEY_keygen_init', EVP_PKEY_CTX_POINTER)
    EVP_PKEY_keygen = create_crypto_func('EVP_PKEY_keygen', EVP_PKEY_CTX_POINTER, POINTER(EVP_PKEY_POINTER))
    ERR_print_errors_fp = create_crypto_func('ERR_print_errors_fp', c_void_p, restype=None)
    EVP_PKEY_free = create_crypto_func('EVP_PKEY_free', EVP_PKEY_POINTER, restype=None)
    EVP_PKEY_CTX_free = create_crypto_func('EVP_PKEY_CTX_free', EVP_PKEY_CTX_POINTER, restype=None)
    EVP_PKEY_get_raw_public_key = create_crypto_func('EVP_PKEY_get_raw_public_key', EVP_PKEY_POINTER, c_char_p, POINTER(c_size_t))
    EVP_PKEY_new_raw_public_key = create_crypto_func('EVP_PKEY_new_raw_public_key', c_int, c_void_p, c_char_p, c_size_t, restype=EVP_PKEY_POINTER)
    EVP_PKEY_derive_init = create_crypto_func('EVP_PKEY_derive_init', EVP_PKEY_CTX_POINTER)
    EVP_PKEY_derive_set_peer = create_crypto_func('EVP_PKEY_derive_set_peer', EVP_PKEY_CTX_POINTER, EVP_PKEY_POINTER)
    EVP_PKEY_derive = create_crypto_func('EVP_PKEY_derive', EVP_PKEY_CTX_POINTER, c_char_p, POINTER(c_size_t))
    EVP_CIPHER_CTX_free = create_crypto_func('EVP_CIPHER_CTX_free', EVP_CIPHER_CTX_POINTER, restype=None)
    EVP_get_cipherbyname = create_crypto_func('EVP_get_cipherbyname', c_char_p, restype=EVP_CIPHER_POINTER)
    EVP_CIPHER_key_length = create_crypto_func('EVP_CIPHER_key_length', EVP_CIPHER_POINTER, int_return_ok=bool)
    EVP_CIPHER_iv_length = create_crypto_func('EVP_CIPHER_iv_length', EVP_CIPHER_POINTER, int_return_ok=bool)
    EVP_CIPHER_CTX_block_size = create_crypto_func('EVP_CIPHER_CTX_block_size', EVP_CIPHER_CTX_POINTER, int_return_ok=bool)
    EVP_CIPHER_CTX_new = create_crypto_func('EVP_CIPHER_CTX_new', restype=EVP_CIPHER_CTX_POINTER)
    EVP_EncryptInit_ex = create_crypto_func('EVP_EncryptInit_ex', EVP_CIPHER_CTX_POINTER, EVP_CIPHER_POINTER, c_void_p, c_char_p, c_char_p)
    EVP_EncryptUpdate = create_crypto_func('EVP_EncryptUpdate', EVP_CIPHER_CTX_POINTER, c_char_p, POINTER(c_int), c_char_p, c_int)
    EVP_EncryptFinal_ex = create_crypto_func('EVP_EncryptFinal_ex', EVP_CIPHER_CTX_POINTER, c_char_p, POINTER(c_int))
    EVP_CIPHER_CTX_ctrl = create_crypto_func('EVP_CIPHER_CTX_ctrl', EVP_CIPHER_CTX_POINTER, c_int, c_int, c_char_p)
    try:
        EVP_CIPHER_CTX_tag_length = create_crypto_func('EVP_CIPHER_CTX_tag_length', EVP_CIPHER_CTX_POINTER, int_return_ok=bool)
    except AttributeError:  # need openssl >= 3
        def EVP_CIPHER_CTX_tag_length(cipher):
            return 16
    EVP_CTRL_AEAD_GET_TAG, EVP_CTRL_AEAD_SET_TAG = 0x10, 0x11  # these are defines in the header dont know how to get them programmatically
    EVP_CTRL_AEAD_SET_TAG

    def abort_on_openssl_error():
        stderr = c_void_p.in_dll(libc, 'stderr')
        ERR_print_errors_fp(stderr)
        raise SystemExit(1)

    def elliptic_curve_keypair(algorithm='X25519'):
        nid = OBJ_txt2nid(algorithm.encode())
        pctx = EVP_PKEY_CTX_new_id(nid, None)
        EVP_PKEY_keygen_init(pctx)
        key = EVP_PKEY_POINTER()
        EVP_PKEY_keygen(pctx, byref(key))
        key.algorithm = nid
        return key

    def encrypt(plaintext, symmetric_key, algorithm='aes-256-gcm'):
        cipher = EVP_get_cipherbyname(algorithm.encode())
        if len(symmetric_key) != EVP_CIPHER_key_length(cipher):
            raise KeyError(f'The symmetric key has length {len(symmetric_key)} != {EVP_CIPHER_key_length(cipher)} needed for {algorithm}')
        ctx = EVP_CIPHER_CTX_new()
        iv = os.urandom(EVP_CIPHER_iv_length(cipher))
        EVP_EncryptInit_ex(ctx, cipher, None, symmetric_key, iv)
        bs = EVP_CIPHER_CTX_block_size(ctx)
        ciphertext = create_string_buffer(len(plaintext) + 2 * bs)
        outlen = c_int(len(ciphertext))
        EVP_EncryptUpdate(ctx, ciphertext, byref(outlen), plaintext, len(plaintext))
        ans = ciphertext[:outlen.value]
        outlen = c_int(len(ciphertext))
        EVP_EncryptFinal_ex(ctx, ciphertext, byref(outlen))
        if outlen.value:
            ans += ciphertext[:outlen.value]
        tag = create_string_buffer(EVP_CIPHER_CTX_tag_length(cipher))
        EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, len(tag), tag)
        return iv, ans, tag.raw
    return elliptic_curve_keypair, encrypt
# }}}


# utils {{{
def encrypt_cmd(cmd, password, pubkey=None):
    elliptic_curve_keypair, encrypt = build_crypto_tools()
    if pubkey is None:
        pubkey = os.environ['KITTY_PUBLIC_KEY']
        v, d = pubkey.split(':', 1)
        if v != '1':
            raise SystemExit(f'Unsupported encryption protocol: {v}')
        pubkey = base64.b85decode(d)
    k = elliptic_curve_keypair()
    sk = k.derive_secret(pubkey)
    cmd['timestamp'] = time.time_ns()
    cmd['password'] = password
    data = json.dumps(cmd).encode()
    iv, encrypted, tag = encrypt(data, sk)

    def e(x):
        return base64.b85encode(x).decode('ascii')

    return {
        'encrypted': e(encrypted), 'iv': e(iv), 'tag': e(tag), 'pubkey': e(k.public), 'version': cmd['version']
    }


@contextmanager
def raw_mode(fd):
    old = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        yield
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


def config_dir():
    if 'KITTY_CONFIG_DIRECTORY' in os.environ:
        return os.path.abspath(os.path.expanduser(os.environ['KITTY_CONFIG_DIRECTORY']))
    locations = []
    if 'XDG_CONFIG_HOME' in os.environ:
        locations.append(os.path.abspath(os.path.expanduser(os.environ['XDG_CONFIG_HOME'])))
    locations.append(os.path.expanduser('~/.config'))
    if is_macos:
        locations.append(os.path.expanduser('~/Library/Preferences'))
    for loc in filter(None, os.environ.get('XDG_CONFIG_DIRS', '').split(os.pathsep)):
        locations.append(os.path.abspath(os.path.expanduser(loc)))
    for loc in locations:
        if loc:
            q = os.path.join(loc, 'kitty')
            if os.access(q, os.W_OK) and os.path.exists(os.path.join(q, 'kitty.conf')):
                return q
    for loc in locations:
        if loc:
            q = os.path.join(loc, 'kitty')
            if os.path.isdir(q) and os.access(q, os.W_OK):
                return q
    return ''


def resolve_custom_file(path):
    path = os.path.expanduser(path)
    path = os.path.expandvars(path)
    if not os.path.isabs(path):
        cdir = config_dir()
        if cdir:
            path = os.path.join(cdir, path)
    return path


def get_password(opts):
    if opts.use_password == 'never':
        return ''
    ans = ''
    if opts.password:
        ans = opts.password
    if not ans and opts.password_file:
        if opts.password_file == '-':
            if sys.stdin.isatty():
                from getpass import getpass
                ans = getpass()
            else:
                ans = sys.stdin.read().rstrip()
                try:
                    tty_fd = os.open(os.ctermid(), os.O_RDONLY | os.O_CLOEXEC)
                except OSError:
                    pass
                else:
                    with open(tty_fd, closefd=True):
                        os.dup2(tty_fd, sys.stdin.fileno())
        else:
            try:
                with open(resolve_custom_file(opts.password_file)) as f:
                    ans = f.read().rstrip()
            except OSError:
                pass
    if not ans and opts.password_env:
        ans = os.environ.get(opts.password_env, '')
    if not ans and opts.use_password == 'always':
        raise SystemExit('No password was found')
    if ans and len(ans) > 1024:
        raise SystemExit('Specified password is too long')
    return ans
# }}}


arg_parser = argparse.ArgumentParser(prog='kitty@', description='Control kitty remotely.')
arg_parser.add_argument('--password', default='', help='''\
A password to use when contacting kitty. This will cause kitty to ask the user
for permission to perform the specified action, unless the password has been
accepted before or is pre-configured in kitty.conf''')
arg_parser.add_argument('--password-file', default='rc-pass', help='''\
A file from which to read the password. Trailing whitespace is ignored. Relative
paths are resolved from the kitty configuration directory. Use - to read from STDIN.
Used if no --password is supplied. Defaults to checking for the
rc-pass file in the kitty configuration directory.''')
arg_parser.add_argument('--password-env', default='KITTY_RC_PASSWORD', help='''\
The name of an environment variable to read the password from.
Used if no --password-file is supplied. Defaults to checking the KITTY_RC_PASSWORD.''')
arg_parser.add_argument('--use-password', default='if-available', choices=('if-available', 'always', 'never'), help='''\
If no password is available, kitty will usually just send the remote control command
without a password. This option can be used to force it to always or never use
the supplied password.''')

args = arg_parser.parse_args()


def populate_cmd(cmd):
    raise NotImplementedError()


password = get_password(args)
cmd = {'version': [0, 20, 0]}  # use a random version that's fairly old
populate_cmd(cmd)
if password:
    encrypt_cmd(cmd, password)

# cmd = {'version': [0, 14, 2], 'cmd': 'ls'}
# cmd = encrypt_cmd(cmd, 'test')
# with open(os.open(os.ctermid(), os.O_RDWR | os.O_CLOEXEC), 'w') as tty_file, raw_mode(tty_file.fileno()):
#     print(end=f'\x1bP@kitty-cmd{json.dumps(cmd)}\x1b\\', flush=True, file=tty_file)
#     os.read(tty_file.fileno(), 4096)
