var MAX_NAME_LENGTH = 40;

// The token sets a theme record may carry beyond its accent pair. PALETTE_KEYS
// mirrors Theme.qml's color tokens; ANSI_KEYS feed generated terminal themes;
// EFFECT_SPEC mirrors the ten effects schema keys so a custom theme can
// snapshot them. All three are pinned by theme-catalog-contract against
// config/themes.json.
var PALETTE_KEYS = ["bg", "bgDark", "bgHighlight", "bgPanel", "bgPopover",
    "fg", "fgDim", "fgMuted", "gutter", "accentAlt",
    "cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
    "magenta", "pink"];
var ANSI_KEYS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan",
    "white", "brightBlack", "brightRed", "brightGreen", "brightYellow",
    "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
var EFFECT_SPEC = {
    blurEnabled: { kind: "bool" },
    blurSize: { kind: "int", min: 1, max: 20 },
    blurPasses: { kind: "int", min: 1, max: 5 },
    shadowEnabled: { kind: "bool" },
    shadowRange: { kind: "int", min: 0, max: 60 },
    shadowSharp: { kind: "bool" },
    shadowRenderPower: { kind: "int", min: 1, max: 4 },
    glowEnabled: { kind: "bool" },
    glowRange: { kind: "int", min: 0, max: 30 },
    animationsEnabled: { kind: "bool" }
};

// The minimal built-in shipped list. The full catalog (config/themes.json,
// via ThemeCatalog) is passed into every function that takes a `shipped`
// argument; this fallback keeps the model usable headless and before the
// catalog loads.
var SHIPPED = [
    {
        id: "moon",
        name: "Moon",
        scheme: "dark",
        accent: "#82aaff",
        secondary: "#b172b0",
        shipped: true
    },
    {
        id: "moon-rose",
        name: "Moon Rose",
        scheme: "dark",
        accent: "#ff757f",
        secondary: "#c099ff",
        shipped: true
    },
    {
        id: "day",
        name: "Day",
        scheme: "light",
        accent: "#2e7de9",
        secondary: "#9854f1",
        shipped: true
    }
];

// The curated accents. `gnome` is the nearest member of GNOME's own
// accent-color enum, which is a fixed list of nine we do not get to extend. It
// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so
// choosing an accent here recolors them too instead of leaving them in GNOME
// blue. Nearest by hue, not by name: "rose" maps to red rather than pink
// because it is the red role in this palette. adwaita-accent-contract reads
// this table and fails when a member is missing or is not in GNOME's enum.
var CURATED = {
    blue: {
        dark: "#82aaff", darkSecondary: "#b172b0",
        light: "#2e7de9", lightSecondary: "#9854f1",
        label: "Prism blue",
        gnome: "blue"
    },
    orchid: {
        dark: "#c099ff", darkSecondary: "#fca7ea",
        light: "#7847bd", lightSecondary: "#9854f1",
        label: "Orchid",
        gnome: "purple"
    },
    teal: {
        dark: "#86e1fc", darkSecondary: "#82aaff",
        light: "#007197", lightSecondary: "#2e7de9",
        label: "Teal",
        gnome: "teal"
    },
    green: {
        dark: "#c3e88d", darkSecondary: "#86e1fc",
        light: "#587539", lightSecondary: "#007197",
        label: "Green",
        gnome: "green"
    },
    amber: {
        dark: "#ffc777", darkSecondary: "#ff966c",
        light: "#8c6c3e", lightSecondary: "#b15c00",
        label: "Amber",
        gnome: "yellow"
    },
    orange: {
        dark: "#ff966c", darkSecondary: "#ff757f",
        light: "#b15c00", lightSecondary: "#c64343",
        label: "Orange",
        gnome: "orange"
    },
    rose: {
        dark: "#ff757f", darkSecondary: "#c099ff",
        light: "#f52a65", lightSecondary: "#9854f1",
        label: "Rose",
        gnome: "red"
    },
    slate: {
        dark: "#828bb8", darkSecondary: "#82aaff",
        light: "#6172b0", lightSecondary: "#2e7de9",
        label: "Slate",
        gnome: "slate"
    }
};

function copyColorMap(value, keys) {
    if (!value || typeof value !== "object")
        return null;
    var result = {};
    for (var index = 0; index < keys.length; index++) {
        var color = normalizedColor(value[keys[index]]);
        if (!color)
            return null;
        result[keys[index]] = color;
    }
    return result;
}

