pragma Singleton

// Versioned upgrades for the settings file.
//
// The schema is the single source of truth for what a setting IS, but it cannot
// describe what a setting USED to be. Renaming a key, changing its units, or
// splitting one setting into two all leave a stored value that the new schema
// does not recognize -- and an unrecognized key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent.
//
// HOW IT WORKS
//
// settings.json carries a schemaVersion. On load, every migration with a
// version ABOVE the stored one runs in order, then the file is stamped with
// `current`. A file with no schemaVersion at all is a file written before this
// existed; it is stamped at `baseline` and NOT migrated, because those
// migrations were never written for it.
//
// WRITING ONE
//
//   { version: 2, describe: "rename dockDelay to dockHideDelayMs",
//     migrate: values => { ... return values; } }
//
// Rules that make this safe to run against a real user's file:
//
//   * migrate() receives the whole values object and returns it. Mutating and
//     returning the same object is fine.
//   * NEVER delete a key you are not replacing. Unknown keys are deliberately
//     preserved so that rolling back to an older Panama does not discard a
//     newer version's settings, and a migration is the one place that promise
//     could quietly be broken.
//   * A migration must tolerate its input being absent or the wrong type. It
//     runs against files written by every previous version, including ones
//     that were hand-edited.
//   * Migrations never run twice: the stored version only moves forward.

import QtQuick

QtObject {
    id: root

    // What a file written today is stamped with. Bump this when adding a
    // migration, to the version of the migration you added.
    readonly property int current: 1

    // Files predating versioning are stamped here without being migrated.
    readonly property int baseline: 1

    readonly property string versionKey: "schemaVersion"

    // Ordered by version. Empty is the correct state until the first breaking
    // schema change -- this exists so that change is a routine edit rather than
    // an emergency.
    readonly property var steps: []

    function storedVersion(values: var): int {
        const raw = values ? values[root.versionKey] : undefined;
        return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
    }

    // Returns { values, migrated, changed, from, to, applied }.
    //
    // `applied` names each step that ran, so the caller can log something
    // meaningful rather than "settings changed somehow". `changed` is the one
    // the caller should write on: stamping a pre-versioning file changes it
    // without running any step, and left unwritten the stamp would live only in
    // memory and be redone on every launch.
    function apply(values: var): var {
        return root.applyWith(values, root.steps, root.current, root.baseline);
    }

    // The same logic with the step list injected, so the machinery can be
    // tested against fixture migrations. The real list is empty until the first
    // breaking schema change, and a mechanism that has never run against a
    // failing step is not one to find out about during an upgrade.
    function applyWith(values: var, steps: var, current: int, baseline: int): var {
        const safe = (values && typeof values === "object") ? values : {};
        const from = root.storedVersion(safe);

        // No version: written before versioning existed. Stamp it and stop.
        // Running the migration list against it would apply upgrades designed
        // for schemas this file never had.
        if (from === 0) {
            safe[root.versionKey] = baseline;
            return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
        }

        // A file from a NEWER Panama. Left completely alone: downgrading its
        // keys is not something this can do correctly, and unknown keys are
        // already preserved, so the older build simply ignores what it does not
        // understand.
        if (from > current)
            return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };

        const applied = [];
        let working = safe;
        for (const step of steps) {
            if (step.version <= from || step.version > current)
                continue;
            try {
                const result = step.migrate(working);
                if (result && typeof result === "object")
                    working = result;
                applied.push(step.version + ": " + step.describe);
            } catch (error) {
                // One bad migration must not cost the user every setting. Stop
                // at the last good version so the next launch retries from
                // here rather than skipping the failed step forever.
                console.warn("Migrations: step", step.version, "failed:", error);
                working[root.versionKey] = step.version - 1;
                return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
                         from: from, to: step.version - 1, applied: applied };
            }
        }

        working[root.versionKey] = current;
        return { values: working, migrated: applied.length > 0, changed: from !== current,
                 from: from, to: current, applied: applied };
    }
}
