import { tmd } from "./core";
import { LinkedList } from "./linkedlist";
import { LogData, StreamListEvent, StreamEvent, EventStream, SubscriptionId, ProcessingContext, traxEvents, ProcessingContextData, TraxLogTraxProcessingCtxt, TraxLogPropGet, TraxLogProcDirty, TraxLogPropSet, TraxLogObjectCreate, TraxLogObjectDispose, TraxLogProcSkipped, ConsoleOutput, traxEventTypes } from "./types";

/**
 * Resolve function used in the await map
 * This function will check the event details
 * If the event matches the awaitEvent args, then it resolves the pending promise and returns true
 * Otherwise it will return false and keep the promis unfullfiled
 */
type awaitResolve = (e: StreamEvent) => boolean;

const CONSOLE_OUTPUT_VALUES = new Set<ConsoleOutput>(["", "Main", "AllButGet", "All"]);
const STL_TRX = "color: #65abff";
const STL_NONE = 'color: ';
const STL_DATA = "color: #e08d00;font-weight:bold"; // orange
const STL_EVT_TYPE = "color: #00ff00;font-weight:bold"; // green

/**
 * Create an event stream
 * The key passed as argument will be used to authorize events with reserved types
 * @param internalSrcKey
 * @returns
 */
export function createEventStream(internalSrcKey: any, dataStringifier?: (data: any) => string, onCycleComplete?: () => void): EventStream {
    let size = 0;
    let maxSize = 1000;
    let head: StreamListEvent | undefined;
    let tail: StreamListEvent | undefined;
    const awaitMap = new Map<string, awaitResolve[]>();
    const consumers: ((e: StreamEvent) => void)[] = [];
    let consoleOutput: ConsoleOutput = "";

    // ----------------------------------------------
    // cycle id managment
    let cycleCount = -1; // cycle counter, incremented for each new cycle
    let eventCount = -1; // event counter, incremented for each new event, reset for each new cycle
    let cycleCompletePromise: null | Promise<void> = null;
    let cycleTimeMs = 0; // Time stamp used to process elapsed time values

    function generateId() {
        if (cycleCompletePromise === null) {
            // no current cycle defined
            eventCount = -1;
            cycleCount++;
            cycleCompletePromise = Promise.resolve().then(processCycleEnd);
            logCycleEvent(traxEvents.CycleStart);
        }
        eventCount++;
        return cycleCount + ":" + eventCount;
    }

    function processCycleEnd() {
        emptyPcStack();
        logCycleEvent(traxEvents.CycleComplete);
        cycleCompletePromise = null;
    }

    function logCycleEvent(type: "!CS" | "!CC") {
        if (type === traxEvents.CycleComplete && onCycleComplete) {
            onCycleComplete();
        }
        const ts = Date.now();
        const elapsedTime = cycleTimeMs !== 0 ? ts - cycleTimeMs : 0;
        cycleTimeMs = ts;
        logEvent(type, { elapsedTime }, internalSrcKey);
    }

    // ----------------------------------------------
    // Processing context

    const START = 1, PAUSE = 2, END = 3;
    // Processing context stack
    const pcStack = new LinkedList<ProcessingContext>();

    function stackPc(pc: ProcessingContext) {
        pcStack.add(pc);
    }

    function unstackPc(pc: ProcessingContext) {
        let last = pcStack.shift();
        while (last && last !== pc) {
            error("[trax/processing context] Contexts must be ended or paused before parent:", last.id);
            last = pcStack.shift();
        }
    }

    function emptyPcStack() {
        // check that the Processing Context stack is empty at the end of a cycle
        if (pcStack.size !== 0) {
            let pc = pcStack.shift();
            while (pc) {
                error("[trax/processing context] Contexts must be ended or paused before cycle ends:", pc.id);
                pc = pcStack.shift();
            }
        }
    }

    function createProcessingContext(logId: string, data: ProcessingContextData): ProcessingContext {
        let state = START;
        const evtData = {
            processId: logId,
            ...data
        }
        const pc = {
            get id() {
                return logId;
            },
            pause() {
                if (state !== START) {
                    error("[trax/processing context] Only started or resumed contexts can be paused:", logId);
                } else {
                    unstackPc(pc);
                    logEvent(traxEvents.ProcessingPause, evtData, internalSrcKey);
                    state = PAUSE;
                }
            },
            resume() {
                if (state !== PAUSE) {
                    error("[trax/processing context] Only paused contexts can be resumed:", logId);
                } else {
                    stackPc(pc);
                    logEvent(traxEvents.ProcessingResume, evtData, internalSrcKey);
                    state = START;
                }
            },
            end() {
                if (state === END) {
                    error("[trax/processing context] Contexts cannot be ended twice:", logId);
                } else {
                    unstackPc(pc);
                    logEvent(traxEvents.ProcessingEnd, evtData, internalSrcKey);
                    state = END;
                }
            }
        }
        stackPc(pc);
        return pc;
    }

    function error(...data: LogData[]) {
        logEvent(traxEvents.Error, mergeMessageData(data));
    }

    function logEvent(type: string, data?: LogData, src?: any, parentId?: string) {
        let evt: StreamListEvent;
        if (size >= maxSize && maxSize > 1) {
            evt = head!;
            head = head!.next;
            size--;
            evt.id = "";
            evt.type = "";
            evt.next = evt.data = evt.parentId = undefined;
        } else {
            evt = { id: "", type: "" }
        }

        format(internalSrcKey, evt, type, dataStringifier, data, src);
        evt.id = generateId();
        evt.parentId = parentId;
        if (evt.type !== "") {
            if (head === undefined) {
                head = tail = evt;
                size = 1;
            } else {
                // append to tail
                tail!.next = evt;
                tail = evt;
                size++;
            }
            for (const c of consumers) {
                try {
                    c({ id: evt.id, type: evt.type, data: evt.data });
                } catch (ex) { }
            }
            resolveAwaitPromises(evt.type, evt);
        }
        if (consoleOutput) {
            // output the event on the console (debug mode)
            const etp = evt.type;
            let ok = false;
            if (consoleOutput === "All") {
                ok = true;
            } else if (consoleOutput === "AllButGet") {
                ok = etp !== traxEvents.Get;
            } else if (consoleOutput === "Main") {
                if (!traxEventTypes.has(etp)) {
                    ok = true; // custom event
                } else {
                    ok = (etp === traxEvents.Error
                        || etp === traxEvents.Info
                        || etp === traxEvents.Warning
                        || etp === traxEvents.ProcessingResume
                        || etp === traxEvents.ProcessorDirty
                        || etp === traxEvents.ProcessorSkipped
                        || etp === traxEvents.Set
                        || etp === traxEvents.ProcessingStart
                    );
                }
            }
            if (ok && etp !== traxEvents.CycleStart && etp !== traxEvents.CycleComplete) {
                let data = formatEventData(evt.type, evt.data, true);
                let pid = "";
                if (evt.parentId) {
                    pid = " - parent:%c" + evt.parentId;
                }
                const msg = `%cTRX %c${evt.id} %c${evt.type}${data ? "%c - " + data : ""}${pid}`;
                const msgNoStyle = msg.replaceAll("%c", "");
                const count = Math.floor((msg.length - msgNoStyle.length) / 2);
                const args: any[] = [msg, STL_TRX, STL_NONE, STL_EVT_TYPE];
                for (let i = 3; count > i; i++) {
                    args.push(i % 2 ? STL_NONE : STL_DATA);
                }
                console.log.apply(console, args);
            }
        }
        return evt;
    }

    function resolveAwaitPromises(eventType: string, e: StreamEvent) {
        const resolveList = awaitMap.get(eventType);
        if (resolveList) {
            const ls = resolveList.filter((resolve) => {
                // if resolve returns true => resolution was ok, so we remove the resolve callback from the list
                return !resolve({ id: e.id, type: e.type, data: e.data, parentId: e.parentId });
            });
            if (ls.length === 0) {
                awaitMap.delete(eventType);
            } else {
                awaitMap.set(eventType, ls);
            }
        }
    }

    return {
        get consoleOutput() {
            return consoleOutput
        },
        set consoleOutput(v: ConsoleOutput) {
            if (CONSOLE_OUTPUT_VALUES.has(v)) {
                consoleOutput = v;
            } else {
                const values = `"${[...CONSOLE_OUTPUT_VALUES].join('" or "')}"`;
                console.log(`%cTRX %cInvalid consoleOutput value - should be either %c${values}`, STL_TRX, 'color: ', STL_DATA);
            }
        },
        event(type: string, data?: LogData, src?: any) {
            logEvent(type, data, src);
        },
        info(...data: LogData[]) {
            logEvent(traxEvents.Info, mergeMessageData(data));
        },
        warn(...data: LogData[]) {
            logEvent(traxEvents.Warning, mergeMessageData(data));
        },
        error,
        set maxSize(sz: number) {
            const prev = maxSize;
            if (sz < 0) {
                maxSize = -1; // no limits
            } else if (sz < 2) {
                maxSize = 2; // minimum size to remove corner cases
            } else {
                maxSize = sz;
            }
            if (maxSize > 0 && maxSize < prev && maxSize < size) {
                // we need to remove the oldest elements
                let count = size - maxSize;
                while (head && count) {
                    head = head.next;
                    count--;
                    size--;
                }
            }
        },
        startProcessingContext(data: ProcessingContextData, src?: any): ProcessingContext {
            // prevent reserved names usage
            let name = data.name;
            if (name.charAt(0) === "!" && src !== internalSrcKey) {
                error(`Processing Context name cannot start with reserved prefix: ${name}`);
                data.name = name.replace(/\!+/, "");
            }
            const last = pcStack.peek();
            const parentId = last ? last.id : undefined;
            const evt = logEvent(traxEvents.ProcessingStart, data, internalSrcKey, parentId);
            return createProcessingContext(evt.id, data);
        },
        get maxSize(): number {
            return maxSize;
        },
        get size() {
            return size;
        },
        scan(eventProcessor: (itm: StreamEvent) => void | boolean) {
            let itm = head, process = true;
            while (process && itm) {
                try {
                    if (eventProcessor(itm) === false) {
                        process = false;
                    };
                    itm = itm.next;
                } catch (ex) { }
            }
        },
        lastEvent(): StreamEvent | undefined {
            return tail;
        },
        async awaitEvent(eventType: string, targetData?: string | number | boolean | Record<string, string | number | boolean>): Promise<StreamEvent> {
            if (eventType === "" || eventType === "*") {
                logEvent(traxEvents.Error, `[trax/eventStream.await] Invalid event type: '${eventType}'`);
                return { id: tail!.id, type: tail!.type, data: tail!.data };
            }
            let resolveList = awaitMap.get(eventType);
            if (resolveList === undefined) {
                resolveList = [];
                awaitMap.set(eventType, resolveList);
            }

            let r: any, p = new Promise((resolve: (e: StreamEvent) => void) => {
                r = resolve;
            });

            resolveList.push((e: StreamEvent) => {
                if (checkPropMatch(e, targetData)) {
                    r(e);
                    return true;
                }
                return false;
            });
            return p;
        },
        subscribe(eventType: string | "*", callback: (e: StreamEvent) => void): SubscriptionId {
            let fn: (e: StreamEvent) => void;
            if (eventType === "*") {
                fn = (e: StreamEvent) => callback(e);
            } else {
                fn = (e: StreamEvent) => {
                    if (e.type === eventType) {
                        callback(e);
                    }
                };
            }
            consumers.push(fn);
            return fn;
        },
        unsubscribe(subscriptionId: SubscriptionId): boolean {
            const idx = consumers.indexOf(subscriptionId as any);
            if (idx > -1) {
                consumers.splice(idx, 1);
                return true;
            }
            return false;
        }
    }
}

