// The version stamped on the stored profile, and the shape check every read
// of one goes through.
//
// Storage is the one input to this extension that nobody validated. A profile
// carried no version at all, so there was no way to tell a record this build
// understands from one a later build wrote, and loadState() coerced scalars
// while trusting the structure — so a `wallets` that was a string, or an array
// of nulls, or a later schema's wallet records, reached the popup and threw on
// the first dereference. The popup rendered NOTHING: no view, no message, no
// control, and no way out from inside the product
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// Two separate jobs, deliberately not merged:
//
//   stateProblem() / assertStateUsable()  refuse a record this build cannot
//                     safely reason about, loudly, naming the problem in a
//                     sentence that goes on screen. This is the gate.
//   normalizePersisted() (persistedState.js)  self-heal a record that IS
//                     usable: absent fields, legacy shapes, out-of-range flags.
//
// The gate runs FIRST, on the raw stored bytes, before normalization has a
// chance to paper over a record whose meaning nobody can vouch for. A blob
// that fails it is left in storage untouched — it is the user's only copy of
// whatever it holds, and the recovery screen exports it before offering to
// erase it.
//
// What is checked HERE is what nothing downstream can floor: the wallet list,
// the version, and the network id that keys an object. Every other field is
// normalizePersisted()'s to make safe, and what that function does today is
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
// which one a given field has without reading it off:
//
//   Type-checked, container AND entries: trackedTokens, each address's
//     tokenBalances, networkId, networkEndpoints, activeAddress, viewStack.
//     These are the fields something dereferences structurally — iterated,
//     indexed, assigned into, or .toLowerCase()'d — where a truthy value of
//     the wrong type throws on the first read. The entries matter as much as
//     the container: [1, 2] IS a list, and `t.address` is one level below the
//     Array.isArray(). What is checked on an ENTRY is the field the check
//     exists for and no more — for trackedTokens and tokenBalances that is
//     `address` alone; the rest of an entry is taken verbatim. So an entry's
//     `decimals` and `balance` may be null, which is how balances.js records
//     that nothing knows the token's scale
//     (https://git.eeqj.de/sneak/AutistMask/issues/349), and every reader
//     handles that null rather than being defended from it here.
//   Container shape only: allowedSites, deniedSites. A falsy value or a list
//     becomes {}; anything else is taken as stored and the entries are not
//     checked.
//   `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
//     lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
//     currentView, selectedToken, viewData.
//   Present-or-default, value taken verbatim: every boolean flag,
//     dustThresholdGwei, selectedWallet, selectedAddress.
//
// A field added to the record needs a check here or a floor there, chosen by
// what reads it: anything dereferenced structurally needs the type check, and
// neither of the last two kinds is one.

const { isKnownNetworkId } = require("./networks");

// Bump this when the MEANING of a stored field changes, and add the migration
// that carries the older version forward. Adding a field with a defaulted
// absent value is not a bump: normalizePersisted() already handles that, and
// bumping for it would send every older install to the recovery screen for no
// reason.
//
// Version 1 is the shape that shipped unversioned. An unversioned record is
// therefore version 1, not a defect — see migrationNeeded() below.
const STATE_SCHEMA_VERSION = 1;

// Thrown by every read path that finds a record it cannot use. `problem` is
// the sentence shown to the user; `message` carries the same text so a log
// line or a rethrow is not empty.
class StateUnusableError extends Error {
    constructor(problem) {
        super(problem);
        this.name = "StateUnusableError";
        this.problem = problem;
    }
}

function isPlainObject(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}

// Own properties only, everywhere in this file. `saved` comes from storage as
// parsed JSON, so `saved.constructor` and `saved.__proto__` answer from the
// prototype chain for a record that carries neither — a check written as a
// plain truthiness test can be satisfied by Object.prototype rather than by
// anything the user's profile actually contains.
function has(obj, key) {
    return Object.prototype.hasOwnProperty.call(obj, key);
}

function ordinal(index) {
    return String(index + 1);
}

function describeType(value) {
    if (value === null) return "null";
    if (Array.isArray(value)) return "a list";
    return "a " + typeof value;
}

// One address record, as every screen dereferences it.
function addressProblem(addr, walletIndex, addrIndex) {
    const where =
        "address " +
        ordinal(addrIndex) +
        " of wallet " +
        ordinal(walletIndex) +
        " in the saved data";
    if (!isPlainObject(addr)) {
        return "The " + where + " is " + describeType(addr) + ", not a record.";
    }
    if (typeof addr.address !== "string" || addr.address === "") {
        return "The " + where + " has no address.";
    }
    return null;
}

