// Shared DOM helpers used by all views.
//
// Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string
// goes through escapeHtml(), whatever its provenance looks like today. The
// only interpolations left bare are markup FRAGMENTS this code just built
// (a rendered dot, an icon, a composed row), which escaping would turn into
// visible angle brackets, and locally computed numbers and loop indices.
// The distinction is meant to be greppable: an unescaped `${` next to a
// name that reads like data is a defect.

// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { markViewRendered } = require("../viewRouter");

// When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match.
const VIEWS = [
    "welcome",
    "add-wallet",
    "main",
    "address",
    "address-token",
    "send",
    "confirm-tx",
    "wait-tx",
    "success-tx",
    "error-tx",
    "receive",
    "add-token",
    "settings",
    "delete-wallet-confirm",
    "delete-wallet-lost-password",
    "delete-address-confirm",
    "settings-addtoken",
    "transaction",
    "approve-site",
    "approve-tx",
    "approve-sign",
    "export-privkey",
    "show-phrase",
    // Shown by src/popup/views/stateRecovery.js when the stored profile
    // cannot be read. It is never reached through showView() — by then the
    // state singleton this file writes on every navigation refuses to be read
    // — but it is listed so that every view-hiding loop covers it.
    "state-recovery",
];

// Cleanup callbacks for views that hold a secret in the DOM. The view
// registers one for itself and showView() runs it whenever that view is
// navigated away from, so the secret is wiped no matter which control
// caused the navigation — "Back", the settings gear, or a jump from
// anywhere else. A per-button clear would only cover the one path.
const viewLeaveHandlers = new Map();

function onViewLeave(name, fn) {
    viewLeaveHandlers.set(name, fn);
}

function $(id) {
    return document.getElementById(id);
}

function showError(id, msg) {
    const el = $(id);
    el.textContent = msg;
    el.style.visibility = "visible";
}

function hideError(id) {
    const el = $(id);
    el.textContent = "";
    el.style.visibility = "hidden";
}

function showView(name) {
    const leaving = state.currentView;
    if (leaving && leaving !== name) {
        const onLeave = viewLeaveHandlers.get(leaving);
        if (onLeave) onLeave();
    }
    for (const v of VIEWS) {
        const el = document.getElementById(`view-${v}`);
        if (el) {
            el.classList.toggle("hidden", v !== name);
        }
    }
    clearFlash();
    state.currentView = name;
    // A view's show() ends here, so this is where the Back path learns the
    // view is no longer the blank template from index.html and must not be
    // rendered a second time. See viewRouter.js.
    markViewRendered(name);
    saveState();
    updateDebugBanner(name);
}

// Create or update the debug/insecure warning banner.
// Called on every view switch and after the settings debug toggle changes.
// The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
function updateDebugBanner(viewName) {
    const debug = isDebug();
    const net = currentNetwork();
    const show = debug || net.isTestnet;
    let banner = document.getElementById("debug-banner");
    if (show) {
        if (!banner) {
            banner = document.createElement("div");
            banner.id = "debug-banner";
            banner.style.cssText =
                "background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
            document.body.prepend(banner);
        }
        const suffix = viewName ? " (" + viewName + ")" : "";
        if (debug && net.isTestnet) {
            banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
        } else if (net.isTestnet) {
            banner.textContent = "[TESTNET]" + suffix;
        } else {
            banner.textContent = "DEBUG / INSECURE" + suffix;
        }
    } else if (banner) {
        banner.remove();
    }
}

// Callback that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses.
//
// It answers true when it took the navigation — the view is rendered and
// shown, or its backing data was gone and it fell back — and false for a
// view the popup does not render from persisted state. Those can only be
// on the stack from this page load, because the stack is filtered on load,
// so they have already been rendered and only need unhiding.
let _renderBack = null;

function setBackRenderer(fn) {
    _renderBack = fn;
}

// Push the current view onto the navigation stack so goBack() can
// return to it.  Call this before any forward navigation.
function pushCurrentView() {
    if (state.currentView) {
        state.viewStack.push(state.currentView);
    }
}

// Pop the navigation stack and show the previous view.  If the stack
// is empty, fall back to the main (home) view.
function goBack() {
    let target;
    if (state.viewStack.length > 0) {
        target = state.viewStack.pop();
    } else {
        target = "main";
    }
    // A popped view is landed on, not navigated to. If the popup has been
    // closed and reopened since the view was pushed, nothing has ever
    // rendered it in this page load and its template is still blank, so it
    // has to be rendered here rather than merely unhidden.
    if (_renderBack && _renderBack(target)) return;
    showView(target);
}

// Clear the entire navigation stack (used when resetting to root,
// e.g. after adding or deleting a wallet).
function clearViewStack() {
    state.viewStack = [];
}

let flashTimer = null;

function clearFlash() {
    if (flashTimer) {
        clearTimeout(flashTimer);
        flashTimer = null;
    }
    $("flash-msg").textContent = "";
}

function showFlash(msg, duration = 2000) {
    clearFlash();
    $("flash-msg").textContent = msg;
    flashTimer = setTimeout(() => {
        $("flash-msg").textContent = "";
        flashTimer = null;
    }, duration);
}

