import fastdom from "fastdom";

import { createComputedFactory } from "./createComputedFactory.js";
import { createJsxFactory } from "./createJsxFactory.js";
import { createNestedQueryFactory } from "./createNestedQueryFactory.js";
import { createRuleFactory } from "./createRuleFactory.js";
import { createUtilsFactory } from "./createUtilsFactory.js";
import { createVarFactory } from "./createVarFactory.js";
import { STYLESHEET_ELEMENT_CREATED_EVENT, STYLESHEET_ELEMENT_TOGGLE_EVENT } from "./events.js";
import { generateId } from "./utils/generateId.js";
import { objectToCss } from "./utils/objectToCss.js";

import type { CreateComputedFunction } from "./createComputedFactory.js";
import type { CreateJsxControlFunction, CreateJsxFunction } from "./createJsxFactory.js";
import type { CreateNestedQueryFunction } from "./createNestedQueryFactory.js";
import type {
    CreateClassNameFunction,
    CreateControlFunction,
    CreateRuleFunction,
    CreateVariantFunction,
} from "./createRuleFactory.js";
import type { BoolIfFunction, BoolNotFunction, BoolOrFunction, BoolSwitchFunction } from "./createUtilsFactory.js";
import type { CreateVarFunction, CreateVariableNameFunction, CreateVarsFunction } from "./createVarFactory.js";
import type { StylesheetElementCreatedEvent, StylesheetElementToggleEvent } from "./events.js";
import type { Properties as CSSProperties } from "csstype";
import type { createElement as reactCreateElement, forwardRef as reactForwardRef } from "react";

type PureScopedStyleSheetRules = { [P in keyof CSSProperties]: CSSProperties[P] | (string & { h?: string }) } & {
    [index: string]: any;
};

type PureScopedStyleSheet = {
    inc: number;
    id: string;
    className: string;
    settings: {
        /**
         * A unique string which identifies a whole stylesheet. This is useful when using
         * e.g. `usePureScopedStylesheet` in React and you do not want to have extra stylesheets
         * per rendered root.
         */
        reuse?: string;
        createElement?: typeof reactCreateElement;
        forwardRef?: typeof reactForwardRef;
        filterClassName?: (classNames: string[], scopedClassName: string, meta: PureScopedStyleSheet) => void;
        modifyRule?: (properties: Parameters<CreateRuleFunction>[0]) => void;
    };
    plugins: {
        filterClassName: PureScopedStyleSheet["settings"]["filterClassName"][];
        modifyRule: PureScopedStyleSheet["settings"]["modifyRule"][];
    };
    runPlugin: <Name extends keyof PureScopedStyleSheet["plugins"]>(
        name: Name,
        ...args: Parameters<PureScopedStyleSheet["plugins"][Name][0]>
    ) => void;
    /**
     * Keep internally track of original variable values instead of mapped and serialized.
     * This helps us to make computed variables awesome.
     */
    varsVal: Map<string, any>;
    rules: Map<string, PureScopedStyleSheetRules>;
    element: HTMLStyleElement;
    isExtension: boolean;
    /**
     * Allows references to the main `<style` element, also for extended stylesheets.
     * This can be useful for e.g. computed event dispatchers and listeners.
     */
    mainElement: HTMLStyleElement;
    /**
     * Extended stylesheet instances.
     */
    extended: Record<symbol, CreatePureScopedStylesheetCallbackReturn>;
    /**
     * Configured specificity IDs. See als `specify`.
     */
    specifiedIds: string[];
};

type CreatePureScopedStylesheetCallbackArgs = {
    meta: PureScopedStyleSheet;
    plugin: <Name extends keyof PureScopedStyleSheet["plugins"]>(
        name: Name,
        callback: PureScopedStyleSheet["plugins"][Name][0],
    ) => void;
    className: CreateClassNameFunction;
    rule: CreateRuleFunction;
    control: CreateControlFunction;
    varName: CreateVariableNameFunction;
    variable: CreateVarFunction;
    vars: CreateVarsFunction;
    nestedQuery: CreateNestedQueryFunction;
    variant: CreateVariantFunction;
    computed: CreateComputedFunction;
    boolIf: BoolIfFunction;
    boolSwitch: BoolSwitchFunction;
    boolNot: BoolNotFunction;
    boolOr: BoolOrFunction;
    jsx: CreateJsxFunction;
    jsxControl: CreateJsxControlFunction;
};