// A palette or ansi block is all-or-nothing: a record either carries every
// key valid, or the field is dropped and resolution falls back to the
// scheme's default theme. Effects are per-key: invalid entries are dropped.
function normalizePalette(value) { return copyColorMap(value, PALETTE_KEYS); }
function normalizeAnsi(value) { return copyColorMap(value, ANSI_KEYS); }

function normalizeEffects(value) {
    if (!value || typeof value !== "object")
        return null;
    var result = {};
    var any = false;
    Object.keys(EFFECT_SPEC).forEach(function(key) {
        var spec = EFFECT_SPEC[key];
        var raw = value[key];
        if (raw === undefined)
            return;
        if (spec.kind === "bool") {
            if (typeof raw !== "boolean")
                return;
            result[key] = raw;
            any = true;
            return;
        }
        var number = Number(raw);
        if (!isFinite(number))
            return;
        result[key] = Math.round(clamp(number, spec.min, spec.max));
        any = true;
    });
    return any ? result : null;
}

function copyProfile(profile) {
    var result = {
        id: profile.id,
        name: profile.name,
        scheme: profile.scheme,
        accent: profile.accent,
        secondary: profile.secondary,
        shipped: profile.shipped === true
    };
    var palette = normalizePalette(profile.palette);
    if (palette)
        result.palette = palette;
    var ansi = normalizeAnsi(profile.ansi);
    if (ansi)
        result.ansi = ansi;
    var effects = normalizeEffects(profile.effects);
    if (effects)
        result.effects = effects;
    return result;
}

function shippedList(shipped) {
    return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
}

function shippedProfiles(shipped) {
    return shippedList(shipped).map(copyProfile);
}

function curatedAccents() {
    var result = {};
    Object.keys(CURATED).forEach(function(name) {
        result[name] = Object.assign({}, CURATED[name]);
    });
    return result;
}

function isScheme(value) {
    return value === "dark" || value === "light";
}

function normalizedColor(value) {
    var color = String(value || "").trim().toLowerCase();
    return /^#[0-9a-f]{6}$/.test(color) ? color : null;
}

function normalizeStoredProfile(value) {
    if (!value || typeof value !== "object" || value.shipped === true)
        return null;

    var id = String(value.id || "").trim();
    var name = String(value.name || "").trim();
    var accent = normalizedColor(value.accent);
    var secondary = normalizedColor(value.secondary);
    if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id)
            || name.length === 0 || name.length > MAX_NAME_LENGTH
            || !isScheme(value.scheme) || !accent || !secondary)
        return null;

    var result = {
        id: id,
        name: name,
        scheme: value.scheme,
        accent: accent,
        secondary: secondary,
        shipped: false
    };
    var palette = normalizePalette(value.palette);
    if (palette)
        result.palette = palette;
    var ansi = normalizeAnsi(value.ansi);
    if (ansi)
        result.ansi = ansi;
    var effects = normalizeEffects(value.effects);
    if (effects)
        result.effects = effects;
    return result;
}

// The id is the identity here, and every custom id is custom-* namespaced, so
// it can never collide with a shipped one by accident; a repeat inside the
// stored list is a genuine duplicate and is dropped.
//
// A NAME is only a label, and a colliding one is renamed rather than dropped.
// Dropping was data loss on a delay: this list is also what gets written back
// to the preference, so a custom theme whose name a later Panama release
// happened to ship was silently deleted at the user's next theme edit.
function validCustomProfiles(values, shipped) {
    if (!Array.isArray(values))
        return [];

    var catalog = shippedList(shipped);
    var ids = {};
    catalog.forEach(function(profile) { ids[profile.id] = true; });

    var result = [];
    values.forEach(function(value) {
        var profile = normalizeStoredProfile(value);
        if (!profile || ids[profile.id])
            return;
        ids[profile.id] = true;
        profile.name = uniqueName(profile.name, catalog.concat(result));
        result.push(profile);
    });
    return result;
}

function profileCatalog(values, shipped) {
    return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
}

function boundedName(value) {
    var name = String(value || "").trim();
    if (!name)
        name = "Custom theme";
    return name.slice(0, MAX_NAME_LENGTH);
}

function uniqueName(value, profiles) {
    var requested = boundedName(value);
    var names = {};
    profiles.forEach(function(profile) {
        names[profile.name.toLowerCase()] = true;
    });
    if (!names[requested.toLowerCase()])
        return requested;

    for (var suffix = 2; suffix < 10000; suffix++) {
        var ending = " " + suffix;
        var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending;
        if (!names[candidate.toLowerCase()])
            return candidate;
    }
    return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000";
}

function slug(value) {
    var result = String(value || "").toLowerCase()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "")
        .slice(0, 48)
        .replace(/-+$/g, "");
    return result || "theme";
}