// A stored token balance as a number, or null when there is no number in it.
// balances.js writes null for a holding whose scale nothing knows, and this
// keeps that null from becoming a zero one dereference later.
function unknownableAmount(balance) {
    if (balance == null) return null;
    const n = parseFloat(balance);
    return Number.isFinite(n) ? n : null;
}

// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
// so it is attacker-chosen markup until it has been through escapeHtml, and
// attacker-chosen length until it has been through displaySymbol. This is
// the row that issue #307 was reported against: every screen that lists a
// holding renders through here.
//
// `amount` is null for a holding whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
// print for it and no fiat value to derive from one, and printing 0.0000 for
// a real holding is the failure this whole rule exists to prevent, so the row
// says so instead.
function balanceLine(symbol, amount, price, tokenId) {
    const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
    const usd =
        price && amount !== null
            ? formatUsd(amount * price) || "&nbsp;"
            : "&nbsp;";
    // tokenId is a contract address out of the same explorer JSON, and it
    // lands inside a quoted attribute.
    const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
    const clickClass = tokenId
        ? " cursor-pointer hover:bg-hover balance-row"
        : "";
    return (
        `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
        `<span class="flex justify-between" style="width:42ch;max-width:100%">` +
        `<span>${escapeHtml(displaySymbol(symbol))}</span>` +
        `<span>${qty}</span>` +
        `</span>` +
        `<span class="text-right text-muted flex-1">${usd}</span>` +
        `</div>`
    );
}

function balanceLinesForAddress(addr, trackedTokens, showZero) {
    let html = balanceLine(
        "ETH",
        parseFloat(addr.balance || "0"),
        getPrice("ETH"),
        "ETH",
    );
    const seen = new Set();
    for (const t of addr.tokenBalances || []) {
        // A null balance is a holding of an unstatable amount, not a holding
        // of zero, so the show-zero setting has no say over it: hiding it
        // would be asserting the zero nobody established. Anything that does
        // not parse to a finite number is unknown for the same reason — the
        // `|| "0"` this replaced turned both into a confident zero.
        const bal = unknownableAmount(t.balance);
        if (bal === 0 && !showZero) continue;
        html += balanceLine(
            t.symbol,
            bal,
            getPrice(t.symbol),
            t.address.toLowerCase(),
        );
        seen.add(t.address.toLowerCase());
    }
    if (showZero && trackedTokens) {
        for (const t of trackedTokens) {
            if (seen.has(t.address.toLowerCase())) continue;
            html += balanceLine(
                t.symbol,
                0,
                getPrice(t.symbol),
                t.address.toLowerCase(),
            );
        }
    }
    return html;
}

// Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about. Deliberately unrounded — the rendered lines round to four
// decimals, so a dust balance displays as 0.0000 while still being real
// money at a real address. Callers that warn about holdings must ask this,
// not the rendered figure.
function addressHoldsFunds(addr) {
    if (!addr) return false;
    if (parseFloat(addr.balance || "0") > 0) return true;
    for (const t of addr.tokenBalances || []) {
        // A null balance is a holding whose amount could not be stated —
        // balances.js drops a row of zero base units before the scale is
        // consulted, so a row that survived with no quantity is holding
        // something. Warning about funds must err towards warning.
        const bal = unknownableAmount(t.balance);
        if (bal === null || bal > 0) return true;
    }
    return false;
}

// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
// README).  Callers that need to display less should use a different UI
// approach rather than silently making addresses insecure.
function truncateMiddle(str, maxLen) {
    if (str.length <= maxLen) return str;
    const removed = str.length - maxLen + 1; // +1 for the ellipsis char
    if (removed > 10) {
        maxLen = str.length - 10 + 1;
    }
    if (maxLen >= str.length) return str;
    const half = Math.floor((maxLen - 1) / 2);
    return str.slice(0, half) + "\u2026" + str.slice(-(maxLen - 1 - half));
}

// 16 colors evenly spaced around the hue wheel (22.5° apart),
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
const ADDRESS_COLORS = [
    "#d92626",
    "#d96926",
    "#d9ac26",
    "#c2d926",
    "#80d926",
    "#3dd926",
    "#26d953",
    "#26d996",
    "#26d9d9",
    "#2696d9",
    "#2653d9",
    "#3d26d9",
    "#8026d9",
    "#c226d9",
    "#d926ac",
    "#d92669",
];

function addressColor(address) {
    const idx = parseInt(address.slice(2, 6), 16) % 16;
    return ADDRESS_COLORS[idx];
}

function addressDotHtml(address) {
    const color = addressColor(address);
    return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
}

// Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) {
    const lower = address.toLowerCase();
    for (let wi = 0; wi < wallets.length; wi++) {
        const addrs = wallets[wi].addresses;
        for (let ai = 0; ai < addrs.length; ai++) {
            if (addrs[ai].address.toLowerCase() === lower) {
                return wallets[wi].name + " \u2014 Address " + (ai + 1);
            }
        }
    }
    return null;
}

// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
// Delegates to renderAddressHtml for consistent output.
function formatAddressHtml(address, ensName, maxLen, title) {
    return renderAddressHtml(address, { title, ensName, maxLen });
}

function isoDate(timestamp) {
    const d = new Date(timestamp * 1000);
    const pad = (n) => String(n).padStart(2, "0");
    if (state.utcTimestamps) {
        return (
            d.getUTCFullYear() +
            "-" +
            pad(d.getUTCMonth() + 1) +
            "-" +
            pad(d.getUTCDate()) +
            "T" +
            pad(d.getUTCHours()) +
            ":" +
            pad(d.getUTCMinutes()) +
            ":" +
            pad(d.getUTCSeconds()) +
            "Z"
        );
    }
    const offsetMin = -d.getTimezoneOffset();
    const sign = offsetMin >= 0 ? "+" : "-";
    const absOff = Math.abs(offsetMin);
    const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
    return (
        d.getFullYear() +
        "-" +
        pad(d.getMonth() + 1) +
        "-" +
        pad(d.getDate()) +
        "T" +
        pad(d.getHours()) +
        ":" +
        pad(d.getMinutes()) +
        ":" +
        pad(d.getSeconds()) +
        tzStr
    );
}

function timeAgo(timestamp) {
    const seconds = Math.floor(Date.now() / 1000 - timestamp);
    if (seconds < 60) return seconds + " seconds ago";
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60)
        return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
    const hours = Math.floor(minutes / 60);
    if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
    const days = Math.floor(hours / 24);
    if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
    const months = Math.floor(days / 30);
    if (months < 12)
        return months + " month" + (months !== 1 ? "s" : "") + " ago";
    const years = Math.floor(days / 365);
    return years + " year" + (years !== 1 ? "s" : "") + " ago";
}

// Shared external-link icon SVG used across all views.
const EXT_ICON =
    `<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
    `<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
    `<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
    `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
    `</svg></span>`;

// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
    return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}

function etherscanAddressUrl(address) {
    return explorerUrl("address", address);
}

// The URL still has to be escaped on the way into href="...": encoding
// governs what the URL means, escaping governs whether it stays inside the
// attribute.
function etherscanLinkHtml(url) {
    return (
        `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" ` +
        `class="inline-flex items-center">${EXT_ICON}</a>`
    );
}