type CreatePureScopedStylesheetCallbackReturn<ParentReturn = any> = {
    meta: PureScopedStyleSheet;
    className: string;
    functions: Omit<CreatePureScopedStylesheetCallbackArgs, "meta">;
    element: PureScopedStyleSheet["element"];
    toggle: (active: boolean) => void;
    /**
     * In a world of website builders (e.g. WordPress), it can happen that other global
     * styles override our scoped stylesheet. For sure, we can use the `all: unset` CSS
     * property to cover first things, but what happens if a CSS selector has a higher
     * specifity than our scoped class? See this [example](https://i.imgur.com/gSKyl45.jpg).
     *
     * In this example, the following specifies are given:
     *
     * 1. `.wp-block-post-content a:where(:not(.wp-element-button))` -> 31
     * 2. `.a387521fba9-ext-7-260` -> 0
     *
     * In an ideal world, it should look like this:
     *
     * 1. `#my-awesome-id .a387521fba9-ext-7-260` -> 110
     * 2. `.wp-block-post-content a:where(:not(.wp-element-button))` -> 31
     *
     * Usage: `stylesheet.specify("my-awesome-id")` and add the ID to your most-upwards `div`.
     */
    specify: (specificId: string) => void;
    extend: <E extends Record<string, any>>(
        /**
         * Pass in an unique symbol so stylesheets do not get created twice. This is useful
         * e.g. in imports of React components and the VDOM rerendering cycle.
         */
        symbol: symbol,
        /**
         * Create your new rules in this callback and return classnames, controls, ...
         */
        callback: (args: CreatePureScopedStylesheetCallbackArgs, parentReturn: ParentReturn) => E,
        /**
         * If `true`, the variables will not be attached to the main stylesheet class. That means, you need
         * to add the class of this extension to your HTML tag.
         *
         * Additionally, the variable names are also not attached to the extension itself, it reuses the prefix
         * of the main stylesheet. In general, it results to the following mechanism:
         *
         * ```
         *                      / extends detached: true (own Symbol) \
         *                     /                                       \
         * main stylesheet -- |                                         | extends detached: false -> reuse variables of the extensions
         *                     \                                       /
         *                      \ extends detached: true (own Symbol) /
         * ```
         *
         * See example: Each content blocker fills a set of variables, and the variables are reused in another stylesheet.
         *
         * ![Example](https://i.imgur.com/L2PjnRA.png).
         */
        detached?: boolean,
        /**
         * This chain represents the extension chain.
         */
        chain?: symbol[],
    ) => E & ParentReturn & CreatePureScopedStylesheetCallbackReturn<ParentReturn & E>;
};

const REUSE_STYLESHEET: Record<string, ReturnType<typeof createPureScopedStylesheet>> = {};

function getExportedIdentifiers(result: CreatePureScopedStylesheetCallbackReturn<any>) {
    const { className, element, extend, functions, meta, toggle, specify, ...rest } = result;
    return rest;
}

const SPECIFY_SELECTOR_SPLIT = /,(?![^(]*\))/;
function specifySelector(id: string, specificIds: string[], selector: string) {
    const selectors = selector.indexOf(",") === -1 ? [selector] : selector.split(SPECIFY_SELECTOR_SPLIT);
    const newSelectors: string[] = [];
    for (const selector of selectors) {
        newSelectors.push(selector);

        if (selector.startsWith(`.${id}`)) {
            for (const specificId of specificIds) {
                newSelectors.push(`#${specificId} ${selector}`);
            }
        }
    }
    return newSelectors.join(",");
}