function uniqueId(name, profiles) {
    var base = "custom-" + slug(name);
    var ids = {};
    profiles.forEach(function(profile) { ids[profile.id] = true; });
    if (!ids[base])
        return base;
    for (var suffix = 2; suffix < 10000; suffix++) {
        var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix;
        if (!ids[candidate])
            return candidate;
    }
    return base.slice(0, 52) + "-10000";
}

function createCustomProfile(values, input, shipped) {
    var customs = validCustomProfiles(values, shipped);
    var catalog = shippedProfiles(shipped).concat(customs);
    var scheme = input && input.scheme;
    var accent = normalizedColor(input && input.accent);
    var secondary = normalizedColor(input && input.secondary);
    if (!isScheme(scheme) || !accent || !secondary)
        return { profiles: customs, profile: null };

    var name = uniqueName(input && input.name, catalog);
    var profile = {
        id: uniqueId(name, catalog),
        name: name,
        scheme: scheme,
        accent: accent,
        secondary: secondary,
        shipped: false
    };
    var palette = normalizePalette(input && input.palette);
    if (palette)
        profile.palette = palette;
    var ansi = normalizeAnsi(input && input.ansi);
    if (ansi)
        profile.ansi = ansi;
    var effects = normalizeEffects(input && input.effects);
    if (effects)
        profile.effects = effects;
    return { profiles: customs.concat([profile]), profile: profile };
}

function findProfile(values, id, shipped) {
    var catalog = profileCatalog(values, shipped);
    for (var index = 0; index < catalog.length; index++) {
        if (catalog[index].id === id)
            return catalog[index];
    }
    return null;
}

function editProfile(values, selected, changes, shipped) {
    var customs = validCustomProfiles(values, shipped);
    if (!selected || typeof selected !== "object")
        return { profiles: customs, profile: null };

    function pick(field, fallback) {
        return changes && changes[field] !== undefined ? changes[field] : fallback;
    }
    var accent = normalizedColor(pick("accent", selected.accent));
    var secondary = normalizedColor(pick("secondary", selected.secondary));
    var scheme = pick("scheme", selected.scheme);
    if (!isScheme(scheme) || !accent || !secondary)
        return { profiles: customs, profile: null };

    if (selected.shipped === true) {
        // Editing a shipped theme forks it into a custom, carrying the whole
        // palette so the fork looks identical until the edit lands.
        return createCustomProfile(customs, {
            name: selected.name + " custom",
            scheme: scheme,
            accent: accent,
            secondary: secondary,
            palette: pick("palette", selected.palette),
            ansi: pick("ansi", selected.ansi),
            effects: pick("effects", selected.effects)
        }, shipped);
    }

    var stored = normalizeStoredProfile(selected);
    if (!stored)
        return { profiles: customs, profile: null };

    var updated = Object.assign({}, stored, {
        scheme: scheme,
        accent: accent,
        secondary: secondary
    });
    var palette = normalizePalette(pick("palette", stored.palette));
    if (palette)
        updated.palette = palette;
    else
        delete updated.palette;
    var ansi = normalizeAnsi(pick("ansi", stored.ansi));
    if (ansi)
        updated.ansi = ansi;
    else
        delete updated.ansi;
    var effects = normalizeEffects(pick("effects", stored.effects));
    if (effects)
        updated.effects = effects;
    else
        delete updated.effects;
    var found = false;
    var next = customs.map(function(profile) {
        if (profile.id !== updated.id)
            return profile;
        found = true;
        return updated;
    });
    if (!found)
        next.push(updated);
    return { profiles: next, profile: updated };
}

function deleteProfile(values, id, shipped) {
    var customs = validCustomProfiles(values, shipped);
    var fixed = shippedList(shipped);
    for (var shippedIndex = 0; shippedIndex < fixed.length; shippedIndex++) {
        if (fixed[shippedIndex].id === id)
            return { profiles: customs, removed: false };
    }
    var next = customs.filter(function(profile) { return profile.id !== id; });
    return { profiles: next, removed: next.length !== customs.length };
}

function curatedPair(name, scheme) {
    var entry = CURATED[name] || CURATED.blue;
    if (scheme === "light")
        return { accent: entry.light, secondary: entry.lightSecondary };
    return { accent: entry.dark, secondary: entry.darkSecondary };
}

function matchingShippedProfile(scheme, accent, secondary, shipped) {
    var first = normalizedColor(accent);
    var second = normalizedColor(secondary);
    var fixed = shippedList(shipped);
    for (var index = 0; index < fixed.length; index++) {
        var profile = fixed[index];
        if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
            return copyProfile(profile);
    }
    return null;
}

