import type { EServiceTemplateGoogleConsentModeTypes } from "@devowl-wp/api-real-cookie-banner";
import { waitObject, yieldMainThread } from "@devowl-wp/react-utils";

import { getDefaultDecision } from "../decision/getDefaultDecision.js";
import { getUserDecision } from "../decision/getUserDecision.js";

import type { apply } from "./apply.js";
import type { ApplyOptInOptions } from "./optIn.js";
import type { DecisionConsentGroups } from "../decision/getUserDecision.js";
import type { ServiceGroup, ServiceTagManager } from "../types/service.js";
import type { ClickableButtonsNamed } from "../types/types.js";

type CookieConsentManagerTransaction = Partial<{
    decision: DecisionConsentGroups | "all" | "essentials";
    revision: string;
    tcfString: string;
    gcmConsent: EServiceTemplateGoogleConsentModeTypes[];
    createdClientTime: string;
    buttonClicked: ClickableButtonsNamed;
}>;

type CookieConsentManagerOptions<Transaction extends CookieConsentManagerTransaction = any> = {
    /**
     * The cookie needs to be built in this way: `{consent-uuid1}[,{consent-uuid2}...]:{revision-uuid}:{consent-json-stringified}`.
     */
    decisionCookieName: string;
    /**
     * **Current** revision hash.
     */
    revisionHash: string;
    supportsCookiesName?: string;
    groups: ServiceGroup[];
    setCookiesViaManager: ServiceTagManager;
    /**
     * Is the Google Consent Mode activated?
     */
    isGcm: boolean;
    skipOptIn?: ApplyOptInOptions["skipOptIn"];
    cmpId?: number | string;
    /**
     * Currently, only two bits (https://git.io/JmuD0) can be saved to the `cmpVersion`, so we can only save the major version.
     */
    cmpVersion?: number | string;
    tcfCookieName?: string;
    gcmCookieName?: string;
    /**
     * In this local storage item the queue of pending consents will be saved. See also `persistConsent`.
     */
    consentQueueLocalStorageName: string;
    /**
     * When `persistConsent` fails, it will save the given consent locally in local storage. This allows to respect the user
     * consent even when it could not be documented successfully.
     */
    failedConsentDocumentationHandling?: "optimistic" | "essentials";
    /**
     * A callback which receives a transaction (representing a user consent) as argument and it should save the
     * consent to the server. When it throws an error, it will be automatically added to a managed queue which
     * implements a lifecycle to retry the save procedure (see also `CookieConsentManager#persistConsent`).
     *
     * @param setCookies If `true`, the server response should return cookies, otherwise not. This is needed for persisting "old"
     *                   consents which were not saved due to a server error.
     * @return The consent UUID
     */
    persistConsent?: (transaction: Transaction, setCookies?: boolean) => Promise<string>;
};

/**
 * Main class to manage cookie consents within your application.
 */
class CookieConsentManager<Transaction extends CookieConsentManagerTransaction = any> {
    public static BROADCAST_SIGNAL_APPLY_COOKIES = "applyCookies";

    private options: CookieConsentManagerOptions<Transaction>;

