/**
 * Locale overlay resolver — TypeScript-only helper for the editor.
 *
 * Each project now ships ONE form factor (picked at create time + baked into
 * the project's canvas dims). Locale is the only runtime axis: the resolver
 * overlays per-layer `localeOverrides` and per-frame `screen.localePaths` on
 * top of a flat Layout when the active locale isn't the default.
 *
 * Pure functions, no React, no DOM.
 */
import type { Layer, Layout } from '../types/layout';

type AnyRecord = Record<string, unknown>;

function isRecord(v: unknown): v is AnyRecord {
    return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/**
 * Recursive merge: overlay scalars win, associative objects recurse, arrays
 * replace wholesale. Returns a NEW object — input is not mutated.
 */
export function deepMerge<T extends AnyRecord>(base: T, overlay: AnyRecord): T {
    const out: AnyRecord = { ...base };
    for (const k of Object.keys(overlay)) {
        const v = overlay[k];
        const bv = out[k];
        if (isRecord(v) && isRecord(bv)) {
            out[k] = deepMerge(bv as AnyRecord, v as AnyRecord);
            continue;
        }
        out[k] = v;
    }
    return out as T;
}

/**
 * Sparse field-level diff — returns only fields where `a` differs from `b`.
 * Floats compare on 5-decimal rounding to absorb derive jitter. Lists are
 * opaque (any inequality copies the full list from `a`). Nested objects
 * recurse.
 *
 * Note: when a key exists only in `b`, the diff records `null` for that key
 * (matches PHP's "removal" semantics).
 */
export function shallowDiff(a: AnyRecord, b: AnyRecord): AnyRecord {
    const out: AnyRecord = {};
    const keys = new Set<string>([...Object.keys(a), ...Object.keys(b)]);
    for (const k of keys) {
        const hasA = Object.prototype.hasOwnProperty.call(a, k);
        const hasB = Object.prototype.hasOwnProperty.call(b, k);
        const av = a[k];
        const bv = b[k];

        if (hasA && !hasB) {
            out[k] = av;
            continue;
        }
        if (!hasA && hasB) {
            out[k] = null;
            continue;
        }

        if (isRecord(av) && isRecord(bv)) {
            const sub = shallowDiff(av as AnyRecord, bv as AnyRecord);
            if (Object.keys(sub).length > 0) {
                out[k] = sub;
            }
            continue;
        }

        if (Array.isArray(av) && Array.isArray(bv)) {
            if (JSON.stringify(av) !== JSON.stringify(bv)) {
                out[k] = av;
            }
            continue;
        }

        if (Array.isArray(av) !== Array.isArray(bv)) {
            out[k] = av;
            continue;
        }

        if (typeof av === 'number' || typeof bv === 'number') {
            const ar = typeof av === 'number' ? Math.round(av * 1e5) / 1e5 : av;
            const br = typeof bv === 'number' ? Math.round(bv * 1e5) / 1e5 : bv;
            if (ar !== br) {
                out[k] = av;
            }
            continue;
        }

        if (av !== bv) {
            out[k] = av;
        }
    }
    return out;
}

/* -------------------------------------------------------------------------- */
/* applyLocaleScreenPaths                                                     */
/* -------------------------------------------------------------------------- */

/**
 * Per-locale frame screenshot swap. When a frame's `screen.localePaths[locale]`
 * is a non-empty string, rewrite `screen.storagePath` to that value. The
 * default locale (or null/empty) short-circuits — default always reads
 * `storagePath` directly.
 */
export function applyLocaleScreenPaths(layout: Layout, locale: string | null | undefined): Layout {
    if (!locale) return layout;
    const layers = layout.layers ?? [];
    if (!layers.length) return layout;

    let mutated = false;
    const nextLayers: Layer[] = layers.map((layer) => {
        const lr = layer as unknown as AnyRecord;
        if (lr.type !== 'frame') return layer;
        const screen = lr.screen as AnyRecord | undefined;
        if (!isRecord(screen)) return layer;
        const localePaths = screen.localePaths as AnyRecord | undefined;
        if (!isRecord(localePaths)) return layer;
        const candidate = localePaths[locale];
        if (typeof candidate !== 'string' || candidate === '') return layer;
        mutated = true;
        return {
            ...lr,
            screen: { ...screen, storagePath: candidate },
        } as unknown as Layer;
    });

    if (!mutated) return layout;
    return { ...layout, layers: nextLayers };
}

/* -------------------------------------------------------------------------- */
/* applyLocaleOverrides                                                       */
/* -------------------------------------------------------------------------- */

/**
 * Per-layer locale property overlay. For each layer with a non-empty
 * `localeOverrides[locale]` partial, deep-merge that partial onto the layer
 * body. Short-circuits on default locale or null/empty locale.
 *
 * `localeOverrides` itself is preserved on the merged layer so downstream
 * reads keep working — the field is a sibling of the body fields it overlays,
 * not a competitor.
 */
export function applyLocaleOverrides(
    layout: Layout,
    locale: string | null | undefined,
    defaultLocale: string,
): Layout {
    if (!locale || locale === defaultLocale) return layout;
    const layers = layout.layers ?? [];
    if (!layers.length) return layout;

    let mutated = false;
    const nextLayers: Layer[] = layers.map((layer) => {
        const lr = layer as unknown as AnyRecord;
        const overridesAll = lr.localeOverrides as AnyRecord | undefined;
        if (!isRecord(overridesAll)) return layer;
        const partial = overridesAll[locale];
        if (!isRecord(partial) || Object.keys(partial).length === 0) return layer;
        mutated = true;
        return deepMerge(lr, partial) as unknown as Layer;
    });

    if (!mutated) return layout;
    return { ...layout, layers: nextLayers };
}

/* -------------------------------------------------------------------------- */
/* applyLocaleEdit                                                            */
/* -------------------------------------------------------------------------- */

/**
 * Merge a user edit back into the locale-overlay model.
 *
 * - When `locale` is null/empty/`defaultLocale`: the edit IS the new layout
 *   (default-locale edits write straight through).
 * - When `locale !== defaultLocale`: diff `edited` against the pre-locale
 *   baseline (re-applying current layout's locale overlay STRIPPED — i.e.
 *   the layout as-is, before this locale's overrides) and write each layer's
 *   per-layer diff into `layer.localeOverrides[locale]` on the corresponding
 *   layer in the original layout. Per-locale layer additions/removals are
 *   out of scope — the layer set is shared across locales.
 *
 * Returns a NEW layout; input is not mutated.
 */
export function applyLocaleEdit(
    layout: Layout,
    locale: string | null | undefined,
    defaultLocale: string,
    edited: Layout,
): Layout {
    if (!locale || locale === defaultLocale) {
        return edited;
    }

    // Pre-locale baseline = `layout` itself. `layout` already carries
    // `localeOverrides` data; the renderer applies `applyLocaleOverrides`
    // on top to produce the visible Layout. The diff target is the raw
    // (pre-overlay) layer body.
    const byBaselineId: Record<string, AnyRecord> = {};
    for (const bl of layout.layers ?? []) {
        const br = bl as unknown as AnyRecord;
        if (typeof br.id === 'string') byBaselineId[br.id] = br;
    }

    // Deep-clone so we never mutate input.
    const next: Layout = JSON.parse(JSON.stringify(layout));
    if (!Array.isArray(next.layers)) return next;

    const nextLayers = next.layers as unknown as AnyRecord[];
    const nextIdx: Record<string, number> = {};
    nextLayers.forEach((l, i) => {
        if (isRecord(l) && typeof l.id === 'string') nextIdx[l.id] = i;
    });

    for (const el of (edited?.layers ?? []) as unknown as AnyRecord[]) {
        if (!isRecord(el) || typeof el.id !== 'string') continue;
        const id = el.id;
        const baseLayer = byBaselineId[id];
        if (!baseLayer) continue;

        // Compare edited vs. raw baseline. The renderer feeds us the
        // overlay-applied edited layer; the baseline is the pre-overlay
        // body. Differences become this-locale-only overrides.
        //
        // To diff cleanly we need to compare against the OVERLAY-applied
        // baseline (otherwise every locale-overlay field re-diffs every
        // time). Build the overlay-applied baseline inline.
        const overlay = isRecord(baseLayer.localeOverrides)
            ? ((baseLayer.localeOverrides as AnyRecord)[locale] as AnyRecord | undefined)
            : undefined;
        const overlayBaseline =
            isRecord(overlay) && Object.keys(overlay).length > 0
                ? deepMerge(baseLayer, overlay)
                : baseLayer;

        const diff = shallowDiff(el, overlayBaseline);

        // Drop `id` from the diff payload (shallowDiff only includes it on
        // type mismatch; we don't want layer-id reinjected as an override).
        delete (diff as AnyRecord).id;
        // Drop `localeOverrides` from the diff — we're writing TO it, so a
        // baseline `{localeOverrides: {...}}` shouldn't echo into itself.
        delete (diff as AnyRecord).localeOverrides;

        if (Object.keys(diff).length === 0) continue;

        const idx = nextIdx[id];
        if (idx === undefined) continue;

        const layer = nextLayers[idx];
        const existingAll = isRecord(layer.localeOverrides)
            ? (layer.localeOverrides as AnyRecord)
            : {};
        const existingForLocale = isRecord(existingAll[locale])
            ? (existingAll[locale] as AnyRecord)
            : {};
        const mergedForLocale = deepMerge(existingForLocale, diff);

        layer.localeOverrides = { ...existingAll, [locale]: mergedForLocale };
        nextLayers[idx] = layer;
    }

    next.layers = nextLayers as unknown as Layer[];
    return next;
}