// The nearest curated accent to an arbitrary pair, by hue distance of the
// primary. This is what keeps `accentName` — and through it GNOME's
// accent-color enum, kitty's border, and the lock screen — in sync when a
// theme or a custom color is chosen instead of a curated swatch.
function nearestCuratedName(scheme, accent) {
    var target = hexToHsv(accent);
    if (!target)
        return "blue";
    var bestName = "blue";
    var bestDistance = Infinity;
    Object.keys(CURATED).forEach(function(name) {
        var pair = curatedPair(name, isScheme(scheme) ? scheme : "dark");
        var candidate = hexToHsv(pair.accent);
        if (!candidate)
            return;
        var delta = Math.abs(candidate.h - target.h);
        var hueDistance = Math.min(delta, 360 - delta);
        // A desaturated accent should map to slate rather than whichever hue
        // its noise happens to lean toward.
        var distance = target.s < 12
            ? (name === "slate" ? 0 : 1000 + hueDistance)
            : hueDistance + Math.abs(candidate.s - target.s) * 0.15;
        if (distance < bestDistance) {
            bestDistance = distance;
            bestName = name;
        }
    });
    return bestName;
}

// ── Palette derivation, for the editor ──────────────────────────────────────

function mixHex(first, second, ratio) {
    var a = normalizedColor(first);
    var b = normalizedColor(second);
    var r = clamp(ratio, 0, 1);
    if (!a || !b)
        return a || b;
    function channel(offset) {
        var from = parseInt(a.slice(offset, offset + 2), 16);
        var to = parseInt(b.slice(offset, offset + 2), 16);
        return channelHex(from + (to - from) * r);
    }
    return "#" + channel(1) + channel(3) + channel(5);
}

// A full palette from the four editor wells plus a base theme. Background
// derives the surface family, foreground derives the text tints, and every
// other token is inherited from the base so a small edit stays a small edit.
function derivePalette(base, input) {
    var origin = normalizePalette(base) || null;
    var bg = normalizedColor(input && input.bg) || (origin ? origin.bg : null);
    var fg = normalizedColor(input && input.fg) || (origin ? origin.fg : null);
    var accent = normalizedColor(input && input.accent);
    var scheme = isScheme(input && input.scheme) ? input.scheme : "dark";
    if (!bg || !fg)
        return origin;

    var dark = scheme === "dark";
    var result = origin ? Object.assign({}, origin) : {};
    result.bg = bg;
    result.bgDark = mixHex(bg, "#000000", dark ? 0.10 : 0.06);
    result.bgPopover = dark ? mixHex(bg, "#000000", 0.06) : mixHex(bg, "#ffffff", 0.35);
    result.bgPanel = dark ? mixHex(bg, fg, 0.05) : mixHex(bg, "#000000", 0.03);
    result.bgHighlight = mixHex(bg, fg, dark ? 0.11 : 0.14);
    result.gutter = mixHex(bg, fg, dark ? 0.20 : 0.35);
    result.fg = fg;
    result.fgDim = mixHex(fg, bg, 0.32);
    result.fgMuted = mixHex(fg, bg, 0.47);
    if (accent)
        result.accentAlt = mixHex(accent, dark ? "#ffffff" : "#000000", 0.18);
    return normalizePalette(result);
}

// Scale the saturation of every token together. factor 1.0 is neutral;
// backgrounds move at quarter strength so the ground stays a ground.
function resaturatePalette(palette, factor) {
    var origin = normalizePalette(palette);
    if (!origin)
        return null;
    var scale = clamp(factor, 0, 2);
    var result = {};
    PALETTE_KEYS.forEach(function(key) {
        var hsv = hexToHsv(origin[key]);
        var strength = key.indexOf("bg") === 0 || key === "gutter" ? 0.25 : 1;
        var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
        result[key] = hsvToHex(hsv.h, next, hsv.v);
    });
    // Validated on the way out like every other palette this module builds: a
    // factor that is not a number survives clamp() as NaN and turns every token
    // into a string no palette reader can use. Returning null makes that a
    // rejection the caller can see instead of a corrupt theme it stores.
    return normalizePalette(result);
}