function checkPropMatch(e: StreamEvent, targetData?: string | number | boolean | Record<string, string | number | boolean | RegExp>): boolean {
    if (targetData === undefined || e.data === undefined) return true;
    const data = JSON.parse(e.data);
    if (typeof targetData !== "object" && targetData !== null) {
        return data === targetData;
    } else if (targetData !== null && data !== null && typeof data === "object") {
        for (const k of Object.keys(targetData)) {
            const value = (data as any)[k];
            const target = targetData[k];
            if (target instanceof RegExp) {
                if (typeof value === "string") {
                    if (value.match(target) === null) return false;
                } else {
                    return false;
                }
            } else if (value !== targetData[k]) {
                return false;
            }
        }
        return true;
    }
    return false;
}

function mergeMessageData(data: LogData[]): LogData | undefined {
    let curMessage = "";
    const output: LogData[] = [];
    for (let d of data) {
        const tp = typeof d;
        if (tp === "string" || tp === "number" || tp === "boolean" || d === null) {
            if (curMessage) {
                curMessage += " " + d;
            } else {
                curMessage = "" + d;
            }
        } else {
            if (curMessage) {
                output.push(curMessage);
                curMessage = "";
            }
            output.push(d);
        }
    }
    if (curMessage) {
        output.push(curMessage);
    }
    if (output.length === 0) return undefined;
    if (output.length === 1) {
        return output[0];
    }
    return output;
}