    public constructor(options: Omit<CookieConsentManagerOptions<Transaction>, "tcfCookieName" | "gcmCookieName">) {
        const { decisionCookieName } = options;
        this.options = options;

        this.options.tcfCookieName = `${decisionCookieName}-tcf`;
        this.options.gcmCookieName = `${decisionCookieName}-gcm`;

        // Listen to consent changes in other tabs so we can apply cookies for this tab, too (but only once for the initial cookie banner representation)
        let otherTabListenerExecuted = false;
        window.addEventListener("storage", ({ key, oldValue, newValue, isTrusted }) => {
            if (!otherTabListenerExecuted && key === this.getConsentQueueName() && newValue && isTrusted) {
                const parsedOldValue = JSON.parse(oldValue || "[]") as CookieConsentManagerTransaction[];
                const parsedNewValue = JSON.parse(newValue) as CookieConsentManagerTransaction[];
                if (parsedNewValue.length > parsedOldValue.length) {
                    // A new consent got added, so we are safe to say that another tab accepted the cookie banner
                    // At this time we have not yet the decision available in localStorage or HTTP cookie, so we
                    // need to wait for the consent to be available
                    otherTabListenerExecuted = true;
                    const previousConsentDecision = JSON.stringify(getUserDecision(decisionCookieName));
                    waitObject(
                        () => JSON.stringify(getUserDecision(decisionCookieName)) !== previousConsentDecision,
                        500,
                        20,
                    ).then(() => this.applyCookies({ type: "consent", triggeredByOtherTab: true }));
                }
            }
        });

        const fn = async () => {
            const { retryPersistFromQueue } = await import(
                /* webpackChunkName: "banner-lazy", webpackMode: "lazy-once" */ "../decision/retryPersistFromQueue.js"
            );

            // Only retry when the queue is filled with items
            const start = (tryImmediate: boolean) => {
                const dispose = retryPersistFromQueue(this, tryImmediate);
                window.addEventListener("beforeunload", dispose);
            };
            if (this.getConsentQueue().length > 0) {
                start(true);
            } else {
                const listener = ({ key, newValue }: StorageEvent) => {
                    const fromCurrentTab = key === this.getConsentQueueName() && newValue;
                    const fromOtherTab = key === this.getConsentQueueName(true) && !newValue;
                    if (fromCurrentTab || fromOtherTab) {
                        start(fromOtherTab);
                        window.removeEventListener("storage", listener);
                    }
                };
                window.addEventListener("storage", listener);
            }
        };

        if (window.requestIdleCallback) {
            requestIdleCallback(fn);
        } else {
            yieldMainThread().then(fn);
        }
    }

    public async applyCookies(options: Pick<Parameters<typeof apply>[0], "type" | "triggeredByOtherTab">) {
        const { apply: doApply } = await import(
            /* webpackChunkName: "banner-lazy", webpackMode: "lazy-once" */ "../apply/apply.js"
        );
        await doApply({
            ...options,
            ...this.options,
        });
    }

    /**
     * If you have passed a `persistConsent` as option to the manager constructor, you can use this method.
     * This method wraps your passed callback and if an error occurs, it accordingly handles the error and pushes
     * the transaction into a queue. The queue has a lifecycle to get retried at a later stage when e.g. your server
     * is available again.
     */
    public async persistConsent(transaction: Transaction) {
        const { persistWithQueueFallback } = await import(
            /* webpackChunkName: "banner-lazy", webpackMode: "lazy-once" */ "../decision/persistWithQueueFallback.js"
        );
        return await persistWithQueueFallback(transaction, this);
    }

    public getUserDecision(onlyUptoDate?: boolean) {
        const decision = getUserDecision(this.getOption("decisionCookieName"));
        return onlyUptoDate === true
            ? decision
                ? decision.revision === this.getOption("revisionHash")
                    ? decision
                    : false
                : false
            : decision;
    }

    public getDefaultDecision(respectLegitimateInterests = true) {
        return getDefaultDecision(this.options.groups, respectLegitimateInterests);
    }

    public getOption<K extends keyof CookieConsentManagerOptions>(name: K) {
        return this.options[name];
    }

    public getOptions() {
        return this.options;
    }

    public getConsentQueueName(lock = false) {
        return `${this.options.consentQueueLocalStorageName}${lock ? "-lock" : ""}`;
    }

    public getConsentQueue() {
        return JSON.parse(
            localStorage.getItem(this.getConsentQueueName()) || "[]",
        ) as CookieConsentManagerTransaction[];
    }

    public setConsentQueue(queue: CookieConsentManagerTransaction[]) {
        const key = this.getConsentQueueName();
        const oldValue = localStorage.getItem("test");
        const newValue = queue.length > 0 ? JSON.stringify(queue) : null;
        if (newValue) {
            localStorage.setItem(key, newValue);
        } else {
            localStorage.removeItem(key);
        }

        // Send custom event so the current browser can listen to the queue change (see constructor)
        // https://stackoverflow.com/a/72428465/5506547
        window.dispatchEvent(
            new StorageEvent("storage", {
                key,
                oldValue,
                newValue,
            }),
        );
    }

    public isConsentQueueLocked(set?: boolean) {
        const current = new Date().getTime();
        const lockName = this.getConsentQueueName(true);
        if (set === false) {
            localStorage.removeItem(lockName);
        } else if (set === true) {
            localStorage.setItem(lockName, `${current + 1000 * 60}`);
        }

        const time = +(localStorage.getItem(lockName) || 0);
        return !(current > time);
    }
}

export { type CookieConsentManagerOptions, CookieConsentManager, type CookieConsentManagerTransaction };