// Render a copyable text span with dashed underline affordance.
// The caller must attach click handlers via attachCopyHandlers() or
// manually wire up [data-copy] elements after inserting the HTML.
function copyableHtml(text, extraClass) {
    const cls =
        "underline decoration-dashed cursor-pointer" +
        (extraClass ? " " + extraClass : "");
    return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}

// Attach click-to-copy handlers to all [data-copy] elements within
// a container.  Safe to call multiple times on the same container.
function attachCopyHandlers(container) {
    const root =
        typeof container === "string"
            ? document.getElementById(container)
            : container;
    if (!root) return;
    root.querySelectorAll("[data-copy]").forEach((el) => {
        el.onclick = () => {
            navigator.clipboard.writeText(el.dataset.copy);
            showFlash("Copied!");
            flashCopyFeedback(el);
        };
    });
}

// Unified address rendering.
//
// Produces consistent HTML for any Ethereum address:
//   • Color dot
//   • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
//   • Optional ENS name shown bold above address
//   • Full address (or truncated via maxLen) with dashed-underline click-to-copy
//   • Etherscan external link icon
//
// Options object:
//   title    — wallet title string (from addressTitle)
//   ensName  — ENS name string
//   maxLen   — if set, truncate address display (min 32 chars enforced)
//   noLink   — if true, omit etherscan link
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
    const { title, ensName, maxLen, noLink } = opts || {};
    const dot = addressDotHtml(address);
    const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
    const link = etherscanAddressUrl(address);
    const extLink = noLink ? "" : etherscanLinkHtml(link);

    let html = "";
    if (title) {
        html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
    }
    if (ensName) {
        html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
    }
    if (title || ensName) {
        html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
    } else {
        html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
    }
    return html;
}

function flashCopyFeedback(el) {
    if (!el) return;
    el.classList.remove("copy-flash-fade");
    el.classList.add("copy-flash-active");
    setTimeout(() => {
        el.classList.remove("copy-flash-active");
        el.classList.add("copy-flash-fade");
        setTimeout(() => {
            el.classList.remove("copy-flash-fade");
        }, 275);
    }, 75);
}

module.exports = {
    VIEWS,
    $,
    showError,
    hideError,
    showView,
    onViewLeave,
    updateDebugBanner,
    setBackRenderer,
    pushCurrentView,
    goBack,
    clearViewStack,
    showFlash,
    flashCopyFeedback,
    balanceLine,
    balanceLinesForAddress,
    addressHoldsFunds,
    unknownableAmount,
    addressColor,
    addressDotHtml,
    escapeHtml,
    displaySymbol,
    addressTitle,
    formatAddressHtml,
    renderAddressHtml,
    copyableHtml,
    attachCopyHandlers,
    etherscanAddressUrl,
    etherscanLinkHtml,
    explorerUrl,
    EXT_ICON,
    truncateMiddle,
    isoDate,
    timeAgo,
};
