const ACCESS_TOKEN = 'ORDINARY_ACCESS_TOKEN';
const REFRESH_TOKEN = 'ORDINARY_REFRESH_TOKEN';
const TOKEN_SIGNING_KEY = 'ORDINARY_TOKEN_SIGNING_KEY';
const SCHEMA = 'ORDINARY_SCHEMA';
const CORRELATION = 'ORDINARY_CORRELATION_ID';

const DEFAULT_CLIENT_EXP_S = 3;

const MIN_EVENT_DELAY_S = 5;
const MAX_EVENT_DELAY_S = 10;
const MAX_EVENT_BUFFER = 20;
const MAX_EVENT_BATCH = 15;

export const ALG = {
    // 'Sha512': 'SHA-512',
    // 'Sha384': 'SHA-384',
    'Sha256': 'SHA-256',
};


function getCorrelationId() {
    const correlationId = sessionStorage.getItem(CORRELATION);

    if (correlationId) {
        return correlationId;
    }

    const newCorrelationId = crypto.randomUUID();
    sessionStorage.setItem(CORRELATION, newCorrelationId);

    return newCorrelationId;
}

export async function getHeaders({
                                     cookies = true,
                                     requestId = true,
                                     correlationId = true,
                                     authorization = false,
                                 } = {}) {
    const token = authorization ? await getAccess(cookies) : new Uint8Array([]);

    return {
        ...(requestId ? {'x-request-id': crypto.randomUUID()} : {}),
        ...(correlationId ? {'x-correlation-id': getCorrelationId()} : {}),
        ...(authorization ? {'authorization': `Bearer ${base64encode(token)}`} : {}),
    }
}

export async function getSchema() {
    const schema = localStorage.getItem(SCHEMA);

    if (schema) {
        const [compression, encoded] = schema.split(',');

        const decoded = base64decode(encoded);
        const decompressed = decompress(decoded, 'application/json', compression);

        const json = await decompressed.json();

        if (json.version === '{{ version }}') {
            return json;
        }
    }

    const res = await fetch('/.ordinary/schema', {
        headers: await getHeaders(),
    });
    const json = await res.json();

    const [compression, compressed] = await compress(JSON.stringify(json), 'application/json');
    const encoded = base64encode(compressed);

    localStorage.setItem(SCHEMA, `${compression},${encoded}`);

    return json;
}

export async function getFlag(flagName) {
    const FLAG = `ORDINARY_FLAG_${flagName.toUpperCase()}`;
    const flag = localStorage.getItem(FLAG);

    if (!flag) {
        const schema = await getSchema();

        for (let i = 0; i < schema.flags.length; i += 1) {
            const flag = schema.flags[i];

            if (flag.name === flagName) {
                const select = Math.floor(Math.random() * 101);

                let acc = 0;

                for (let j = 0; j < flag.options.length; j += 1) {
                    const option = flag.options[j];

                    acc += option.percentage;

                    if (acc >= select) {
                        localStorage.setItem(FLAG, option.name);
                        return option.name;
                    }
                }
            }
        }
    }

    return flag;
}

async function getFlags() {
    const schema = await getSchema();
    const flags = {};

    for (let i = 0; i < schema.flags.length; i += 1) {
        const name = schema.flags[i].name;


        flags[name] = await getFlag(name);
    }

    return flags;
}

export function base64encode(value) {
    return value.toBase64({
        omitPadding: true,
        alphabet: "base64url"
    });
}

export function base64decode(value) {
    return Uint8Array.fromBase64(value, {
        alphabet: "base64url",
    });
}

async function compress(value, mime) {
    const blob = new Blob([value], {type: mime});

    const deflate = await (new Response(blob.stream().pipeThrough(
        new CompressionStream('deflate'),
    ))).bytes();

    return ['deflate', deflate];
}

function decompress(value, mime, compression) {
    const blob = new Blob([value], {type: mime});

    return (new Response(blob.stream().pipeThrough(
        new DecompressionStream(compression),
    )));
}

export async function tryCompress(value, mime, force) {
    const [compression, compressed] = await compress(value, mime)
    const useCompressed = compressed.length < value.length || force;

    return {
        body: useCompressed ? compressed : value,
        headers: {
            'content-type': mime,
            ...(useCompressed ? {'content-encoding': compression} : {}),
        },
    };
}

export function setTokenSigningKey(key) {
    const encoded = base64encode(key);
    // ?? encrypt/decrypt with a hashed passcode that lives in SessionStorage
    localStorage.setItem(TOKEN_SIGNING_KEY, encoded);

    localStorage.removeItem(REFRESH_TOKEN);
    localStorage.removeItem(ACCESS_TOKEN);
}

async function trySigningToken(token) {
    const encoded = localStorage.getItem(TOKEN_SIGNING_KEY);

    if (!encoded) return token;

    const exp = new ArrayBuffer(4);
    const buf = new DataView(exp);

    // todo: make the client exp settable in config
    buf.setUint32(0, Math.floor(Date.now() / 1000) + DEFAULT_CLIENT_EXP_S, false);

    const withExp = new Uint8Array([...token, ...new Uint8Array(exp)]);
    const decoded = base64decode(encoded);

    const signingKey = await crypto.subtle.importKey(
        'pkcs8',
        decoded,
        {
            name: 'Ed25519'
        },
        false,
        ['sign']
    );

    const signature = await crypto.subtle.sign("Ed25519", signingKey, withExp);
    return new Uint8Array([...withExp, ...new Uint8Array(signature)]);
}