// Terminal colors for a custom theme that has none of its own: semantic
// tokens map straight onto ANSI, brights lift value a step.
function deriveAnsi(palette, accent) {
    var origin = normalizePalette(palette);
    if (!origin)
        return null;
    var blue = normalizedColor(accent) || origin.accentAlt;
    function brighten(color) {
        var hsv = hexToHsv(color);
        return hsvToHex(hsv.h, Math.max(0, hsv.s - 8), Math.min(100, hsv.v + 12));
    }
    return normalizeAnsi({
        black: origin.bgHighlight, red: origin.red, green: origin.green,
        yellow: origin.yellow, blue: blue, magenta: origin.magenta,
        cyan: origin.cyan, white: origin.fgDim,
        brightBlack: origin.gutter, brightRed: brighten(origin.red),
        brightGreen: brighten(origin.green), brightYellow: brighten(origin.yellow),
        brightBlue: brighten(blue), brightMagenta: brighten(origin.magenta),
        brightCyan: brighten(origin.cyan), brightWhite: origin.fg
    });
}

function curatedNameForProfile(profile) {
    if (!profile || !isScheme(profile.scheme))
        return "";
    var accent = normalizedColor(profile.accent);
    var secondary = normalizedColor(profile.secondary);
    var names = Object.keys(CURATED);
    for (var index = 0; index < names.length; index++) {
        var name = names[index];
        var pair = curatedPair(name, profile.scheme);
        if (pair.accent === accent && pair.secondary === secondary)
            return name;
    }
    return "";
}

function clamp(value, minimum, maximum) {
    return Math.max(minimum, Math.min(maximum, Number(value)));
}

function channelHex(value) {
    var text = Math.round(value).toString(16);
    return text.length < 2 ? "0" + text : text;
}

function hsvToHex(hue, saturation, value) {
    var h = Number(hue);
    var s = Number(saturation);
    var v = Number(value);
    if (!isFinite(h) || !isFinite(s) || !isFinite(v))
        return null;
    h = ((h % 360) + 360) % 360;
    s = clamp(s, 0, 100) / 100;
    v = clamp(v, 0, 100) / 100;

    var chroma = v * s;
    var section = h / 60;
    var x = chroma * (1 - Math.abs(section % 2 - 1));
    var red = 0;
    var green = 0;
    var blue = 0;
    if (section < 1) { red = chroma; green = x; }
    else if (section < 2) { red = x; green = chroma; }
    else if (section < 3) { green = chroma; blue = x; }
    else if (section < 4) { green = x; blue = chroma; }
    else if (section < 5) { red = x; blue = chroma; }
    else { red = chroma; blue = x; }
    var match = v - chroma;
    return "#" + channelHex((red + match) * 255)
        + channelHex((green + match) * 255)
        + channelHex((blue + match) * 255);
}

function hexToHsv(value) {
    var color = normalizedColor(value);
    if (!color)
        return null;
    var red = parseInt(color.slice(1, 3), 16) / 255;
    var green = parseInt(color.slice(3, 5), 16) / 255;
    var blue = parseInt(color.slice(5, 7), 16) / 255;
    var maximum = Math.max(red, green, blue);
    var minimum = Math.min(red, green, blue);
    var delta = maximum - minimum;
    var hue = 0;
    if (delta !== 0) {
        if (maximum === red)
            hue = 60 * (((green - blue) / delta) % 6);
        else if (maximum === green)
            hue = 60 * ((blue - red) / delta + 2);
        else
            hue = 60 * ((red - green) / delta + 4);
    }
    if (hue < 0)
        hue += 360;
    return {
        h: Math.round(hue),
        s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100),
        v: Math.round(maximum * 100)
    };
}

if (typeof module !== "undefined") {
    module.exports = {
        MAX_NAME_LENGTH: MAX_NAME_LENGTH,
        PALETTE_KEYS: PALETTE_KEYS,
        ANSI_KEYS: ANSI_KEYS,
        EFFECT_SPEC: EFFECT_SPEC,
        shippedProfiles: shippedProfiles,
        curatedAccents: curatedAccents,
        validCustomProfiles: validCustomProfiles,
        profileCatalog: profileCatalog,
        createCustomProfile: createCustomProfile,
        findProfile: findProfile,
        editProfile: editProfile,
        deleteProfile: deleteProfile,
        curatedPair: curatedPair,
        matchingShippedProfile: matchingShippedProfile,
        curatedNameForProfile: curatedNameForProfile,
        nearestCuratedName: nearestCuratedName,
        normalizePalette: normalizePalette,
        normalizeAnsi: normalizeAnsi,
        normalizeEffects: normalizeEffects,
        mixHex: mixHex,
        derivePalette: derivePalette,
        resaturatePalette: resaturatePalette,
        deriveAnsi: deriveAnsi,
        hsvToHex: hsvToHex,
        hexToHsv: hexToHsv
    };
}
