import type { Layer, Layout, TextLayer } from '@/types/layout';
import type { DevicePresetDto } from '@/types/layout';
import { applyActionsToLayout, type ChatAction } from './aiActions';
import { renderScreenToBlob } from './export/renderScreen';
import { resolveAnchors } from './anchors';
import { measureTextNaturalSize, fitTextSize } from './textFit';

/**
 * Deck-wide AI "polish pass" — the client-side iterate loop the server can't do
 * (no Konva/Chromium server-side render). For every screen it renders the
 * composition to a PNG, asks the vision model to critique it against the quality
 * bar, applies the returned `set_props` / `set_text` refinements to a detached
 * copy of the layout, and saves it. Cosmetic only (contrast, sizing, hierarchy,
 * colour) — it tunes what exists, never restructures.
 *
 * Mirrors the reference Agent Skill's measure-and-fix loop, but scoped to one
 * round per screen to bound token cost (each screen = one `/ai/chat` call,
 * metered + rate-limited by the existing chat guards).
 */

/**
 * A precise, MEASURABLE audit against the quality bar (the full bar is already in
 * the chat system prompt). Scoped to what `set_props`/`set_text` on TEXT layers
 * can actually fix — contrast, text fit, hierarchy, vertical balance. The device
 * frame, decoration and layout are template-controlled and deliberately off-limits.
 */
function polishPrompt(canvasWidth: number, canvasHeight: number, measured: TextIssue[]): string {
    const measuredBlock =
        measured.length > 0
            ? '\n\nMEASURED PROBLEMS on this screen (computed from the layout geometry — these are FACTS, not opinions; you MUST return a fix for EACH one):\n' +
              measured.map((m) => `- ${m.detail}`).join('\n') +
              '\nTo clear an OVERLAP: shrink the `fontSize` of the lower-priority text (caption / eyebrow / subhead before headline) via set_props, and/or nudge one block’s `y` (percent, centres the layer) so the two boxes no longer touch. ' +
              'To clear an OFF-CANVAS overflow: reduce that layer’s `fontSize` (and/or nudge its `y`) until it sits fully inside the canvas. Keep the headline dominant.\n'
            : '';
    return (
        `FINAL QUALITY-BAR AUDIT of this screen — you can SEE it in the attached image. Canvas is ${canvasWidth}×${canvasHeight}px. ` +
        'Check it against these MEASURABLE rules and return set_props / set_text fixes ONLY for clear violations:\n' +
        '1. CONTRAST (bar §5): every headline / subhead / eyebrow must clear WCAG AA 4.5:1 against the pixels directly behind it — judge the WORST spot, not the average. A pale tint of the brand colour on a light bg is the classic fail. Fix: set_props `color` to a darker/lighter variant of the SAME hue until it clearly reads. Accent / emphasis words count too.\n' +
        '2. TEXT FIT (bar §1, §6): no text may run off the canvas edge or clip; no single word wider than ~88% of canvas width; a headline is ≤ 3 lines. Fix: reduce `fontSize` until it fits comfortably.\n' +
        '3. HIERARCHY (bar §6): the headline must clearly dominate the subhead — visibly bigger and heavier. If they read as the same weight, raise the headline `fontSize` / `fontWeight` (or lower the subhead).\n' +
        '4. VERTICAL BALANCE (bar §1): no dead empty band taller than ~22% of canvas height. If a text block is stranded high or low leaving a big gap, nudge its `y` (0–100, percent, centres the layer) so it sits balanced in its area. Move TEXT only, small nudges.\n' +
        '5. OVERLAP (bar §1): no text block may overlap another text block. If two text boxes collide, shrink the lower-priority one’s `fontSize` or nudge its `y` until they clearly separate.\n' +
        measuredBlock +
        'HARD RULES: set_props / set_text on EXISTING TEXT layers ONLY. Never add, remove, group, duplicate, or reorder layers; never touch the device frame, background, or decoration; never resize the phone. ' +
        'If the screen has NO measured problems above AND clears every check, return an EMPTY actions array — do not restyle for taste.'
    );
}

type PolishScreen = { id: number; layout: Layout | null; version?: number };

export type PolishProgress = {
    done: number;
    total: number;
    changed: number;
    message: string;
};

/** A screen "sells" only if it has at least one text layer with real content.
 *  A screen with none is structurally silent — polish can't fix that (it only
 *  refines existing text), so we surface it instead of claiming "looks good". */
function hasCopy(layout: Layout): boolean {
    return layout.layers.some(
        (l) =>
            l.type === 'text' &&
            l.content != null &&
            Object.values(l.content).some((v) => typeof v === 'string' && v.trim() !== ''),
    );
}

type Rect = { x: number; y: number; width: number; height: number };

/**
 * The on-canvas pixel rect a text layer actually renders into — computed with the
 * SAME math the export/editor renderer uses (`resolveAnchors` for the box,
 * `fitTextSize`/`measureTextNaturalSize` for the shrink-to-fit + flowed height),
 * so overlap detection matches what the eye sees, not the nominal anchor box.
 * `auto` hugs the glyphs (offset inside its box by alignment); `shrink`/`fixed`
 * flow from the box top and can extend past the box height. Mirrors renderScreen.
 */