export function setRefresh(token) {
    const encoded = base64encode(token);
    localStorage.setItem(REFRESH_TOKEN, encoded);
    localStorage.removeItem(ACCESS_TOKEN);
}

async function refetchAccess(withCookies) {
    const encoded = localStorage.getItem(REFRESH_TOKEN);

    if (!encoded) {
        throw new Error('not logged in.');
    }

    const refresh_token = base64decode(encoded);
    const exp = new DataView(refresh_token.buffer).getUint32(0, false);

    if (exp < Math.round(new Date().getTime() / 1000)) {
        localStorage.removeItem(REFRESH_TOKEN);
        localStorage.removeItem(ACCESS_TOKEN);

        throw new Error('refresh token expired.');
    }

    const signed_token = await trySigningToken(refresh_token);

    let access_res;

    const schema = await getSchema();

    if (withCookies && schema.auth.cookies_enabled) {
        access_res = await fetch('/accounts/access/get', {
            method: 'POST',
            body: signed_token,
            credentials: 'include',
            headers: await getHeaders(),
        });
    } else {
        access_res = await fetch('/accounts/access/get', {
            method: 'PUT',
            body: signed_token,
            headers: await getHeaders(),
        });
    }

    const token = await access_res.bytes();
    setAccess(token);

    return token;
}

function setAccess(token) {
    const encoded = base64encode(token);
    localStorage.setItem(ACCESS_TOKEN, encoded);
}

export async function getAccess(withCookies) {
    const encoded = localStorage.getItem(ACCESS_TOKEN);

    let access_token;

    if (!encoded) {
        access_token = await refetchAccess(withCookies);
    } else {
        access_token = base64decode(encoded);
        const exp = new DataView(access_token.buffer).getUint32(0, false);

        if (exp < Math.round(new Date().getTime() / 1000)) {
            access_token = await refetchAccess(withCookies);
        }
    }

    return await trySigningToken(access_token);
}

async function getActualDelayS() {
    const clientConfig = (await getSchema())?.logging?.client;

    const maxDelayS = clientConfig?.max_delay ?? MAX_EVENT_DELAY_S;
    const minDelayS = clientConfig?.min_delay ?? MIN_EVENT_DELAY_S;

    return Math.floor(Math.random() * (maxDelayS - minDelayS + 1) + minDelayS);
}

let eventFlushTimeout = null;

async function flushEventsDelayed(db) {
    if (eventFlushTimeout) clearTimeout(eventFlushTimeout);

    eventFlushTimeout = setTimeout(() => {
        flushEvents(db);
    }, (await getActualDelayS()) * 1000);
}

async function flushEvents(db, immediate) {
    const clientConfig = (await getSchema())?.logging?.client;

    const maxBuffer = clientConfig?.max_buffer ?? MAX_EVENT_BUFFER;
    const maxBatch = clientConfig?.max_buffer ?? MAX_EVENT_BATCH;

    const actualDelayS = await getActualDelayS();

    const now = new Date();
    now.setHours(now.getSeconds() - actualDelayS);
    const tsThreshold = now.toISOString();

    const eventsStore = db
        .transaction("events", "readwrite")
        .objectStore("events");

    const lvlIndex = eventsStore.index("lvl");

    const outEvents = immediate ? [immediate] : [];
    let lastCount = 0;

    lvlIndex.openCursor().onsuccess = async (evt) => {
        const cursor = evt.target.result;

        if (cursor) {
            lastCount = lvlIndex.count();

            if (cursor.value.ts < tsThreshold || lastCount > maxBuffer || outEvents.length < maxBatch) {
                outEvents.push(cursor.value);
                eventsStore.delete(cursor.primaryKey);

                cursor.continue();
                return;
            }
        }

        if (outEvents.length) {
            const req = await tryCompress(
                JSON.stringify(outEvents),
                'application/json',
                true
            );

            if (navigator.sendBeacon) {
                navigator.sendBeacon("/events", req.body);
            }

            if (lastCount) await flushEventsDelayed(db);
        }
    };
}

async function recordEvent(lvl, msg, fields) {
    const now = new Date();
    const flags = await getFlags();

    const event = {
        ts: now.toISOString(),
        lvl,
        version: '{{ version }}',
        path: location.pathname,
        correlation: getCorrelationId(),
        query: location.search ? location.search : null,
        fragment: location.hash ? location.hash : null,
        fields: fields ?? null,
        flags: Object.getOwnPropertyNames(flags).length ? flags : null,
        msg,
    };

    const eventsDB = indexedDB.open("OrdinaryEvents", 1);

    eventsDB.onerror = (evt) => {
        console.error(evt);
    };

    eventsDB.onupgradeneeded = (evt) => {
        const db = evt.target.result;

        const eventsStore = db.createObjectStore(
            "events",
            {keyPath: "ts", autoIncrement: true}
        );

        eventsStore.createIndex("lvl", "lvl", {unique: false});
    };

    eventsDB.onsuccess = (evt) => {
        const db = evt.target.result;

        if (lvl === 'error') {
            flushEvents(db, event);
        } else {
            const eventsStore = db
                .transaction("events", "readwrite")
                .objectStore("events")

            eventsStore.add(event);
            flushEventsDelayed(db);
        }
    };
}

export const events = {
    trace: (msg, fields) => recordEvent('trace', msg, fields),
    debug: (msg, fields) => recordEvent('debug', msg, fields),
    info: (msg, fields) => recordEvent('info', msg, fields),
    warn: (msg, fields) => recordEvent('warn', msg, fields),
    error: (msg, fields) => recordEvent('error', msg, fields),
};