function walletProblem(wallet, index) {
    const where = "Wallet " + ordinal(index) + " in the saved data";
    if (!isPlainObject(wallet)) {
        return where + " is " + describeType(wallet) + ", not a wallet record.";
    }
    if (!Array.isArray(wallet.addresses)) {
        return where + " has no list of addresses.";
    }
    if (has(wallet, "name") && typeof wallet.name !== "string") {
        return where + " has a name that is not text.";
    }
    for (let i = 0; i < wallet.addresses.length; i++) {
        const problem = addressProblem(wallet.addresses[i], index, i);
        if (problem) return problem;
    }
    return null;
}

function versionProblem(saved) {
    // No version field at all is the shape every install in the field has:
    // no build ever wrote one. It is version 1, and it is migrated in place.
    if (!has(saved, "schemaVersion")) return null;
    const version = saved.schemaVersion;
    if (
        typeof version !== "number" ||
        !Number.isInteger(version) ||
        version < 1
    ) {
        return (
            "The saved data carries a schema version AutistMask does not" +
            " recognize (" +
            JSON.stringify(version) +
            ")."
        );
    }
    if (version > STATE_SCHEMA_VERSION) {
        return (
            "The saved data was written by a newer version of AutistMask" +
            " (schema version " +
            version +
            "; this build understands version " +
            STATE_SCHEMA_VERSION +
            ")."
        );
    }
    return null;
}

/**
 * The reason this build cannot use `saved`, as a sentence for the user, or
 * null when it can.
 *
 * @param {*} saved the raw record from storage, or undefined for a fresh
 *     install.
 * @returns {string|null}
 */
function stateProblem(saved) {
    // Nothing stored is a first run, not a defect.
    if (saved === undefined || saved === null) return null;
    if (!isPlainObject(saved)) {
        return (
            "The saved data is " +
            describeType(saved) +
            ", not the record AutistMask stores."
        );
    }

    const version = versionProblem(saved);
    if (version) return version;

    // Read once, from an OWN property or not at all, so that a polluted
    // prototype cannot decide whether a profile is refused. Note that
    // normalizePersisted() reads the same field plainly, and so WOULD consult
    // the prototype chain: the two halves agree only because a record arriving
    // from storage has been through structuredClone and always carries
    // Object.prototype. Nothing reachable from storage can put them at odds,
    // but a caller that hands either one a hand-built object with an unusual
    // prototype is not covered by that.
    const wallets =
        has(saved, "wallets") && saved.wallets !== undefined
            ? saved.wallets
            : [];
    if (!Array.isArray(wallets)) {
        return (
            "The list of wallets in the saved data is " +
            describeType(wallets) +
            ", not a list."
        );
    }
    for (let i = 0; i < wallets.length; i++) {
        const problem = walletProblem(wallets[i], i);
        if (problem) return problem;
    }

    // networkId is not merely displayed: it is an object KEY into
    // state.networkEndpoints. A corrupt "__proto__" would set that map's
    // prototype instead of an own key, so the user's endpoint would silently
    // not be recorded and a switch away and back would return the public
    // default. isKnownNetworkId() is an own-property test against the network
    // table for exactly that reason.
    if (
        has(saved, "networkId") &&
        saved.networkId !== undefined &&
        !isKnownNetworkId(saved.networkId)
    ) {
        return (
            "The saved data selects a network AutistMask does not know (" +
            JSON.stringify(saved.networkId) +
            ")."
        );
    }

    return null;
}

/**
 * Refuse a record this build cannot use.
 *
 * @param {*} saved the raw record from storage.
 * @throws {StateUnusableError}
 */
function assertStateUsable(saved) {
    const problem = stateProblem(saved);
    if (problem) throw new StateUnusableError(problem);
}

/**
 * Whether `saved` is a usable record written before versions existed, and so
 * gets the current version stamped on it the next time anything writes. Purely
 * informational — the migration itself is that stamp, since version 1 IS the
 * unversioned shape.
 *
 * @param {*} saved
 * @returns {boolean}
 */
function migrationNeeded(saved) {
    return (
        isPlainObject(saved) &&
        !has(saved, "schemaVersion") &&
        stateProblem(saved) === null
    );
}

module.exports = {
    STATE_SCHEMA_VERSION,
    StateUnusableError,
    assertStateUsable,
    migrationNeeded,
    stateProblem,
};
