import {
    ref,
    watch,
    isRef,
    toValue,
    getCurrentInstance,
    effectScope,
    onScopeDispose,
    getCurrentScope,
    type MaybeRefOrGetter,
    type Ref,
    type ComponentInternalInstance,
    type EffectScope,
} from "vue";

import { getConfig } from "@/utils/config";
import {
    isDefined,
    blankIfUndefined,
    getValueByPath,
    isTrueish,
} from "@/utils/helpers";

import type { ClassBinding, ComponentClass, TransformFunction } from "@/types";

// named tuple as prop definition
type ComputedClass = readonly [
    className: string,
    defaultClass: string,
    suffix?: MaybeRefOrGetter<string | undefined> | null,
    apply?: MaybeRefOrGetter<boolean> | null,
];

/** Helper function to get all active classes from a class binding list */
export const getActiveClasses = (
    classes: MaybeRefOrGetter<ClassBinding[]>,
): string[] => {
    const values = toValue(classes);
    if (!values) return [];
    return values.flatMap((bind) =>
        Object.keys(bind)
            .filter((key) => key && bind[key])
            .flatMap((v) => v.split(" ")),
    );
};

type DefineClassesOptions = {
    /**
     * Pass a custom effect scope.
     * By default a new effect scope is created.
     * An error will be thrown if no current scope or a custom scope is given.
     * @default effectScope()
     */
    scope?: EffectScope;
    /**
     * Pass a custom props object which will be watched on additionaly to the current component instance props.
     * this will recompute the class bind property when the class property change.
     * @default vm.proxy?.$props
     */
    props?: Record<string, any>;
    /**
     * Pass a custom component instance.
     * By default the current component instance is used.
     */
    vm?: ComponentInternalInstance;
};

export function defineClasses(
    ...args: [...ComputedClass[], DefineClassesOptions]
): Ref<ClassBinding[]>;

export function defineClasses(
    ...args: [...ComputedClass[]]
): Ref<ClassBinding[]>;

/**
 * Calculate dynamic classes based on class definitions
 */
export function defineClasses(
    ...args: ComputedClass[] | [...ComputedClass[], DefineClassesOptions]
): Ref<ClassBinding[]> {
    // extract last argument if its the option object
    const options = Array.isArray(args[args.length - 1])
        ? undefined
        : (args[args.length - 1] as DefineClassesOptions);

    // get class defintion list based on options are given or not
    const classDefinitions = (
        Array.isArray(args[args.length - 1]) ? args : args.slice(0, -1)
    ) as ComputedClass[];

    // getting a hold of the internal instance of the component in setup()
    const vm = options?.vm || getCurrentInstance();
    if (!vm)
        throw new Error(
            "defineClasses must be called within a component setup function.",
        );
    // check if there is no current active effect scope given
    if (!getCurrentScope() && !options?.scope)
        throw new Error(
            "defineClasses must be called within a current active effect scope.",
        );

    // create an effect scope object to capture reactive effects
    const scope = options?.scope || effectScope();

    // check if there is a current active effect scope
    if (getCurrentScope())
        // Registers a dispose callback on the current active effect scope.
        // The callback will be invoked when the associated effect scope is stopped.
        onScopeDispose(() => {
            // stop all effects when appropriate
            if (scope) scope.stop();
        });

    // reactive classes container
    const classes = ref<ClassBinding[]>([]);

    classes.value = classDefinitions.map((defintion, index) => {
        const className = defintion[0];
        const defaultClass = defintion[1];
        const suffix = defintion[2];
        const apply = defintion[3];

        function getClassBind(): ClassBinding {
            // compute class based on definition parameter
            const computedClass = computeClass(
                vm!,
                className,
                defaultClass,
                toValue(suffix) || undefined,
            );

            // if apply is not defined or true
            const applied = !isDefined(apply) || toValue(apply);

            // return class bind property
            return { [computedClass]: applied };
        }

        // run all watcher and computed properties in an active effect scope
        scope.run(() => {
            // recompute the class bind property when the class property change
            watch(
                [
                    () => vm.proxy?.$props[className],
                    () => (options?.props ? options?.props[className] : null),
                ],
                () => {
                    // recompute the class bind property
                    const classBind = getClassBind();
                    // update class binding property by class index
                    classes.value[index] = classBind;
                },
            );

            // if suffix is defined, watch suffix changed and recalculate class
            if (isDefined(suffix) && isRef(suffix)) {
                watch(suffix, (value, oldValue) => {
                    // only recompute when value has really changed
                    if (value === oldValue) return;
                    // recompute the class bind property
                    const classBind = getClassBind();
                    // update class binding property by class index
                    classes.value[index] = classBind;
                });
            }

            // if apply is defined, watch apply changed and update apply state (no need of recalculation here)
            if (isDefined(apply) && isRef(apply)) {
                watch(apply, (applied, oldValue) => {
                    // only change apply when value has really changed
                    if (applied === oldValue) return;
                    // get class binding property by class index
                    const classBind = classes.value[index];
                    // update the apply class binding state
                    Object.keys(classBind).forEach(
                        (key) => (classBind[key] = applied),
                    );
                    // update the class binding property by class index
                    classes.value[index] = classBind;
                });
            }
        });

        // return computed class based on parameter
        return getClassBind();
    });

    // return reactive classes
    return classes;
}

