import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { extractObject } from "octagonal-wheels/object";
import {
    TweakValuesShouldMatchedTemplate,
    TweakValuesTemplate,
    IncompatibleChanges,
    configurationNames,
    statusDisplay,
    type TweakValues,
    type ObsidianLiveSyncSettings,
    type RemoteDBSettings,
    IncompatibleChangesInSpecificPattern,
    CompatibleButLossyChanges,
    type RemotePreferredTweakResult,
    RemotePreferredTweakStatuses,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { $msg, translateIfAvailable } from "@/common/translation";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import {
    CENTRAL_COMPATIBILITY_REJECTION_REASONS,
    REMOTE_RESOURCE_KINDS,
    type ReplicationAttemptFailure,
    type ReplicatorInstance,
} from "@vrtmrz/livesync-commonlib/replication";

interface PreferredRemoteTweakWriter extends ReplicatorInstance {
    setPreferredRemoteTweakSettings(setting: ObsidianLiveSyncSettings): Promise<void>;
}

function canSetPreferredRemoteTweakSettings(replicator: ReplicatorInstance): replicator is PreferredRemoteTweakWriter {
    return (
        "setPreferredRemoteTweakSettings" in replicator &&
        typeof replicator.setPreferredRemoteTweakSettings === "function"
    );
}

/**
 * Localised counterpart of Commonlib's `confName()`, which takes no translator.
 * Same shape: label plus status suffix, and an empty string for an unknown key.
 */
function localisedConfName(key: keyof ObsidianLiveSyncSettings): string {
    const info = configurationNames[key];
    if (!info) return "";
    return `${translateIfAvailable(info.name)}${statusDisplay(info.status)}`;
}

function valueToString(value: string | number | boolean | object | undefined): string {
    if (typeof value === "boolean") {
        return value ? "true" : "false";
    }
    if (typeof value === "object") {
        return JSON.stringify(value);
    }
    return `${value}`;
}

export class ModuleResolvingMismatchedTweaks extends AbstractModule {
    private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
        const items = Object.keys(
            TweakValuesShouldMatchedTemplate
        ) as (keyof typeof TweakValuesShouldMatchedTemplate)[];
        return items.filter((key) => current[key] !== preferred[key]);
    }

    private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
        Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
        const currentModified = current.tweakModified;
        const preferredModified = preferred.tweakModified;
        // debugger;
        const hasCurrentModified = typeof currentModified === "number" && currentModified > 0;
        const hasPreferredModified = typeof preferredModified === "number" && preferredModified > 0;

        if (!hasCurrentModified && !hasPreferredModified) return "REMOTE";
        if (!hasCurrentModified) return "REMOTE";
        if (!hasPreferredModified) return "CURRENT";
        if (preferredModified >= currentModified) return "REMOTE";
        return "CURRENT";
    }

    private async _shouldAutoAcceptCompatibleLossy(
        current: TweakValues,
        preferred: Partial<TweakValues>,
        mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
    ): Promise<"REMOTE" | "CURRENT" | undefined> {
        if (mismatchedKeys.length === 0) return undefined;
        const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
            (key) => CompatibleButLossyChanges.indexOf(key) !== -1
        );
        if (!hasOnlyCompatibleLossyMismatches) return undefined;

        let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak;
        if (this.settings.autoAcceptCompatibleTweak === undefined) {
            // Keep the settings object stable: settings panes and an in-flight replication retry can
            // retain this reference while the default is persisted.
            this.settings.autoAcceptCompatibleTweak = true;
            await this.services.setting.saveSettingData();
            autoAcceptCompatibleTweak = true;
            Logger("Automatic alignment of compatible chunk settings has been enabled.");
        }

        if (autoAcceptCompatibleTweak !== true) return undefined;
        return this._selectNewerTweakSide(current, preferred);
    }

    /**
     * Hook before saving settings, to check if there are changes in tweak values, and if so,
     * update the tweakModified timestamp to current time.
     * This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time.
     * @param next
     * @param previous
     * @returns
     */
    async _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings) {
        const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[];
        const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified");
        const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]);
        if (!hasChangedTweak) return;
        Logger(
            `Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}`
        );
        const modified = Date.now();
        Logger(`Modified: ${modified}`);
        return await Promise.resolve({
            tweakModified: modified,
        });
    }

    async _anyAfterConnectCheckFailed(failure: ReplicationAttemptFailure): Promise<boolean | "CHECKAGAIN" | undefined> {
        const recovery = failure.outcome.recoveryHint;
        if (
            recovery?.reason !== CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH ||
            !recovery.preferredTweakValue
        ) {
            return false;
        }
        const ret = await this.services.tweakValue.askResolvingMismatched(
            { ...recovery.preferredTweakValue },
            async (setting) => {
                let updated = false;
                await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
                    if (activeContext !== failure.context) return;
                    if (!canSetPreferredRemoteTweakSettings(activeContext.replicator)) return;
                    await activeContext.replicator.setPreferredRemoteTweakSettings({ ...setting });
                    updated = true;
                });
                return updated;
            }
        );
        if (ret == "OK") return false;
        if (ret == "CHECKAGAIN") return "CHECKAGAIN";
        if (ret == "IGNORE") return true;
    }

    async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
        const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
        const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred);
        const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys);
        if (autoAcceptSide === "REMOTE") {
            return [{ ...mine, ...preferred }, false];
        }
        if (autoAcceptSide === "CURRENT") {
            return [true, false];
        }
        const items = Object.entries(TweakValuesShouldMatchedTemplate);
        let rebuildRequired = false;
        let rebuildRecommended = false;
        // Making tables:
        // let table = `| Value name | This device | Configured | \n` + `|: --- |: --- :|: ---- :| \n`;
        const tableRows = [];
        // const items = [mine,preferred]
        for (const v of items) {
            const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
            const valueMine = escapeMarkdownValue(mine[key]);
            const valuePreferred = escapeMarkdownValue(preferred[key]);
            if (valueMine == valuePreferred) continue;
            if (IncompatibleChanges.indexOf(key) !== -1) {
                rebuildRequired = true;
            }
            for (const pattern of IncompatibleChangesInSpecificPattern) {
                if (pattern.key !== key) continue;
                // if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
                const isFromConditionMet = "from" in pattern ? pattern.from === mine[key] : false;
                // and, if to value supplied, same as above.
                const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
                // if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
                if (isFromConditionMet || isToConditionMet) {
                    if (pattern.isRecommendation) {
                        rebuildRecommended = true;
                    } else {
                        rebuildRequired = true;
                    }
                }
            }
            if (CompatibleButLossyChanges.indexOf(key) !== -1) {
                rebuildRecommended = true;
            }

            // table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
            tableRows.push(
                $msg("TweakMismatchResolve.Table.Row", {
                    name: localisedConfName(key),
                    self: valueToString(valueMine),
                    remote: valueToString(valuePreferred),
                })
            );
        }

        const additionalMessage =
            rebuildRequired && this.core.settings.isConfigured
                ? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired")
                : "";
        const additionalMessage2 =
            rebuildRecommended && this.core.settings.isConfigured
                ? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended")
                : "";

        const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });

        const message = $msg("TweakMismatchResolve.Message.MainTweakResolving", {
            table: table,
            additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
        });

        const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseRemote");
        const CHOICE_USE_REMOTE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteWithRebuild");
        const CHOICE_USE_REMOTE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteAcceptIncompatible");
        const CHOICE_USE_MINE = $msg("TweakMismatchResolve.Action.UseMine");
        const CHOICE_USE_MINE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseMineWithRebuild");
        const CHOICE_USE_MINE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseMineAcceptIncompatible");
        const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");

        const CHOICE_AND_VALUES = [] as [string, [result: TweakValues | boolean, rebuild: boolean]][];

        if (rebuildRequired) {
            CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_PREVENT_REBUILD, [preferred, false]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_MINE_PREVENT_REBUILD, [true, false]]);
        } else if (rebuildRecommended) {
            CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
        } else {
            CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
            CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
        }
        CHOICE_AND_VALUES.push([CHOICE_DISMISS, [false, false]]);
        const CHOICES = Object.fromEntries(CHOICE_AND_VALUES) as Record<
            string,
            [TweakValues | boolean, performRebuild: boolean]
        >;
        const retKey = await this.core.confirm.askSelectStringDialogue(message, Object.keys(CHOICES), {
            title: $msg("TweakMismatchResolve.Title.TweakResolving"),
            timeout: 60,
            defaultAction: CHOICE_DISMISS,
        });
        if (!retKey) return [false, false];
        return CHOICES[retKey];
    }

    async _askResolvingMismatchedTweaks(
        preferredSource: TweakValues,
        updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
    ): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
        const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
        if (!conf) return "IGNORE";

        const updateRemote = async () => {
            if (updatePreferredRemote) return await updatePreferredRemote(this.settings);
            const candidate = this.core.replicator;
            if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false;
            await candidate.setPreferredRemoteTweakSettings(this.settings);
            return true;
        };

        if (conf === true) {
            if (!(await updateRemote())) return "IGNORE";
            if (rebuildRequired) {
                await this.core.rebuilder.$rebuildRemote();
            }
            Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE);
            return "CHECKAGAIN";
        }
        if (conf) {
            // ReplicationService retains the current settings object while it performs the immediate
            // CHECKAGAIN retry. Update that object in place so the retry observes the accepted values.
            Object.assign(this.settings, extractObject(TweakValuesTemplate, conf));
            await this.services.setting.saveSettingData();
            if (!rebuildRequired) {
                // The failed replication has settled before mismatch resolution runs. Reinitialise the
                // chunk-generation managers now so hash and splitter changes take effect before retrying.
                await this.localDatabase.managers.reinitialise();
            }
            if (!(await updateRemote())) return "IGNORE";
            if (rebuildRequired) {
                await this.core.rebuilder.$fetchLocal();
            }
            Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE);
            return "CHECKAGAIN";
        }
        return "IGNORE";
    }

    async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<RemotePreferredTweakResult> {
        try {
            const probe = await this.services.replicator.createRemoteResource(
                REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK,
                trialSetting
            );
            if (!probe) {
                this._log("The remote type does not support preferred tweak values.", LOG_LEVEL_NOTICE);
                return { status: RemotePreferredTweakStatuses.UNSUPPORTED };
            }
            return await withOwnedRemoteResource(probe, (ownedProbe) => ownedProbe.read());
        } catch (ex) {
            this._log("Failed to get the preferred tweak values from the remote.", LOG_LEVEL_NOTICE);
            return {
                status: RemotePreferredTweakStatuses.UNAVAILABLE,
                error: ex,
            };
        }
    }

    async _checkAndAskUseRemoteConfiguration(
        trialSetting: RemoteDBSettings
    ): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
        if (trialSetting.remoteType === REMOTE_P2P) {
            return { result: false, requireFetch: false };
        }
        const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
        if (preferred.status === RemotePreferredTweakStatuses.AVAILABLE) {
            return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred.values);
        }
        return { result: false, requireFetch: false };
    }

    async _askUseRemoteConfiguration(
        trialSetting: RemoteDBSettings,
        preferred: TweakValues
    ): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
        const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
        const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred);
        const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys);
        if (autoAcceptSide === "REMOTE") {
            return { result: { ...trialSetting, ...preferred }, requireFetch: false };
        }
        if (autoAcceptSide === "CURRENT") {
            return { result: false, requireFetch: false };
        }

        const items = Object.entries(TweakValuesShouldMatchedTemplate);
        let rebuildRequired = false;
        let rebuildRecommended = false;
        // Making tables:
        // let table = `| Value name | This device | On Remote | \n` + `|: --- |: ---- :|: ---- :| \n`;
        let differenceCount = 0;
        const tableRows = [] as string[];
        // const items = [mine,preferred]
        for (const v of items) {
            const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
            const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key]));
            const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key]));
            if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
                if (IncompatibleChanges.indexOf(key) !== -1) {
                    rebuildRequired = true;
                }
                for (const pattern of IncompatibleChangesInSpecificPattern) {
                    if (pattern.key !== key) continue;
                    // if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
                    const isFromConditionMet =
                        "from" in pattern ? pattern.from === (trialSetting as TweakValues)?.[key] : false;
                    // and, if to value supplied, same as above.
                    const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
                    // if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
                    if (isFromConditionMet || isToConditionMet) {
                        if (pattern.isRecommendation) {
                            rebuildRecommended = true;
                        } else {
                            rebuildRequired = true;
                        }
                    }
                }
                if (CompatibleButLossyChanges.indexOf(key) !== -1) {
                    rebuildRecommended = true;
                }
            } else {
                continue;
            }
            tableRows.push(
                $msg("TweakMismatchResolve.Table.Row", {
                    name: localisedConfName(key),
                    self: currentValueForDisplay,
                    remote: remoteValueForDisplay,
                })
            );
            differenceCount++;
        }

        if (differenceCount === 0) {
            this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
            return { result: false, requireFetch: false };
        }
        const additionalMessage =
            rebuildRequired && this.core.settings.isConfigured
                ? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired")
                : "";
        const additionalMessage2 =
            rebuildRecommended && this.core.settings.isConfigured
                ? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended")
                : "";

        const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });

        const message = $msg("TweakMismatchResolve.Message.Main", {
            table: table,
            additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
        });

        const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseConfigured");
        const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
        // const CHOICE_AND_VALUES = [
        //     [CHOICE_USE_REMOTE, preferred],
        //     [CHOICE_DISMISS, false]]
        const CHOICES = [CHOICE_USE_REMOTE, CHOICE_DISMISS];
        const retKey = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
            title: $msg("TweakMismatchResolve.Title.UseRemoteConfig"),
            timeout: 0,
            defaultAction: CHOICE_DISMISS,
        });
        if (!retKey) return { result: false, requireFetch: false };
        if (retKey === CHOICE_DISMISS) return { result: false, requireFetch: false };
        if (retKey === CHOICE_USE_REMOTE) {
            return { result: { ...trialSetting, ...preferred }, requireFetch: rebuildRequired };
        }
        return { result: false, requireFetch: false };
    }

    override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
        services.setting.onBeforeSaveSettingData.addHandler(this._onBeforeSaveSettingData.bind(this));
        services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this));
        services.tweakValue.checkAndAskResolvingMismatched.setHandler(
            this._checkAndAskResolvingMismatchedTweaks.bind(this)
        );
        services.tweakValue.askResolvingMismatched.setHandler(this._askResolvingMismatchedTweaks.bind(this));
        services.tweakValue.checkAndAskUseRemoteConfiguration.setHandler(
            this._checkAndAskUseRemoteConfiguration.bind(this)
        );
        services.tweakValue.askUseRemoteConfiguration.setHandler(this._askUseRemoteConfiguration.bind(this));
        services.replication.checkConnectionFailure.addHandler(this._anyAfterConnectCheckFailed.bind(this));
    }
}
