const idUrlInput = document.querySelector('#id-url');
const logFlow = document.querySelector('#log-flow');
const msgFlow = document.querySelector('#msg-flow');
const idPubkeyInput = document.querySelector('#id-pubkey');
const actPubkeyDisplay = document.querySelector('#act-pubkey');
const serverUrlInput = document.querySelector('#server-url');
const roomsList = document.querySelector('#rooms');
const membersList = document.querySelector('#room-members');
const joinNewRoomInput = document.querySelector('#join-new-room');
const chatInput = document.querySelector('#chat');

let apiUrl = null;
let curRoom = null;
let ws = null;
let keypair = null;
let defaultConfig = {};
let lastCid = null;

function bufToHex(buf) {
    return [...new Uint8Array(buf)]
        .map(x => x.toString(16).padStart(2, '0'))
        .join('');
}

function hexToBuf(hex) {
    return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16)))
}

function shortenIdKey(id_key) {
    return id_key.replace(/^(.{4}).*(.{4})$/, '$1…$2');
}

function getIdPubkey() {
    const s = idPubkeyInput.value.trim();
    if (!s.match(/^[a-zA-Z0-9]{64}$/)) {
        throw new Error(`invalid id_key, please re-enter: ${s}`);
    }
    return s;
}

async function getActPubkey() {
    if (keypair === null) throw new Error('no actkey');
    return bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey));
}

function appendMsg(parent, el) {
    parent.append(el);
    parent.scrollTo({
        top: parent.scrollTopMax,
        behavior: 'instant',
    })
}

function log(msg, isHtml) {
    const el = document.createElement('span', {});
    el.classList.add('log');
    if (isHtml) {
        el.innerHTML = msg;
    } else {
        el.innerText = msg;
    }
    appendMsg(logFlow, el)
}

async function loadKeypair() {
    try {
        const rawJson = localStorage.getItem('keypair');
        if (rawJson === null) return false;
        const json = JSON.parse(rawJson)
        keypair = {
            publicKey: await crypto.subtle.importKey('jwk', json.publicKey, { name: 'Ed25519' }, true, ['verify']),
            privateKey: await crypto.subtle.importKey('jwk', json.privateKey, { name: 'Ed25519' }, true, ['sign']),
        };
        log('loaded keypair from localStorage');
        return true;
    } catch (e) {
        console.error(e);
        log('failed to load keypair from localStorage');
        return false;
    }
}

async function generateKeypair() {
    log('generating keypair');
    document.querySelectorAll('input, button, select').forEach((el) => el.disabled = true);
    try {
        keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
    } catch (e) {
        console.error('keygen', e);
        chatInput.disabled = true;
        log(
            `failed to generate keypair, posting is disabled. maybe try Firefox or Safari?
            <a target="_blank" href="https://caniuse.com/mdn-api_subtlecrypto_sign_ed25519">
                check caniuse.com
            </a>
            `,
            true
        );
        return;
    }

    log('keypair generated');
    actPubkeyDisplay.value = await getActPubkey();
    document.querySelectorAll('input, button, select').forEach((el) => el.disabled = false);

    try {
        const serialize = (k) => crypto.subtle.exportKey('jwk', k);
        localStorage.setItem('keypair', JSON.stringify({
            publicKey: await serialize(keypair.publicKey),
            privateKey: await serialize(keypair.privateKey),
        }));
    } catch (e) {
        console.error(e);
        log('failed to store keypair into localStorage');
    }
}

async function register() {
    function norm(url) {
        return String(url).endsWith('/') ? url : url + '/';
    }

    try {
        const idUrl = idUrlInput.value.trim();
        if (idUrl === '') return;

        const wellKnownUrl = (new URL(idUrl)) + '.well-known/blah/identity.json';
        log(`fetching ${wellKnownUrl}`);
        const idDescResp = await fetch(wellKnownUrl, { redirect: 'error' });
        if (idDescResp.status !== 200) throw new Error(`status ${idDescResp.status}`);
        const idDescJson = await idDescResp.json()
        if (typeof idDescJson.id_key !== 'string' || idDescJson.id_key.length !== 64) {
            throw new Error('invalid id_key from identity description response');
        }
        idPubkeyInput.value = idDescJson.id_key;

        const getResp = await fetch(`${apiUrl}/user/me`, {
            cache: 'no-store'
        })
        if (getResp.status === 204) {
            log('already registered');
            return;
        }
        const getRespJson = await getResp.json();
        if (getResp.status !== 404) {
            throw new Error(`failed to get user info, status ${getResp.status}: ${getRespJson.error.message}`);
        }
        const challenge_nonce = getRespJson?.register_challenge?.pow?.nonce;
        if (!challenge_nonce) {
            throw new Error(`cannot get challenge nonce: ${getRespJson.error.message}`);
        }
        const difficulty = getRespJson.register_challenge.pow.difficulty;

        log('solving challenge')
        const postResp = await signAndPost(`${apiUrl}/user/me`, {
            // sorted fields.
            challenge: {
                pow: {
                    nonce: challenge_nonce,
                },
            },
            id_key: getIdPubkey(),
            id_url: norm(idUrl),
            server_url: norm(apiUrl.replace(/\/_blah\/?$/, '')),
            typ: 'user_register',
        }, difficulty)
        if (!postResp.ok) throw new Error(`status ${getResp.status}: ${(await getResp.json()).error.message}`);
        log('registered')
    } catch (err) {
        log(`failed to register: ${err}`)
    }
}