function format(internalSrcKey: any, entry: StreamListEvent, type: string, dataStringifier?: (data: any) => string, data?: LogData, src?: any) {
    let hasError = false;
    let errMsg = "";
    if (type === "") {
        hasError = true;
        errMsg = "Event type cannot be empty";
    } else {
        if (type.charAt(0) === "!"
            && src !== internalSrcKey
            && type !== traxEvents.Error
            && type !== traxEvents.Warning
            && type !== traxEvents.Info) {
            // reserved
            hasError = true;
            errMsg = "Event type cannot start with reserved prefix: " + type;
        } else {
            entry.type = type;
            if (data !== undefined) {
                try {
                    if (dataStringifier) {
                        entry.data = dataStringifier(data);
                    } else {
                        entry.data = JSON.stringify(data);
                    }
                } catch (ex) {
                    hasError = true;
                    errMsg = "Event strinfication error: " + ex;
                }
            } else {
                data = undefined;
            }
        }
    }
    if (hasError) {
        // transform event into an error event
        entry.type = traxEvents.Error;
        entry.data = errMsg;
    }
}

export function formatEventData(eventType: string, data?: any, styleDelimiter = false) {
    if (!data || !eventType || eventType.charAt(0) !== "!") return data;
    const c = styleDelimiter ? "%c" : "";
    try {
        const sd = JSON.parse("" + data);

        if (eventType === traxEvents.CycleStart || eventType === traxEvents.CycleComplete) {
            return `${c}0`; // 0 = elapsedTime
        } else if (eventType === traxEvents.Info
            || eventType === traxEvents.Warning
            || eventType === traxEvents.Error) {
            return `${c}${data.replace(/"/g, "")}`;
        } else if (eventType === traxEvents.ProcessingPause
            || eventType === traxEvents.ProcessingResume
            || eventType === traxEvents.ProcessingEnd) {
            const d = JSON.parse(data);
            return `${(d as any).processId}`;
        } else if (eventType === traxEvents.ProcessingStart) {
            const d = sd as TraxLogTraxProcessingCtxt;
            if (d.name === "!StoreInit") {
                return `${d.name} (${c}${d.storeId}${c})`;
            } else if (d.name === "!Compute") {
                const R = d.isRenderer ? "R" : "";
                return `${d.name} #${d.computeCount} (${c}${d.processorId}${c}) P${d.processorPriority}${R} ${d.trigger}`;
            } else if (d.name === "!Reconciliation") {
                return `${d.name} #${d.index} - ${d.processorCount} processor${d.processorCount !== 1 ? "s" : ""}`;
            } else if (d.name === "!ArrayUpdate") {
                return `${d.name} (${c}${d.objectId}${c})`;
            } else {
                return `${c}${(d as any).name}`;
            }
        } else if (eventType === traxEvents.New) {
            const d = sd as TraxLogObjectCreate;
            if (d.objectId === undefined) return data;
            return `${d.objectType}: ${c}${d.objectId}`;
        } else if (eventType === traxEvents.Dispose) {
            const d = sd as TraxLogObjectDispose;
            if (d.objectId === undefined) return data;
            return `${c}${d.objectId}`;
        } else if (eventType === traxEvents.Get) {
            const d = sd as TraxLogPropGet;
            return `${c}${d.objectId}.${d.propName}${c} -> ${c}${stringify(d.propValue)}`;
        } else if (eventType === traxEvents.Set) {
            const d = sd as TraxLogPropSet;
            return `${c}${d.objectId}.${d.propName}${c} = ${c}${stringify(d.toValue)}${c} (prev: ${stringify(d.fromValue)})`;
        } else if (eventType === traxEvents.ProcessorDirty) {
            const d = sd as TraxLogProcDirty;
            return `${c}${d.processorId}${c} <- ${c}${d.objectId}.${d.propName}`;
        } else if (eventType === traxEvents.ProcessorSkipped) {
            const d = sd as TraxLogProcSkipped;
            return `${c}${d.processorId}`;
        }
    } catch (ex) { }
    return data;
}

function stringify(v: any) {
    if (v === undefined) {
        return "undefined";
    } else if (v === null) {
        return "null";
    } else if (typeof v === "object") {
        const md = tmd(v);
        if (md) return md.id;
        return JSON.stringify(v);
    } else if (typeof v === "string") {
        return "'" + v.replace(/\'/g, "\\'") + "'"
    } else {
        return "" + v;
    }
}