function textVisualRect(layer: TextLayer, layout: Layout, locale: string): Rect {
    const canvas = layout.canvas;
    const box = resolveAnchors(layer.anchors, canvas);
    if (layer.fitMode === 'auto') {
        const nat = measureTextNaturalSize(layer, locale, canvas.width);
        const leftAlign =
            layer.anchors.left && layer.anchors.left.type !== 'auto' ? layer.anchors.left.align : undefined;
        const topAlign =
            layer.anchors.top && layer.anchors.top.type !== 'auto' ? layer.anchors.top.align : undefined;
        const x =
            box.x +
            (leftAlign === 'center' ? (box.width - nat.width) / 2 : leftAlign === 'end' ? box.width - nat.width : 0);
        const y =
            box.y +
            (topAlign === 'center' ? (box.height - nat.height) / 2 : topAlign === 'end' ? box.height - nat.height : 0);
        return { x, y, width: nat.width, height: nat.height };
    }
    // shrink / fixed / fit: text flows from the box top-left, wrapped to box width.
    const eff = fitTextSize(layer, box.width, box.height, locale);
    const flowed = measureTextNaturalSize({ ...layer, fontSize: eff }, locale, box.width);
    return { x: box.x, y: box.y, width: box.width, height: flowed.height };
}

export type TextIssue = { kind: 'overlap' | 'overflow'; ids: string[]; detail: string };

/**
 * Deterministic geometry audit — the check the 480px vision thumbnail keeps
 * missing. Flags text↔text collisions and text running off the canvas edge,
 * measured from the real rendered rects. Text↔device-frame overlap is
 * intentionally NOT flagged (split/editorial layouts deliberately sit text over
 * a bleeding device). Returns confirmed facts, not judgment calls.
 */
export function detectTextGeometryIssues(layout: Layout, locale: string): TextIssue[] {
    const canvas = layout.canvas;
    const texts = layout.layers.filter(
        (l): l is TextLayer =>
            l.type === 'text' &&
            l.visible !== false &&
            l.content != null &&
            Object.values(l.content).some((v) => typeof v === 'string' && v.trim() !== ''),
    );
    const rects = texts.map((l) => ({ layer: l, rect: textVisualRect(l, layout, locale) }));
    const issues: TextIssue[] = [];

    const vTol = canvas.height * 0.015; // ignore sub-1.5% grazes (rounding / kerning)
    for (let i = 0; i < rects.length; i++) {
        for (let j = i + 1; j < rects.length; j++) {
            const a = rects[i];
            const b = rects[j];
            const ix = Math.min(a.rect.x + a.rect.width, b.rect.x + b.rect.width) - Math.max(a.rect.x, b.rect.x);
            const iy = Math.min(a.rect.y + a.rect.height, b.rect.y + b.rect.height) - Math.max(a.rect.y, b.rect.y);
            if (ix > 0 && iy > vTol) {
                const pct = Math.round((iy / canvas.height) * 100);
                issues.push({
                    kind: 'overlap',
                    ids: [a.layer.id, b.layer.id],
                    detail: `Text "${a.layer.name ?? 'text'}" (id ${a.layer.id}) and "${b.layer.name ?? 'text'}" (id ${b.layer.id}) overlap by ~${pct}% of canvas height.`,
                });
            }
        }
    }

    const hTol = canvas.width * 0.01;
    const vEdge = canvas.height * 0.01;
    for (const { layer, rect } of rects) {
        const over: string[] = [];
        if (rect.y + rect.height - canvas.height > vEdge) over.push('bottom');
        if (-rect.y > vEdge) over.push('top');
        if (rect.x + rect.width - canvas.width > hTol) over.push('right');
        if (-rect.x > hTol) over.push('left');
        if (over.length) {
            issues.push({
                kind: 'overflow',
                ids: [layer.id],
                detail: `Text "${layer.name ?? 'text'}" (id ${layer.id}) runs off the ${over.join(' + ')} canvas edge.`,
            });
        }
    }
    return issues;
}

/** Compact layer summary so the model can target real ids + reason about colour. */
function layerSummary(layers: Layer[]): Record<string, unknown>[] {
    return layers.map((l) => {
        const fill =
            l.type === 'text'
                ? (l.fill ?? (typeof l.color === 'string' ? { type: 'solid', value: l.color } : undefined))
                : l.type === 'background'
                  ? l.fill
                  : l.style?.fill;
        const c = fill as { type?: string; value?: string } | undefined;
        const hint = c?.type ? (c.type === 'solid' ? c.value : c.type) : undefined;
        return {
            id: l.id,
            type: l.type,
            name: l.name ?? '',
            ...(l.type === 'text' ? { content: l.content } : {}),
            ...(hint ? { fillHint: hint } : {}),
        };
    });
}

async function blobToDataUrl(blob: Blob): Promise<string> {
    return new Promise((resolve, reject) => {
        const r = new FileReader();
        r.onload = () => resolve(r.result as string);
        r.onerror = reject;
        r.readAsDataURL(blob);
    });
}