async function showChatMsg(chat) {
    let verifyRet = null;
    try {
        const sortKeys = (obj) =>
            Object.fromEntries(Object.entries(obj).sort((lhs, rhs) => lhs[0] > rhs[0]));
        let canonicalJson = chat.signee
        // Just for simplicity.
        canonicalJson.payload = sortKeys(canonicalJson.payload);
        canonicalJson = sortKeys(canonicalJson);
        const signeeBytes = (new TextEncoder()).encode(JSON.stringify(canonicalJson));
        const rawkey = hexToBuf(chat.signee.act_key);
        const senderKey = await crypto.subtle.importKey('raw', rawkey, { name: 'Ed25519' }, true, ['verify']);
        const success = await crypto.subtle.verify('Ed25519', senderKey, hexToBuf(chat.sig), signeeBytes);
        verifyRet = success ? '✔️' : '✖️';
    } catch (e) {
        console.error(e);
        verifyRet = `✖️ ${e}`;
    }

    // TODO: The relationship of id_key and act_key is not verified.
    const shortUser = shortenIdKey(chat.signee.id_key);
    const time = new Date(chat.signee.timestamp * 1000).toISOString();

    const el = document.createElement('div', {});
    el.classList.add('msg');
    const elHeader = document.createElement('span', {});
    const elContent = document.createElement('span', {});
    elHeader.innerText = `${shortUser} [${time}] [${verifyRet}]:`;
    elContent.innerHTML = richTextToHtml(chat.signee.payload.rich_text);
    el.appendChild(elHeader);
    el.appendChild(elContent);
    appendMsg(msgFlow, el)
}

function richTextToHtml(richText) {
    let ret = ''
    for (let e of richText) {
        const [text, attrs] = typeof e === 'string' ? [e, {}] : e;
        // Incomplete cases.
        const tags = [
            [attrs.b, 'b'],
            [attrs.i, 'i'],
            [attrs.m, 'code'],
            [attrs.s, 'strike'],
            [attrs.u, 'u'],
        ];
        for (const [cond, tag] of tags) {
            if (cond) ret += `<${tag}>`;
        }
        ret += escapeHtml(text);
        tags.reverse();
        for (const [cond, tag] of tags) {
            if (cond) ret += `</${tag}>`;
        }
    }
    return ret;
}

function escapeHtml(text) {
    return text.replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#039;');
}

async function genAuthHeader() {
    return {
        headers: {
            'Authorization': await signData({ typ: 'auth' }),
        },
    };
}

async function enterRoom(rid) {
    log(`loading room: ${rid}`);
    curRoom = rid;
    roomsList.value = rid;

    msgFlow.replaceChildren();
    membersList.replaceChildren();

    let roomMetadata;
    try {
        const resp = await fetch(`${apiUrl}/room/${rid}`, await genAuthHeader());
        roomMetadata = await resp.json();
        if (resp.status !== 200) throw new Error(`status ${resp.status}: ${roomMetadata.error.message}`);
        document.title = `Blah: ${roomMetadata.title}`;
    } catch (err) {
        log(`failed to get room metadata: ${err}`);
    }

    try {
        const resp = await fetch(`${apiUrl}/room/${rid}/member`, await genAuthHeader());
        const json = await resp.json();
        if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`);
        for (const { id_key, permission, last_seen_cid } of json.members) {
            const el = document.createElement('option')
            el.value = id_key;
            el.innerText = `${shortenIdKey(id_key)} perm=${permission} last_seen=${last_seen_cid || '-'}`;
            membersList.appendChild(el);
        }
    } catch (err) {
        log(`failed to fetch members: ${err}`);
    }

    try {
        const resp = await fetch(`${apiUrl}/room/${rid}/msg`, await genAuthHeader());
        const json = await resp.json();
        if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`);
        const { msgs } = json
        msgs.reverse();
        for (const msg of msgs) {
            lastCid = msg.cid;
            await showChatMsg(msg);
            if (msg.cid === roomMetadata.last_seen_cid) {
                const el = document.createElement('span');
                el.innerText = '---last seen---';
                appendMsg(msgFlow, el)
            }
        }
    } catch (err) {
        log(`failed to fetch history: ${err}`);
    }
}

