// The shape of the persisted profile, and the normalization every read of it
// goes through. No singleton, no storage access, no browser API: just the
// record definition and pure functions over it.
//
// Split out of state.js so that a context which must never touch the
// module-level `state` singleton can still speak the same record format.
// src/background/state.js is that context — the MV3 service worker never
// populates the singleton, and every defect in
// https://git.eeqj.de/sneak/AutistMask/issues/324 came from background code
// reaching it anyway and being served DEFAULT_STATE.

const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { isKnownNetworkId } = require("./networks");
const { STATE_SCHEMA_VERSION } = require("./stateSchema");
// Dependency-free constant module. It lives under src/shared/ rather than
// src/popup/ precisely because this module is in the background bundle: a
// popup-path module reached from the worker is the shape the prohibition in
// script/lib/forbiddenBundleInputs.js exists to keep out, even when the
// particular module is harmless.
const { RESTORABLE_VIEWS } = require("./restorableViews");

const DEFAULT_STATE = {
    hasWallet: false,
    wallets: [],
    trackedTokens: [],
    networkId: "mainnet",
    rpcUrl: DEFAULT_RPC_URL,
    blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
    // Endpoints remembered per network: { [networkId]: { rpcUrl,
    // blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
    // of the active network; this is what the others are restored from
    // when the active network changes. See applyChainSwitchFields().
    networkEndpoints: {},
    lastBalanceRefresh: 0,
    activeAddress: null,
    allowedSites: {},
    deniedSites: {},
    rememberSiteChoice: true,
    showZeroBalanceTokens: true,
    hideSpoofedSymbols: true,
    hideLowHolderTokens: true,
    hideFraudContracts: true,
    hideDustTransactions: true,
    dustThresholdGwei: 100000,
    utcTimestamps: false,
    fraudContracts: [],
    tokenHolderCache: {},
    theme: "system",
    debugMode: false,
};

// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic in
// state.js — like loadState() does, it is always derived from `wallets`,
// never carried as an independent value. schemaVersion is excluded for the
// same reason and is absent from DEFAULT_STATE for it: it describes the
// record rather than being part of it, and every write stamps the current
// value rather than diffing whatever was read.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
    .filter((key) => key !== "hasWallet")
    .concat([
        "currentView",
        "selectedWallet",
        "selectedAddress",
        "selectedToken",
        "viewData",
        "viewStack",
    ]);

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

// A list of token references, as everything downstream dereferences them:
// `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js,
// src/popup/views/helpers.js, and every view that shows a balance line).
//
// Both the container AND the entries, because they are separate defects. A
// container check alone leaves a well-formed list of malformed entries walking
// through to a dereference one level below the check, which is the same blank
// popup: `[1, 2]` and `[{}]` are lists.
//
// A malformed entry is DROPPED rather than repaired: a token reference with no
// address identifies nothing, so there is no value to repair it to, and the
// alternative — refusing the whole record — sends a user whose wallets are
// perfectly readable to an export-or-erase screen over a token list. An entry
// that is a record with a text address is kept verbatim, extra fields and all.
//
// Verbatim is load-bearing for the fields BESIDE the address. A tokenBalances
// entry carries `decimals: null` and `balance: null` when nothing knows the
// token's scale (src/shared/balances.js,
// https://git.eeqj.de/sneak/AutistMask/issues/349), and those nulls are the
// record that the value is unknown. Only `address` decides whether an entry
// survives, so an unknown-scale holding is kept — flooring a null here to some
// default would put the guess back one layer down from where it was removed.
function tokenRefs(value) {
    if (!Array.isArray(value)) return [];
    return value.filter(
        (entry) => isRecord(entry) && typeof entry.address === "string",
    );
}

// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
    // A stored stack that is missing or not an array keeps nothing, but it
    // still goes through the never-empty rule below rather than returning
    // early: otherwise a corrupt stack would depend on exactly the goBack()
    // fallback that the explicit ["main"] exists in order not to depend on.
    const source = Array.isArray(stored) ? stored : [];
    const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
    const kept = cut === -1 ? source.slice() : source.slice(0, cut);
    // A view restored below the root still needs somewhere for Back to go.
    if (
        kept.length === 0 &&
        currentView !== "main" &&
        RESTORABLE_VIEWS.has(currentView)
    ) {
        return ["main"];
    }
    return kept;
}

// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. A pure function so that
// saveState() can apply it too: the fields THIS page did not change still have
// to come from storage in their loaded-and-normalized form, not as the raw
// bytes another page (or an old release) left there — otherwise a legacy shape
// a load has always self-healed in memory (a missing networkEndpoints map, an
// out-of-range flag) is dropped right back into storage unfixed every time the
// page that DID normalize it saves something unrelated, because that field's
// value never "changed" for that page to notice.
//
// The result never shares structure with `saved`, so a caller may mutate it
// freely: it is the detached record every per-call read in the background is
// built on.
function normalizePersisted(saved) {
    saved = saved || {};
    const out = {};
    // Every write goes out at the current version. That IS the migration for
    // the unversioned records every install in the field holds: version 1 is
    // the shape that shipped unversioned, so a record that validated is
    // carried forward simply by being stamped. A record this build does NOT
    // understand never reaches here — assertStateUsable() refuses it on the
    // read path first (src/shared/stateSchema.js).
    out.schemaVersion = STATE_SCHEMA_VERSION;
    out.wallets = structuredClone(saved.wallets || []);
    // Derived, never trusted verbatim off storage — see loadState().
    out.hasWallet = out.wallets.length > 0;
    // Each address's token holdings, floored to a list of token records on the
    // detached copy above. Every reader iterates it behind a `|| []` that only
    // covers an ABSENT value, and then dereferences `t.address.toLowerCase()`
    // and `t.balance` — so a stored string iterates as characters, a number
    // throws on the iterator, and a null entry throws on the field.
    //
    // This field specifically, because refreshBalances() writes it WHOLESALE
    // rather than merging into it: a write that only partly lands is the live
    // cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is
    // where it lands. The wallet list itself is the gate's (stateSchema.js);
    // what is below an address record is not, and gets floored here.
    if (Array.isArray(out.wallets)) {
        for (const wallet of out.wallets) {
            if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue;
            for (const addr of wallet.addresses) {
                if (!isRecord(addr)) continue;
                addr.tokenBalances = tokenRefs(addr.tokenBalances);
            }
        }
    }
    // An actual list of token records is required, not merely a truthy value
    // and not merely a list: everything downstream iterates this and
    // dereferences `token.address`, so a stored string or object walks through
    // a `|| []`, and a list of numbers walks through an Array.isArray(), and
    // both throw on the first read — the blank popup from the issue, for a
    // profile whose wallets are perfectly fine. An empty list is a legitimate
    // value and survives.
    out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
    // The loud refusal for an unknown id is assertStateUsable(); this is the
    // floor under it. networkId is an object KEY into networkEndpoints below,
    // so a value that is not a network in networks.js must never get that far
    // — "__proto__" would set the map's prototype instead of an own key, and
    // the user's endpoint would silently not be recorded.
    out.networkId = isKnownNetworkId(saved.networkId)
        ? saved.networkId
        : DEFAULT_STATE.networkId;
    out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
    out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
    // An actual object is required, not merely a truthy non-array: the code
    // below and applyChainSwitchFields() index and ASSIGN INTO this value, and
    // assigning a property to a string or a number is a silent no-op in
    // sloppy mode. Copied rather than referenced, nested pairs included, so
    // normalizing never mutates the object a caller handed in.
    const rawEndpoints =
        typeof saved.networkEndpoints === "object" &&
        saved.networkEndpoints !== null &&
        !Array.isArray(saved.networkEndpoints)
            ? saved.networkEndpoints
            : {};
    out.networkEndpoints = {};
    for (const netId of Object.keys(rawEndpoints)) {
        // defineProperty, not assignment: a stored map with an own
        // "__proto__" key — which JSON can carry and assignment treats as the
        // prototype setter — would otherwise replace this object's prototype
        // and record no entry at all. Keys other than the known network ids
        // are kept rather than dropped, so a profile that has been on a build
        // with more networks does not lose their endpoints by passing through
        // this one.
        Object.defineProperty(out.networkEndpoints, netId, {
            value: { ...rawEndpoints[netId] },
            writable: true,
            enumerable: true,
            configurable: true,
        });
    }
    // A profile written before this map existed carries exactly one pair of
    // endpoints, belonging to whatever network it was last on. Adopt it as
    // that network's remembered pair, so a custom endpoint set on the old
    // build is not lost by the first switch away and back.
    if (!out.networkEndpoints[out.networkId]) {
        out.networkEndpoints[out.networkId] = {
            rpcUrl: out.rpcUrl,
            blockscoutUrl: out.blockscoutUrl,
        };
    }
    out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
    // A non-empty address, or null, never anything else: this is passed to
    // address.slice() and compared against stored addresses, so a stored
    // number or object walks through a `|| null` and throws on the first
    // render. The empty string is text but it is not an address, and it must
    // become null rather than survive: init() auto-selects the first address
    // only on a STRICT null, so a stored "" would leave the popup with no
    // address ever selected. Nothing in src/ writes one, and this keeps the
    // behaviour the `|| null` this check replaced already had.
    out.activeAddress =
        typeof saved.activeAddress === "string" && saved.activeAddress !== ""
            ? saved.activeAddress
            : null;
    out.allowedSites =
        saved.allowedSites && !Array.isArray(saved.allowedSites)
            ? structuredClone(saved.allowedSites)
            : {};
    out.deniedSites =
        saved.deniedSites && !Array.isArray(saved.deniedSites)
            ? structuredClone(saved.deniedSites)
            : {};
    out.rememberSiteChoice =
        saved.rememberSiteChoice !== undefined
            ? saved.rememberSiteChoice
            : true;
    out.showZeroBalanceTokens =
        saved.showZeroBalanceTokens !== undefined
            ? saved.showZeroBalanceTokens
            : true;
    // A profile written before this setting existed has no key for it. It
    // is a safety filter, so absent must load as on, not as undefined.
    out.hideSpoofedSymbols =
        saved.hideSpoofedSymbols !== undefined
            ? saved.hideSpoofedSymbols
            : true;
    out.hideLowHolderTokens =
        saved.hideLowHolderTokens !== undefined
            ? saved.hideLowHolderTokens
            : true;
    out.hideFraudContracts =
        saved.hideFraudContracts !== undefined
            ? saved.hideFraudContracts
            : true;
    out.hideDustTransactions =
        saved.hideDustTransactions !== undefined
            ? saved.hideDustTransactions
            : true;
    out.dustThresholdGwei =
        saved.dustThresholdGwei !== undefined
            ? saved.dustThresholdGwei
            : 100000;
    out.utcTimestamps =
        saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
    out.fraudContracts = structuredClone(saved.fraudContracts || []);
    out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
    out.theme = saved.theme || "system";
    out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
    out.currentView = saved.currentView || null;
    out.selectedWallet =
        saved.selectedWallet !== undefined ? saved.selectedWallet : null;
    out.selectedAddress =
        saved.selectedAddress !== undefined ? saved.selectedAddress : null;
    out.selectedToken = saved.selectedToken || null;
    out.viewData = structuredClone(saved.viewData || {});
    out.viewStack = restorableStack(saved.viewStack, out.currentView);
    return out;
}

module.exports = {
    DEFAULT_STATE,
    PERSISTED_FIELDS,
    normalizePersisted,
    restorableStack,
};