function csrfHeaders(): Record<string, string> {
    const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
    const h: Record<string, string> = {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
    };
    if (csrf) h['X-CSRF-TOKEN'] = csrf;
    return h;
}

/** Critique one screen and return the refinement actions + the deterministically
 *  measured geometry issues (so the caller can report honestly when the model
 *  fails to act on a confirmed problem). Throws only on a failed request. */
async function critiqueScreen(opts: {
    screenId: number;
    layout: Layout;
    factor: string;
    locale: string;
    devicePresets: DevicePresetDto[];
}): Promise<{ actions: ChatAction[]; issues: TextIssue[] }> {
    // Deterministic geometry audit first — never depends on the vision thumbnail.
    const issues = detectTextGeometryIssues(opts.layout, opts.locale);

    let image: string | null = null;
    try {
        const aspect = opts.layout.canvas.height / opts.layout.canvas.width;
        const targetWidth = 640;
        const blob = await renderScreenToBlob(opts.layout, {
            devicePresets: opts.devicePresets,
            targetWidth,
            targetHeight: Math.round(targetWidth * aspect),
            locale: opts.locale,
        });
        image = await blobToDataUrl(blob);
    } catch {
        // Can't render a preview — still send the measured problems (text only,
        // no image) so a confirmed overlap can be fixed without the thumbnail.
        image = null;
    }

    const res = await fetch('/ai/chat', {
        method: 'POST',
        credentials: 'same-origin',
        headers: csrfHeaders(),
        body: JSON.stringify({
            screen_id: opts.screenId,
            factor: opts.factor,
            locale: opts.locale,
            scope: 'screen',
            message: polishPrompt(opts.layout.canvas.width, opts.layout.canvas.height, issues),
            layers: layerSummary(opts.layout.layers),
            image,
        }),
    });
    const data = (await res.json().catch(() => ({}))) as { ok?: boolean; actions?: ChatAction[] };
    if (!res.ok || !data.ok) throw new Error('Polish request failed');

    // Hard guard: a polish pass only ever retouches TEXT. Drop any action that
    // isn't set_props/set_text on an actual text layer — so a stray instruction
    // can never recolour the device frame, background, or decoration.
    const textIds = new Set(opts.layout.layers.filter((l) => l.type === 'text').map((l) => l.id));
    const actions = (data.actions ?? []).filter(
        (a): a is ChatAction =>
            (a.op === 'set_props' || a.op === 'set_text') &&
            typeof (a as { layer_id?: unknown }).layer_id === 'string' &&
            textIds.has((a as { layer_id: string }).layer_id),
    );
    return { actions, issues };
}

export async function runPolishPass(opts: {
    screens: PolishScreen[];
    factor: string;
    defaultLocale: string;
    locale: string;
    devicePresets: DevicePresetDto[];
    onProgress?: (p: PolishProgress) => void;
    signal?: AbortSignal;
}): Promise<{ changed: number; total: number; silent: number; unresolved: number }> {
    const screens = opts.screens.filter((s) => s.layout != null);
    const total = screens.length;
    // Structurally-silent screens (no copy at all) — polish can't fix these; they
    // need a regenerate. Counted so the summary is honest, not "all good".
    const silent = screens.filter((s) => !hasCopy(s.layout as Layout)).length;
    let changed = 0;
    // Screens with a confirmed overlap/overflow the model couldn't resolve — so
    // the summary never claims "all good" over a screen that still has a defect.
    let unresolved = 0;

    for (let i = 0; i < screens.length; i++) {
        if (opts.signal?.aborted) break;
        const s = screens[i];
        opts.onProgress?.({ done: i, total, changed, message: `Polishing screen ${i + 1} of ${total}…` });

        try {
            const { actions, issues } = await critiqueScreen({
                screenId: s.id,
                layout: s.layout as Layout,
                factor: opts.factor,
                locale: opts.locale,
                devicePresets: opts.devicePresets,
            });
            const { layout: nextLayout, applied } = applyActionsToLayout(s.layout as Layout, actions, {
                activeLocale: opts.locale,
                defaultLocale: opts.defaultLocale,
            });
            let saved = false;
            if (applied > 0) {
                const res = await fetch(`/screens/${s.id}`, {
                    method: 'PUT',
                    credentials: 'same-origin',
                    headers: csrfHeaders(),
                    body: JSON.stringify({
                        factor: opts.factor,
                        layout: nextLayout,
                        ...(typeof s.version === 'number' ? { base_version: s.version } : {}),
                    }),
                });
                if (res.ok) {
                    changed++;
                    saved = true;
                }
            }
            // Honest verdict: re-measure the layout we saved (or the original when
            // nothing was applied). A confirmed geometry defect still present =
            // unresolved, so the summary flags it instead of claiming "all good".
            if (issues.length > 0) {
                const check = detectTextGeometryIssues(saved ? nextLayout : (s.layout as Layout), opts.locale);
                if (check.length > 0) unresolved++;
            }
        } catch {
            // One screen failing shouldn't abort the whole deck — skip it.
        }
    }

    opts.onProgress?.({ done: total, total, changed, message: 'Polish complete.' });
    return { changed, total, silent, unresolved };
}