async function connectServer(newServerUrl) {
    if (newServerUrl === '' || keypair === null) return;
    let wsUrl
    try {
        wsUrl = new URL(newServerUrl);
    } catch (e) {
        log(`invalid url: ${e}`);
        return;
    }
    apiUrl = wsUrl.toString() + '_blah';

    if (ws !== null) {
        ws.close();
    }

    log('connecting server');
    wsUrl.protocol = wsUrl.protocol == 'http:' ? 'ws:' : 'wss:';
    wsUrl.pathname += '_blah/ws';
    ws = new WebSocket(wsUrl);
    ws.onopen = async (_) => {
        const auth = await signData({ typ: 'auth' });
        await ws.send(auth);
        log(`listening events on server: ${newServerUrl}`);
    }
    ws.onclose = (e) => {
        console.error(e);
        log(`ws closed (code=${e.code}): ${e.reason}`);
    };
    ws.onerror = (e) => {
        console.error(e);
        log(`ws error: ${e.error}`);
    };
    ws.onmessage = async (e) => {
        console.log('ws event', e.data);
        const evt = JSON.parse(e.data);
        if (evt.msg !== undefined) {
            if (evt.msg.signee.payload.room === curRoom) {
                lastCid = evt.msg.cid;
                await showChatMsg(evt.msg);
            } else {
                console.log('ignore background room msg');
            }
        } else if (evt.lagged !== undefined) {
            log('some events are dropped because of queue overflow')
        } else {
            log(`unknown ws msg: ${e.data}`);
        }
    };

    loadRoomList(true);
}

async function loadRoomList(autoJoin) {
    log('loading room list');

    async function loadInto(targetEl, filter) {
        const emptyEl = document.createElement('option');
        emptyEl.value = '';
        emptyEl.innerText = '-';
        emptyEl.disabled = true;
        targetEl.replaceChildren(emptyEl);
        targetEl.value = '';

        try {
            const resp = await fetch(`${apiUrl}/room?filter=${filter}`, await genAuthHeader())
            const json = await resp.json()
            if (resp.status !== 200) throw new Error(`status ${resp.status}: ${json.error.message}`);
            for (const { rid, title, attrs, last_msg, last_seen_cid } of json.rooms) {
                const el = document.createElement('option');
                el.value = rid;
                el.innerText = `${title} (rid=${rid}, attrs=${attrs})`;
                if (last_msg !== undefined && last_msg.cid !== last_seen_cid) {
                    el.innerText += ' (unread)';
                }
                targetEl.appendChild(el);
            }
        } catch (err) {
            log(`failed to load room list: ${err}`)
        }
    }

    loadInto(roomsList, 'joined')
    .then(async (_) => {
        if (autoJoin) {
            const el = roomsList.querySelector('option:nth-child(2)');
            if (el !== null) {
                await enterRoom(el.value);
            }
        }
    });

    loadInto(joinNewRoomInput, 'public')
}

async function joinRoom(rid) {
    try {
        joinNewRoomInput.disabled = true;
        await signAndPost(`${apiUrl}/room/${rid}/admin`, {
            // sorted fields.
            permission: 1, // POST_CHAT
            room: rid,
            typ: 'add_member',
            user: await getIdPubkey(),
        });
        log('joined room');
        await loadRoomList(false)
        await enterRoom(rid);
    } catch (e) {
        console.error(e);
        log(`failed to join room: ${e}`);
    } finally {
        joinNewRoomInput.disabled = false;
    }
}

async function leaveRoom() {
    try {
        await signAndPost(`${apiUrl}/room/${curRoom}/admin`, {
            room: curRoom,
            typ: 'remove_member',
            user: await getIdPubkey(),
        });
        log('left room');
        await loadRoomList(true);
    } catch (e) {
        console.error(e);
        log(`failed to leave room: ${e}`);
    }
}


async function signAndPost(url, data, difficulty) {
    const signedPayload = await signData(data, difficulty);
    const resp = await fetch(url, {
        method: 'POST',
        cache: 'no-cache',
        body: signedPayload,
        headers: {
            'Content-Type': 'application/json',
        },
    });
    if (!resp.ok) {
        const errResp = await resp.json();
        throw new Error(`status ${resp.status}: ${errResp.error.message}`);
    }
    return resp;
}

