/// <reference types="@types/dom-serial" />
import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils";
import { Point } from "@noble/secp256k1";
import { base64 } from "@scure/base";
import { logger } from "applesauce-core";
import { createDefer } from "applesauce-core/promise";
import { getEventHash, verifyEvent } from "nostr-tools";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function xOnlyToXY(p) {
    return Point.fromHex(p).toHex().substring(2);
}
const utf8Decoder = new TextDecoder("utf-8");
const utf8Encoder = new TextEncoder();
/** A signer that works with [nostr-signing-device](https://github.com/lnbits/nostr-signing-device) */
export class SerialPortSigner {
    log = logger.extend("SerialPortSigner");
    writer = null;
    pubkey;
    get isConnected() {
        return !!this.writer;
    }
    verifyEvent = verifyEvent;
    nip04;
    constructor() {
        this.nip04 = {
            encrypt: this.nip04Encrypt.bind(this),
            decrypt: this.nip04Decrypt.bind(this),
        };
    }
    lastCommand = null;
    async callMethodOnDevice(method, params, opts = {}) {
        if (!SerialPortSigner.SUPPORTED)
            throw new Error("Serial devices are not supported");
        if (!this.writer)
            await this.connectToDevice(opts);
        // only one command can be pending at any time
        // but each will only wait 6 seconds
        if (this.lastCommand)
            throw new Error("Previous command to device still pending!");
        const command = createDefer();
        this.lastCommand = command;
        // send actual command
        this.sendCommand(method, params);
        setTimeout(() => {
            command.reject(new Error("Device timeout"));
            if (this.lastCommand === command)
                this.lastCommand = null;
        }, 6000);
        return this.lastCommand;
    }
    async connectToDevice({ onConnect, onDisconnect, onError, onDone }) {
        let port = await window.navigator.serial.requestPort();
        let reader;
        const startSerialPortReading = async () => {
            // reading responses
            while (port && port.readable) {
                const textDecoder = new window.TextDecoderStream();
                port.readable.pipeTo(textDecoder.writable);
                reader = textDecoder.readable.getReader();
                const readStringUntil = this.readFromSerialPort(reader);
                try {
                    while (true) {
                        const { value, done } = await readStringUntil("\n");
                        if (value) {
                            const { method, data } = this.parseResponse(value);
                            // if (method === "/log") deviceLog(data);
                            if (method === "/ping")
                                this.log("Pong");
                            if (SerialPortSigner.PUBLIC_METHODS.indexOf(method) === -1) {
                                // ignore /ping, /log responses
                                continue;
                            }
                            this.log("Received: ", method, data);
                            if (this.lastCommand) {
                                this.lastCommand.resolve(data);
                                this.lastCommand = null;
                            }
                        }
                        if (done) {
                            this.lastCommand = null;
                            this.writer = null;
                            if (onDone)
                                onDone();
                            return;
                        }
                    }
                }
                catch (error) {
                    if (error instanceof Error) {
                        this.writer = null;
                        if (onError)
                            onError(error);
                        if (this.lastCommand) {
                            this.lastCommand.reject(error);
                            this.lastCommand = null;
                        }
                        throw error;
                    }
                }
            }
        };
        await port.open({ baudRate: 9600 });
        // this `sleep()` is a hack, I know!
        // but `port.onconnect` is never called. I don't know why!
        await sleep(1000);
        startSerialPortReading();
        const textEncoder = new window.TextEncoderStream();
        textEncoder.readable.pipeTo(port.writable);
        this.writer = textEncoder.writable.getWriter();
        // send ping first
        await this.sendCommand(SerialPortSigner.METHOD_PING);
        await this.sendCommand(SerialPortSigner.METHOD_PING, [window.location.host]);
        if (onConnect)
            onConnect();
        port.addEventListener("disconnect", () => {
            this.log("Disconnected");
            this.lastCommand = null;
            this.writer = null;
            if (onDisconnect)
                onDisconnect();
        });
    }
    async sendCommand(method, params = []) {
        if (!this.writer)
            return;
        this.log("Send command", method, params);
        const message = [method].concat(params).join(" ");
        await this.writer.write(message + "\n");
    }
    readFromSerialPort(reader) {
        let partialChunk;
        let fulliness = [];
        const readStringUntil = async (separator = "\n") => {
            if (fulliness.length)
                return { value: fulliness.shift().trim(), done: false };
            const chunks = [];
            if (partialChunk) {
                // leftovers from previous read
                chunks.push(partialChunk);
                partialChunk = undefined;
            }
            while (true) {
                const { value, done } = await reader.read();
                if (value) {
                    const values = value.split(separator);
                    // found one or more separators
                    if (values.length > 1) {
                        chunks.push(values.shift()); // first element
                        partialChunk = values.pop(); // last element
                        fulliness = values; // full lines
                        return { value: chunks.join("").trim(), done: false };
                    }
                    chunks.push(value);
                }
                if (done)
                    return { value: chunks.join("").trim(), done: true };
            }
        };
        return readStringUntil;
    }
    parseResponse(value) {
        const method = value.split(" ")[0];
        const data = value.substring(method.length).trim();
        return { method, data };
    }
    // NIP-04
    async nip04Encrypt(pubkey, text) {
        const sharedSecretStr = await this.callMethodOnDevice(SerialPortSigner.METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
        const sharedSecret = hexToBytes(sharedSecretStr);
        let iv = Uint8Array.from(randomBytes(16));
        let plaintext = utf8Encoder.encode(text);
        // @ts-ignore
        let cryptoKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "AES-CBC" }, false, ["encrypt"]);
        let ciphertext = await crypto.subtle.encrypt({ name: "AES-CBC", iv }, cryptoKey, plaintext);
        let ctb64 = base64.encode(new Uint8Array(ciphertext));
        let ivb64 = base64.encode(new Uint8Array(iv.buffer));
        return `${ctb64}?iv=${ivb64}`;
    }
    async nip04Decrypt(pubkey, data) {
        let [ctb64, ivb64] = data.split("?iv=");
        const sharedSecretStr = await this.callMethodOnDevice(SerialPortSigner.METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
        const sharedSecret = hexToBytes(sharedSecretStr);
        // @ts-ignore
        let cryptoKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "AES-CBC" }, false, ["decrypt"]);
        let ciphertext = base64.decode(ctb64);
        let iv = base64.decode(ivb64);
        // @ts-ignore
        let plaintext = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, ciphertext);
        let text = utf8Decoder.decode(plaintext);
        return text;
    }
    /** Returns the public key on the device */
    async getPublicKey() {
        const pubkey = await this.callMethodOnDevice(SerialPortSigner.METHOD_PUBLIC_KEY, []);
        this.pubkey = pubkey;
        return pubkey;
    }
    /** Sets the secret key used on the device */
    async restore(secretKey) {
        await this.callMethodOnDevice(SerialPortSigner.METHOD_RESTORE, [bytesToHex(secretKey)]);
    }
    /** Requires the device to sign an event */
    async signEvent(draft) {
        const pubkey = draft.pubkey || this.pubkey;
        if (!pubkey)
            throw new Error("Unknown signer pubkey");
        const draftWithId = { ...draft, id: getEventHash({ ...draft, pubkey }) };
        const sig = await this.callMethodOnDevice(SerialPortSigner.METHOD_SIGN_MESSAGE, [draftWithId.id]);
        const event = { ...draftWithId, sig, pubkey };
        if (!this.verifyEvent(event))
            throw new Error("Invalid signature");
        return event;
    }
    /** Pings to device to see if the connection is open */
    ping() {
        this.sendCommand(SerialPortSigner.METHOD_PING, [window.location.host]);
    }
    // static const
    static SUPPORTED = "navigator" in globalThis && !!navigator.serial;
    static METHOD_PING = "/ping";
    static METHOD_LOG = "/log";
    static METHOD_SIGN_MESSAGE = "/sign-message";
    static METHOD_SHARED_SECRET = "/shared-secret";
    static METHOD_PUBLIC_KEY = "/public-key";
    static METHOD_RESTORE = "/restore";
    static PUBLIC_METHODS = [
        SerialPortSigner.METHOD_PUBLIC_KEY,
        SerialPortSigner.METHOD_SIGN_MESSAGE,
        SerialPortSigner.METHOD_SHARED_SECRET,
        SerialPortSigner.METHOD_RESTORE,
    ];
}