const createPureScopedStylesheet = <T extends Record<string, any> | void>(
    callback: (args: CreatePureScopedStylesheetCallbackArgs) => T,
    settings: PureScopedStyleSheet["settings"] = {},
    /**
     * For internal usage only to make `extend` work.
     */
    {
        element: parentElement,
        id: parentId,
        inc: parentInc,
        varsVal: parentVarsVal,
        extended: parentExtended,
        specifiedIds: parentSpecifiedIds,
        plugins: parentPlugins,
        toggle: parentToggle,
        specify: parentSpecify,
        detached: extendDetached,
    }: Partial<{
        element: HTMLStyleElement;
        id: string;
        inc: PureScopedStyleSheet["inc"];
        varsVal: PureScopedStyleSheet["varsVal"];
        extended: PureScopedStyleSheet["extended"];
        specifiedIds: PureScopedStyleSheet["specifiedIds"];
        plugins: PureScopedStyleSheet["plugins"];
        toggle: CreatePureScopedStylesheetCallbackReturn["toggle"];
        specify: CreatePureScopedStylesheetCallbackReturn["specify"];
        detached: Parameters<CreatePureScopedStylesheetCallbackReturn["extend"]>[2];
    }> = {},
): T & CreatePureScopedStylesheetCallbackReturn<T> => {
    const { reuse } = settings;
    if (reuse && !parentId && REUSE_STYLESHEET[reuse]) {
        return REUSE_STYLESHEET[reuse] as T & CreatePureScopedStylesheetCallbackReturn<T>;
    }

    const extended = parentExtended || {};
    const specifiedIds = parentSpecifiedIds || [];
    const id = parentId ? `${parentId}-ext-${Object.getOwnPropertySymbols(extended).length}` : generateId(4);
    const element = document.createElement("style") as PureScopedStyleSheet["element"];
    element.setAttribute("skip-rucss", "true");
    const meta: PureScopedStyleSheet = {
        inc: parentInc || 1, // not using `0` as it is considered falsy in conditions,
        id,
        varsVal: parentVarsVal || new Map(),
        settings,
        plugins: parentPlugins || {
            filterClassName: [settings.filterClassName].filter(Boolean),
            modifyRule: [settings.modifyRule].filter(Boolean),
        },
        runPlugin: (name, ...args) => {
            for (const p of meta.plugins[name]) {
                (p as any)(...args);
            }
        },
        className: `.${id}`,
        rules: new Map(),
        isExtension: !!parentId,
        element,
        mainElement: parentElement || element,
        specifiedIds,
        extended,
    };

    const toggle =
        parentToggle ||
        ((active: boolean) =>
            fastdom.mutate(() => {
                const { element } = meta;
                const [head] = document.getElementsByTagName("head");

                // Collect all toggle-able elements including extended stylesheets
                const extendedElements = Object.getOwnPropertySymbols(extended).map((sym) => extended[sym].element);
                const elements = [element, ...extendedElements];
                for (const e of elements) {
                    document.dispatchEvent(
                        new CustomEvent<StylesheetElementToggleEvent>(STYLESHEET_ELEMENT_TOGGLE_EVENT, {
                            detail: {
                                stylesheet: meta,
                                active,
                            },
                        }),
                    );

                    if (active) {
                        head.appendChild(e);
                    } else {
                        head.removeChild(e);
                    }
                }
            }));

    const specify =
        parentSpecify ||
        ((specificId: string) => {
            if (specifiedIds.indexOf(specificId) > -1) {
                return;
            }
            specifiedIds.push(specificId);

            fastdom.mutate(() => {
                const regexp = new RegExp(`^[ ]*(\\.${id}.*) {`, "gm");
                const replacer = (_: string, selector: string) => `${specifySelector(id, [specificId], selector)} {`;
                for (const element of [
                    meta.mainElement,
                    ...Object.getOwnPropertySymbols(extended).map((sym) => extended[sym].element),
                ]) {
                    const { textContent } = element;
                    element.textContent = textContent.replace(regexp, replacer);
                }
            });
        });

    const varFactory = createVarFactory(meta, extendDetached);
    const ruleFactory = createRuleFactory(meta, varFactory);
    const computed = createComputedFactory(meta, varFactory);
    const nestedQuery = createNestedQueryFactory(meta, varFactory);
    const utilsFactory = createUtilsFactory(meta, varFactory);
    const jsxFactory = createJsxFactory(meta, ruleFactory);

    const passFunctions: CreatePureScopedStylesheetCallbackReturn["functions"] = {
        ...ruleFactory,
        ...varFactory,
        ...utilsFactory,
        ...jsxFactory,
        nestedQuery,
        computed,
        plugin: (name, callback) => {
            meta.plugins[name].push(callback as any);
        },
    };

    const result = callback({
        meta,
        ...passFunctions,
    });

    fastdom.mutate(() => {
        element.textContent = objectToCss(meta.rules);
        for (const target of [element, document]) {
            target.dispatchEvent(
                new CustomEvent<StylesheetElementCreatedEvent>(STYLESHEET_ELEMENT_CREATED_EVENT, {
                    detail: {
                        stylesheet: meta,
                    },
                }),
            );
        }
    });

    // Allow to extend the stylesheet with the same meta descriptors
    const thisInc = meta.inc;
    const extend: CreatePureScopedStylesheetCallbackReturn["extend"] = function (
        symbol,
        callback,
        detached,
        chain = [],
    ) {
        const { extended, mainElement } = meta;

        // Compose extended results so `.extend` can be chained
        const useResult = Object.assign(
            { _chain: chain },
            result,
            ...chain.map((sym) => getExportedIdentifiers(extended[sym])),
        );

        if (!extended[symbol]) {
            extended[symbol] = createPureScopedStylesheet((args) => callback(args, useResult), settings, {
                toggle,
                detached: detached || false,
                ...meta,
                // When detached, we need to ensure that increment starts from the same point
                inc: detached ? thisInc : meta.inc,
            });

            const alreadyExported = Object.keys(useResult);
            for (const exported of Object.keys(getExportedIdentifiers(extended[symbol]))) {
                if (alreadyExported.indexOf(exported) > -1) {
                    console.warn(`"${exported}" detected in multiple stylesheets. This will lead to side effects.`);
                }
            }

            // Attach to DOM if parent is also attached to DOM
            if (mainElement.isConnected) {
                toggle(true);
            }
        }

        if (chain.indexOf(symbol) === -1) {
            chain.push(symbol);
        }

        const chainedExtend: CreatePureScopedStylesheetCallbackReturn["extend"] = (
            argSymbol,
            argCallback,
            argDetached,
        ) => extend(argSymbol, argCallback, argDetached, chain);

        return {
            ...useResult,
            ...extended[symbol],
            extend: chainedExtend,
        };
    };

    const returnResult = {
        ...result,
        meta,
        element: meta.element,
        className: meta.id,
        specify,
        toggle,
        extend,
        functions: passFunctions,
    };

    if (reuse && !parentId) {
        REUSE_STYLESHEET[reuse] = returnResult;
    }

    return returnResult;
};

/**
 * Create a `style` from a given string. If you pass a `unique` it will override the stylesheet
 * content when already exist.
 */
const createStylesheet = (css: string, unique?: string | { id: string; overwrite?: boolean }, footer = false) => {
    const { id: uniqueId, overwrite = true } = typeof unique === "string" ? { id: unique } : unique || {};
    const id = `pure-css-stylesheet-${uniqueId || generateId(5)}`;
    let element = document.getElementById(id);

    if (!element) {
        element = document.createElement("style");
        element.setAttribute("skip-rucss", "true");
        (element.style as any).type = "text/css";
        element.id = id;

        fastdom.mutate(() => {
            document.getElementsByTagName(footer ? "body" : "head")[0].appendChild(element);
        });
    } else if (!overwrite) {
        return element.remove;
    }

    element.innerHTML = css;

    return element.remove;
};

export {
    specifySelector,
    createPureScopedStylesheet,
    createStylesheet,
    type PureScopedStyleSheetRules,
    type PureScopedStyleSheet,
    type CreatePureScopedStylesheetCallbackArgs,
    type CreatePureScopedStylesheetCallbackReturn,
};