/**
 * Compute a class by a field name
 */
function computeClass(
    vm: ComponentInternalInstance,
    field: string,
    defaultValue: string,
    suffix = "",
): string {
    // get component instance props
    const props = getProps(vm);

    const componentKey: string = vm.proxy?.$options.configField;
    if (!componentKey)
        throw new Error("component must define the 'configField' option.");

    // get the component config if it's not overridden by current instance
    const config = isTrueish(props.override) ? {} : getConfig();

    // --- Override Definition ---

    // define instance override
    const instanceOverride: boolean = isTrueish(props.override);

    // define config override
    const configOverride =
        // do not have to check if config is already overridden
        instanceOverride ||
        // check root config override property
        getValueByPath(config, "override") ||
        // check component field config override property
        getValueByPath(config, `${componentKey}.${field}.override`) ||
        // check component config override property
        getValueByPath(config, `${componentKey}.override`);

    // --- Class Definition ---

    let instanceClassString: string | undefined = undefined;
    let configClassString: string | undefined = undefined;
    let defaultClassString: string | undefined = undefined;

    // procsess instance class definition if available
    const instanceClass: ComponentClass | undefined =
        // get instance class definition
        getValueByPath(props, field);
    // compile instance class
    instanceClassString = compileClass(instanceClass, props, suffix);

    if (!instanceOverride) {
        if (!configOverride) {
            // process default class definition if not overridden by instance or config
            defaultClassString = applySuffix(defaultValue, suffix);
        }

        // process config class definition if not overriden by instance
        const configClass: ComponentClass | undefined =
            // get config class definition
            getValueByPath(config, `${componentKey}.${field}.class`) ||
            getValueByPath(config, `${componentKey}.${field}`);
        // compile config class
        configClassString = compileClass(configClass, props, suffix);
    }

    // --- Define Applied Classes ---

    // add default classes if available
    // add config classes if available
    // add instance classes if available
    let appliedClasses = [
        defaultClassString ?? "",
        configClassString ?? "",
        instanceClassString ?? "",
    ]
        .join(" ")
        .trim()
        .replace(/\s\s+/g, " ");

    // --- Tranform Classes ---

    // get component config transform function
    let transformClasses: TransformFunction | undefined = getValueByPath(
        config,
        `${componentKey}.transformClasses`,
    );
    if (!transformClasses)
        // get root config transform function
        transformClasses = getValueByPath(config, "transformClasses");

    // apply transform function if available
    if (typeof transformClasses === "function")
        appliedClasses = transformClasses(appliedClasses);

    return appliedClasses;
}

/** Compile a component class definition into a string. */
function compileClass(
    classDefinition: ComponentClass | undefined,
    props: ReturnType<typeof getProps>,
    suffix: string,
): string {
    // if definiton is undefined return empty class string
    if (typeof classDefinition === "undefined") return "";

    let classBinding: ClassBinding | ClassBinding[];

    if (typeof classDefinition === "function")
        // call class definition function
        classBinding = classDefinition(suffix, props) ?? "";
    else classBinding = classDefinition;

    let classString = "";
    if (Array.isArray(classBinding)) {
        classString = classBinding
            // transform the classBinding into a string
            .map(processClassBinding)
            // join all classes into one string
            .join(" ");
    } else if (classBinding) {
        // transform the classBinding into a string
        classString = processClassBinding(classBinding);
    }

    // if suffix is not already applied by the classFunction
    if (typeof classDefinition !== "function")
        // apply suffix to the class string
        classString = applySuffix(classString, suffix);

    return classString;
}

/** Transform a classBinding object into a string. */
function processClassBinding(classBinding: ClassBinding): string {
    if (typeof classBinding === "string") return classBinding;

    if (typeof classBinding === "object")
        return (
            Object.keys(classBinding)
                // filter by the truthiness of the data property
                .filter((key) => classBinding[key])
                // join all classes into one string
                .join(" ")
        );

    return "";
}

/** Add a suffix to each word of an input string. */
function applySuffix(value: string, suffix: string): string {
    return blankIfUndefined(value)
        .split(" ")
        .map((cls) => {
            if (cls.includes("{*}")) {
                return cls.replace(/\{\*\}/g, blankIfUndefined(suffix));
            } else {
                return cls + blankIfUndefined(suffix);
            }
        })
        .join(" ");
}

/** Get all props form an component instance. */
function getProps(vm: ComponentInternalInstance): Record<string, any> {
    let props = vm.proxy?.$props || {};

    // get all props which ends with "Props", these are compressed parent props
    // append these parent props as root level prop
    props = Object.keys(props)
        .filter((key) => key.endsWith("Props"))
        .map((key) => props[key])
        .reduce((a, b) => ({ ...a, ...b }), props);

    return props;
}