async function signData(payload, difficulty) {
    const userKey = await getActPubkey();
    const nonceBuf = new Uint32Array(1);
    crypto.getRandomValues(nonceBuf);
    const timestamp = (Number(new Date()) / 1000) | 0;
    const signee = {
        // sorted fields.
        act_key: userKey,
        id_key: getIdPubkey(),
        nonce: nonceBuf[0],
        payload,
        timestamp,
    };
    let signeeBytes = (new TextEncoder()).encode(JSON.stringify(signee));

    if (difficulty !== undefined && difficulty !== 0) {
        const zeroBytes = difficulty >> 3;
        const nonzeroByteMax = 1 << (8 - (difficulty & 7));
        console.log(`sign with difficulty ${difficulty}, zbytes=${zeroBytes}, nzmax=${nonzeroByteMax}`);
        let i = 0;
        let h;
        for (;; i++) {
            h = new Uint8Array(await crypto.subtle.digest('SHA-256', signeeBytes));
            let passed = (h[zeroBytes] < nonzeroByteMax);
            for (let j = 0; j < zeroBytes; j++) passed &&= (h[j] === 0);
            if (passed) break;
            signee.nonce = (signee.nonce + 1) & 0x7FFFFFFF;
            signeeBytes = (new TextEncoder()).encode(JSON.stringify(signee));
        }
        console.log(`challenge complete after ${i} iterations, hash: ${bufToHex(h)}`);
    }

    const sig = await crypto.subtle.sign('Ed25519', keypair.privateKey, signeeBytes);

    return JSON.stringify({ sig: bufToHex(sig), signee });
}

async function postChat(text) {
    text = text.trim();
    if (keypair === null || curRoom === null || text === '') return;

    chatInput.disabled = true;

    try {
        let richText;
        if (text.startsWith('[')) {
            richText = JSON.parse(text);
        } else {
            richText = [text];
        }
        await signAndPost(`${apiUrl}/room/${curRoom}/msg`, {
            // sorted fields.
            rich_text: richText,
            room: curRoom,
            typ: 'chat',
        });
        chatInput.value = '';
    } catch (e) {
        console.error(e);
        log(`failed to post chat: ${e}`);
    } finally {
        chatInput.disabled = false;
    }
}

async function markSeen() {
    try {
        const resp = await fetch(`${apiUrl}/room/${curRoom}/msg/${lastCid}/seen`, {
            method: 'POST',
            headers: (await genAuthHeader()).headers,
        })
        if (!resp.ok) throw new Error(`status ${resp.status}: ${(await resp.json()).error.message}`);
        log('seen')
    } catch (err) {
        log(`failed to mark seen: ${err}`)
    }
}

window.onload = async (_) => {
    try {
        const resp = await fetch('./default.json');
        if (resp.ok) {
            defaultConfig = await resp.json();
        }
    } catch (e) {}

    if (!await loadKeypair()) {
        await generateKeypair();
    }
    if (keypair !== null) {
        actPubkeyDisplay.value = await getActPubkey();
    }
    if (idUrlInput.value === '' && defaultConfig.id_url) {
        idUrlInput.value = defaultConfig.id_url;
    }
    if (idPubkeyInput.value === '' && defaultConfig.id_key) {
        idPubkeyInput.value = defaultConfig.id_key;
    }
    if (serverUrlInput.value === '' && defaultConfig.server_url) {
        serverUrlInput.value = defaultConfig.server_url;
    }
    if (serverUrlInput.value !== '') {
        await connectServer(serverUrlInput.value);
    }
};

function onButtonClick(selector, handler) {
    const el = document.querySelector(selector);
    el.onclick = async () => {
        try {
            el.disabled = true;
            await handler();
        } finally {
            el.disabled = false;
        }
    };
}
onButtonClick('#leave-room', leaveRoom);
onButtonClick('#regen-key', generateKeypair);
onButtonClick('#register', register);
onButtonClick('#refresh-rooms', async () => await loadRoomList(true));
onButtonClick('#mark-seen', markSeen);

serverUrlInput.onchange = async (e) => {
    await connectServer(e.target.value);
};
chatInput.onkeypress = async (e) => {
    if (e.key === 'Enter') {
        await postChat(chatInput.value);
        chatInput.focus();
    }
};
roomsList.onchange = async (_) => {
    await enterRoom(roomsList.value);
};
joinNewRoomInput.onchange = async (_) => {
    await joinRoom(joinNewRoomInput.value);
};