/**
 * layout.json v3 — canonical TypeScript source for the screen scene graph.
 * PHP DTOs are generated from these types; never the other way around.
 *
 * Schema invariants:
 *   - Every Layout carries `version: 3` (single, post-frame-absorbs-screen).
 *     Loaders must migrate older versions before use; unknown versions must
 *     be rejected, not coerced.
 *   - Positions are anchor-based, never absolute. See {@link Anchors}.
 *   - Every text layer has `content: LocalizedText` with the full locale map
 *     present even when only the default locale is active.
 *   - Locale is the only runtime axis: per-layer `localeOverrides` + per-
 *     frame `screen.localePaths` swap content for a non-default locale.
 *     Form-factor is project-level metadata (picked once at create time),
 *     not a runtime axis.
 *   - Mirror override on every layer so RTL auto-mirror can be vetoed per
 *     layer (logos, screenshots usually don't mirror).
 *
 * v1 → v2 (2026-04-25): the standalone `screenshot` layer type was retired.
 * App-screen content now lives on `FrameLayer.screen`, so a device mockup is
 * one layer — frame-level skew/tilt/flip cover the bezel and screen together.
 * Standalone (un-framed) screenshots fall back to the `image` type.
 * v2 → v3: structural no-op stamp; per-factor envelope storage retired —
 * each project now ships ONE form factor, picked at create time.
 */
export type LayoutVersion = 3;

export type LocaleCode = 'en' | 'ar' | 'he' | (string & {});

export type Direction = 'ltr' | 'rtl';

/**
 * Device/size family a project targets. One factor per project — picked at
 * create time, baked into the project's canvas dims via
 * {@link FORM_FACTOR_DEFAULT_CANVAS}. After creation, form_factor is
 * metadata only — the editor doesn't switch factors at runtime. Mac/TV/
 * Watch/Vision land in v2.
 */
export type FormFactor =
    | 'iphone_portrait'
    | 'ipad_portrait'
    | 'android_phone_portrait'
    // Single Android tablet tier — Google Play accepts a wide pixel range
    // (1080–7680) and auto-scales submitted screenshots to every device
    // size, so we ship one canonical Android tablet canvas (Samsung Galaxy
    // Tab S8 Ultra, 14.6" device, 1848×2960). The Play Console's notional
    // 7" / 10" submission slots both accept this resolution.
    | 'android_10_tablet_portrait'
    | 'iphone_landscape'
    | 'ipad_landscape'
    | 'android_phone_landscape'
    | 'android_10_tablet_landscape'
    // Google Play marketing assets. Feature graphic is a 1024×500 banner
    // shown above the screenshot strip on the Play Store listing — not a
    // screenshot, but lives in the same "what do you want to design for
    // this app?" picker. No device frame; the canvas is the asset itself.
    | 'playstore_feature_graphic';

/**
 * Per-factor screenshot completeness, computed server-side in
 * `ProjectController::show`. `frames` = frame layers present on the factor;
 * `missing` = how many of them have no screenshot. Drives the ExportModal
 * pre-flight warning + the FormFactorSwitcher badge — frames start
 * screenshot-less on every factor the user hasn't authored (the
 * cross-factor stamp intentionally clears `screen`).
 */
export type FactorScreenshotGaps = Partial<
    Record<FormFactor, { frames: number; missing: number }>
>;

/**
 * Canvas-space pixels between adjacent screens in the workspace view. Both the
 * server (`ProjectController::VIEWPORT_GUTTER`) and the client recompute screen
 * `viewport_x` from this; the two must match exactly or the workspace canvas
 * drifts from the server-stored layout. PHP-side mirror lives at
 * `app/Http/Controllers/ProjectController.php` — keep the values paired.
 */
export const WORKSPACE_VIEWPORT_GUTTER = 200;

export const FORM_FACTOR_DEFAULT_CANVAS: Record<FormFactor, { w: number; h: number }> = {
    iphone_portrait: { w: 1320, h: 2868 },
    ipad_portrait: { w: 2064, h: 2752 },
    android_phone_portrait: { w: 1080, h: 1920 },
    android_10_tablet_portrait: { w: 1848, h: 2960 },
    iphone_landscape: { w: 2868, h: 1320 },
    ipad_landscape: { w: 2752, h: 2064 },
    android_phone_landscape: { w: 1920, h: 1080 },
    android_10_tablet_landscape: { w: 2960, h: 1848 },
    playstore_feature_graphic: { w: 1024, h: 500 },
};

/** Localized text map. `default` is the fallback when a locale key is missing. */
export type LocalizedText = {
    default: string;
} & Partial<Record<LocaleCode, string>>;

/**
 * Resolve a LocalizedText for a given locale. Returns the per-locale override
 * when present + non-null, otherwise falls back to `content.default`. Pass
 * `undefined` / `null` / `''` for the locale to get the default — handy for
 * call sites that don't know whether they're locale-aware yet.
 */
export function resolveLocalizedText(content: LocalizedText, locale?: string | null): string {
    if (!locale) return content.default ?? '';
    const v = content[locale as LocaleCode];
    return v ?? content.default ?? '';
}

/** Whether a LocalizedText carries an explicit override for the given locale
 *  (vs falling back to default). Used by the properties panel to render the
 *  "Inherited from default" badge. */
export function hasLocaleOverride(content: LocalizedText, locale: string): boolean {
    return locale !== 'default' && Object.prototype.hasOwnProperty.call(content, locale);
}

/** LTR/RTL direction. Mirror of PHP `App\Support\Locales::directionFor`'s
 *  return type. Used to flip text alignment + textarea `dir` attribute for
 *  Arabic / Hebrew / Urdu / Persian. */
export type LocaleDirection = 'ltr' | 'rtl';

/** Map `align: 'start' | 'end'` to Konva's literal 'left' | 'right' given
 *  the writing direction. `'left' | 'right' | 'center' | 'justify'` pass
 *  through unchanged — those are explicit user overrides that should NOT
 *  flip with locale.
 *
 *  Shared between LayerNode, InlineTextEditor, and the export renderer so a
 *  Spanish edit on iPhone Portrait renders identically across the three. */
export function resolveTextAlign(
    align: 'start' | 'end' | 'left' | 'right' | 'center' | 'justify' | undefined,
    direction: LocaleDirection,
): 'left' | 'right' | 'center' | 'justify' {
    if (align === 'center' || align === 'left' || align === 'right' || align === 'justify') {
        return align;
    }
    if (align === 'end') return direction === 'rtl' ? 'left' : 'right';
    // 'start' or undefined
    return direction === 'rtl' ? 'right' : 'left';
}

/* -------------------------------------------------------------------------- */
/* Anchors                                                                    */
/* -------------------------------------------------------------------------- */

export type AnchorValue =
    | { type: 'percent'; value: number; align?: 'start' | 'center' | 'end' }
    | { type: 'px'; value: number; align?: 'start' | 'center' | 'end' }
    | { type: 'auto' };

export type Anchors = {
    top?: AnchorValue;
    right?: AnchorValue;
    bottom?: AnchorValue;
    left?: AnchorValue;
    width?: AnchorValue;
    height?: AnchorValue;
};

/* -------------------------------------------------------------------------- */
/* Style                                                                      */
/* -------------------------------------------------------------------------- */

export type PatternId = 'dots' | 'lines' | 'grid' | 'crosshatch';

export type Color =
    | { type: 'solid'; value: string }
    | {
          type: 'linear';
          angle: number;
          stops: Array<{ offset: number; color: string }>;
      }
    | {
          type: 'radial';
          cx: number;
          cy: number;
          radius: number;
          stops: Array<{ offset: number; color: string }>;
      }
    | { type: 'pattern'; patternId: PatternId; color: string; scale?: number };

export type Shadow = {
    color: string;
    blur: number;
    offsetX: number;
    offsetY: number;
    opacity: number;
};

export type StrokePosition = 'center' | 'inside' | 'outside';

export type StrokeDash = 'solid' | 'dashed' | 'dotted';

export type LineCap = 'butt' | 'round' | 'square';

export type LineJoin = 'miter' | 'round' | 'bevel';

/**
 * Canvas 2D `globalCompositeOperation` values exposed as blend modes. `normal`
 * maps to the omitted/undefined state so the default stays `source-over`.
 */
export type BlendMode =
    | 'normal'
    | 'multiply'
    | 'screen'
    | 'overlay'
    | 'darken'
    | 'lighten'
    | 'color-dodge'
    | 'color-burn'
    | 'hard-light'
    | 'soft-light'
    | 'difference'
    | 'exclusion'
    | 'hue'
    | 'saturation'
    | 'color'
    | 'luminosity';

export type LayerStyle = {
    fill?: Color;
    stroke?: {
        /**
         * Stroke paint. Plain strings are legacy shorthand for a solid color
         * (`'#0F172A'` ≡ `{ type: 'solid', value: '#0F172A' }`) — renderers
         * normalize via `strokeColorAsColor` in `colors.ts`. Gradient strokes
         * store the full `Color` union.
         */
        color: string | Color;
        width: number;
        /**
         * Where the stroke sits relative to the shape edge. `center` matches
         * Konva's native rendering. `inside`/`outside` render as a Group with
         * an offset stroke-only shape so fill doesn't leak past the edge.
         * Honored for rect, ellipse, polygon, star, and closed path. Other
         * shapes (line, arrow, open path) always render `center`.
         */
        position?: StrokePosition;
        /** Dash preset. Actual pattern is computed from stroke width at render time. */
        dash?: StrokeDash;
        /**
         * Offset into the dash pattern (pixels). Useful for "marching ants"
         * effects or aligning dashes across adjacent shapes. Ignored when
         * `dash` is solid/undefined. Not currently surfaced in the UI —
         * `dashGap` covers the typical "more / less space" intent better;
         * field preserved for back-compat with layouts saved before the
         * Dash-offset → Dash-gap rename.
         */
        dashOffset?: number;
        /**
         * Multiplier on stroke width for the GAP between dashes / dots.
         * Default 2 (used when undefined) produces the historical Konva-
         * style pattern. Lower values (1-1.5) tighten the pattern; higher
         * values (3-8) space it out. Applied at render time inside
         * `dashConfig`. Mirror in `editor/export/renderScreen.ts` —
         * editor preview + exported ZIP read the same multiplier.
         */
        dashGap?: number;
        /**
         * Endpoint shape for open paths (line, arrow). Ignored for closed
         * shapes. Dotted dash forces `round` at render time so the zero-length
         * segments still render as visible dots.
         */
        lineCap?: LineCap;
        /**
         * Corner shape for joined stroke segments. Relevant for shapes with
         * vertices (rect, polygon, star, path, arrow). Ignored by line/ellipse.
         */
        lineJoin?: LineJoin;
    };
    opacity?: number;
    /** Compositing mode against layers below. `normal`/undefined = `source-over`. */
    blendMode?: BlendMode;
    /**
     * Legacy single-shadow field. When both `shadow` and `shadows` are set,
     * `shadows` wins. Renderers normalize via `resolveShadows()` in `shadows.ts`.
     */
    shadow?: Shadow;
    /**
     * Multi-shadow array. Rendered by stacking N copies of the shape, one per
     * shadow. Last entry paints on top. Cap at 4 in the UI to keep render cost
     * bounded.
     */
    shadows?: Shadow[];
    radius?: number;
    /**
     * Gaussian blur radius in canvas pixels applied to the rendered shape
     * (Konva.Filters.Blur + node cache). 0 or undefined = no blur. Used for
     * soft decorative blobs/glows. Mirror in `editor/export/renderScreen.ts`
     * and the editor `CanvasStage` shape renderer.
     */
    blur?: number;
    /**
     * Per-corner radius overrides for rectangles. When present, takes
     * precedence over `radius` on a corner-by-corner basis (missing corners
     * fall back to `radius`).
     */
    radii?: {
        topLeft?: number;
        topRight?: number;
        bottomRight?: number;
        bottomLeft?: number;
    };
};

/* -------------------------------------------------------------------------- */
/* Layers — discriminated union on `type`                                     */
/* -------------------------------------------------------------------------- */

type LayerBase = {
    id: string;
    name?: string;
    anchors: Anchors;
    rotation?: number;
    mirror?: boolean | 'auto';
    visible?: boolean;
    locked?: boolean;
    style?: LayerStyle;
    /**
     * ID of the containing GroupLayer. Absent = top-level. Children keep
     * their own absolute anchors — groups only affect selection + z-order
     * bundling, not coordinate space.
     */
    parentId?: string;
    /** Per-locale scalar overrides that can't be expressed via content alone. */
    localeOverrides?: Partial<Record<LocaleCode, Partial<LayerBase>>>;
    /** Reserved for v2 component-instance support — no UI reads this yet. */
    componentRef?: { componentId: string; overrides?: Record<string, unknown> };
};

/**
 * Frame-level transform mixin. Skew + 3D tilt + axis flip apply to the whole
 * device mockup (bezel + bound screen). Rotation lives on LayerBase. Crop and
 * image adjust live on `FrameScreen` — they only make sense on the inner
 * raster, not on the frame as a whole.
 */
export type FrameTransform = {
    flipX?: boolean;
    flipY?: boolean;
    /** Horizontal skew in degrees (-45..45). Render converts to Konva slope via `Math.tan(rad)`. */
    skewX?: number;
    skewY?: number;
    /**
     * 3D perspective tilt around the vertical (Y) axis, in degrees (-45..45).
     * Renders via strip-based trapezoid on a cached raster of the whole frame
     * group (bezel + bound screen). When both axes are set, the dominant axis
     * wins.
     */
    tiltY?: number;
    /** 3D tilt around the horizontal (X) axis, in degrees (-45..45). */
    tiltX?: number;
};

/**
 * App screenshot bound to a device frame. The frame supplies the bezel + inner
 * screen rect; the screen carries the raster + per-image pan, zoom, fit, crop
 * and filter adjustments. Removing `screen` empties the device — the frame
 * then paints `screenFill` instead.
 *
 * Replaces the v1 `ScreenshotLayer` + `frameId` binding pair: a device mockup
 * is now exactly one layer, so frame-level skew/tilt/flip work without having
 * to keep a sibling layer in lockstep. See `migrateLayout.ts` for the v1→v2
 * fold and `frameId`-orphan recovery (standalone screenshot → image layer).
 */
export type FrameScreen = {
    storagePath: string;
    /** Per-locale source path overrides. Resolver swaps `storagePath` to the
     *  matching locale at render time (default locale always reads
     *  `storagePath`). */
    localePaths?: Partial<Record<LocaleCode, string>>;
    fit?: 'cover' | 'contain' | 'fill';
    /** Horizontal pan as a fraction of the inner-screen rect width (-1..1). */
    offsetX?: number;
    /** Vertical pan as a fraction of the inner-screen rect height (-1..1). */
    offsetY?: number;
    /** Multiplier on top of the fit-computed scale. 1 or undefined = stock fit. */
    zoom?: number;
    crop?: ImageCrop;
    adjust?: ImageAdjust;
    /**
     * Hides the screenshot's status bar by painting a colored rect over the
     * top edge of the screen cutout. Useful when a screenshot was captured
     * with iOS Status Bar Tools / Simulator's clock-9:41 mode disabled (or
     * shows a development build's clock/battery state). Color defaults to
     * the screenshot's top-row mean color (auto-detected on enable); height
     * is a percentage of the visible screen-rect height.
     */
    statusBarOverlay?: {
        enabled: boolean;
        /** Hex color (#RRGGBB). Auto-detected on enable; user-editable. */
        color?: string;
        /** Overlay height as a percentage of the screen's visible height (0..30). */
        heightPct?: number;
    };
};

/**
 * Device mockup. The bezel + the optional bound screen render as one layer
 * (replaces the v1 frame + screenshot pair coupled by `frameId`). Frame-level
 * transforms (skew, tilt, flip) apply to bezel and screen together — that's
 * the whole point of the merge.
 */
export type FrameLayer = LayerBase &
    FrameTransform & {
        type: 'frame';
        devicePresetId: number;
        /** Inner-screen background color when `screen` is unset. Defaults to #F4F4F5. */
        screenFill?: Color;
        /** Bound app screenshot. Absent = empty device, paints `screenFill`. */
        screen?: FrameScreen;
        /**
         * Toggles between the procedural bezel renderer (default / undefined =
         * current behavior) and the photorealistic PNG renderer wired in Phase
         * 2 of the real-device-frames plan. When true, the renderer reads the
         * device preset's `has_real_frame` + `real_frame_screen_rect` and
         * composites the PNG asset for the picked color slug. Undefined / false
         * paints the existing procedural bezel.
         */
        realFrameEnabled?: boolean;
        /**
         * Color slug into the device preset's `real_frame_color_options` array
         * (mirrors the keys in `public/device-frames/manifest.json`), e.g.
         * 'natural-titanium'. Only meaningful when `realFrameEnabled` is true.
         */
        realFrameColor?: string;
        /**
         * Multi-device pivot Phase 3.5+: when `true` (default, treated as
         * `true` when the field is absent), the server propagates this
         * frame's `devicePresetId` across other active factors via
         * `device_presets.family_id`. Setting it to `false` opts the
         * layer out of cross-factor sync — the user can pick divergent
         * per-factor devices intentionally. Lives on the shared layer
         * body in the v4 envelope; flows through to the v3 projection
         * via `LayoutResolver::resolveLayoutForFactorV4`.
         */
        linkDeviceFamily?: boolean;
    };

export type DevicePresetDto = {
    id: number;
    key: string;
    label: string;
    category: 'phone' | 'tablet';
    form_factor: FormFactor;
    /**
     * App Store Connect display-size tier (e.g. `iphone-6.9`, `ipad-13`).
     * Multiple device presets can share a tier when their pixel dimensions
     * match Apple's accepted set for the same submission slot.
     */
    apple_tier: string;
    export_w: number;
    export_h: number;
    safe_area?: SafeArea | null;
    /**
     * True when a photorealistic frame PNG bundle exists for this preset
     * (manifest entry at `public/device-frames/manifest.json`). When false,
     * the `realFrameEnabled` flag on a FrameLayer is ignored — only the
     * procedural renderer runs.
     */
    has_real_frame?: boolean;
    /**
     * Color options for the photorealistic frame. Surfaced to the Real-tab
     * picker so the admin can choose a chassis color. Null when
     * `has_real_frame` is false.
     */
    real_frame_color_options?: Array<{
        slug: string;
        label: string;
        swatch: string;
    }> | null;
    /**
     * Screen cutout geometry per orientation. The renderer reads this to
     * position the user's screenshot inside the photorealistic frame PNG.
     * Null when `has_real_frame` is false.
     */
    real_frame_screen_rect?: {
        portrait: {
            x: number;
            y: number;
            width: number;
            height: number;
            radius: number;
            /** Native PNG width — used as the pre-load fallback for the
             *  renderer's scaling math so the screen-cutout sits at the
             *  right coords before `frameImage.naturalWidth` resolves. */
            frame_w?: number;
            frame_h?: number;
        };
        landscape: {
            x: number;
            y: number;
            width: number;
            height: number;
            radius: number;
            frame_w?: number;
            frame_h?: number;
        };
    } | null;
};

/** Full-bleed background: solid, gradient, or image. */
export type BackgroundLayer = LayerBase & {
    type: 'background';
    fill: Color;
    image?: {
        storagePath: string;
        fit: 'cover' | 'contain' | 'tile';
        /** Opacity 0..1. Lets the underlying `fill` bleed through. Defaults to 1. */
        opacity?: number;
        /** Gaussian blur radius in canvas pixels. 0 or undefined = no blur. */
        blur?: number;
        /**
         * Tint painted over the image so foreground text stays readable on
         * busy photos. Rendered after the image, inside the same clip group.
         */
        overlay?: { color: string; opacity: number };
        /**
         * Solid-color recolour mixed INTO the image via Konva's RGBA filter
         * (preserves the image's own alpha — transparent regions stay clear, only
         * visible pixels shift toward `color`). `color` is `#RRGGBB`; `amount` is
         * the blend strength 0..1 (1 = fully the colour). Recolours a
         * white-on-transparent abstract pattern to an on-brand shade so it reads
         * on a light canvas (AI sets this from the palette `decoration`). Mirrors
         * `ImageLayer.tint`; distinct from `overlay` (a flat full-rect wash).
         */
        tint?: { color: string; amount: number };
        /** Multiplier on top of the fit-computed scale. 1 = stock fit. */
        zoom?: number;
        /** Horizontal shift as a fraction of canvas width (-1..1). */
        offsetX?: number;
        /** Vertical shift as a fraction of canvas height (-1..1). */
        offsetY?: number;
    };
};

export type TextFitMode = 'auto' | 'fixed' | 'shrink' | 'wrap' | 'clip';

export type TextTransform = 'none' | 'uppercase' | 'lowercase' | 'capitalize';

export type TextLayer = LayerBase & {
    type: 'text';
    content: LocalizedText;
    /**
     * Opt-in: when true, `content` syncs to same-id text layers in other
     * form-factor variants of this screen on autosave. Server-side propagation;
     * the editor store only ever holds the active variant.
     */
    linkedContent?: boolean;
    fontFamily: string;
    fontWeight?: number | string;
    fontStyle?: 'normal' | 'italic';
    fontSize: number;
    minFontSize: number;
    maxFontSize: number;
    lineHeight?: number;
    letterSpacing?: number;
    align?: 'start' | 'center' | 'end' | 'justify';
    textTransform?: TextTransform;
    /** Solid fallback used when `fill` is absent. */
    color: string;
    /** Optional richer fill (gradient); overrides `color` when set. */
    fill?: Color;
    fitMode: TextFitMode;
    /**
     * SPIKE (inline rich text): ordered runs whose concatenated `text` equals the
     * resolved default-locale string. Each run may override `color` / `bold`.
     * Rendered via Konva `charRenderFunc` on the SAME single Text node, so all
     * existing layout/auto-fit/anchors are untouched. Proof-of-concept only —
     * not migrated, default-locale only, LTR only. See `editor/richText.ts`.
     */
    richRuns?: Array<{ text: string; color?: string; bold?: boolean; italic?: boolean }>;
    /**
     * SPIKE (inline rich text) — per-locale runs for LOCALE OVERRIDES. `richRuns`
     * covers the default locale; this map holds runs for any locale whose
     * `content` was overridden (concatenated text === `content[locale]`). A
     * locale that falls back to the default text uses `richRuns`.
     */
    richRunsByLocale?: Record<string, Array<{ text: string; color?: string; bold?: boolean; italic?: boolean }>>;
};

/**
 * Color / tone adjustments applied via Konva's filter pipeline. Konva requires
 * `node.cache()` before filters run; value changes after caching are live. Keep
 * ranges matched to Konva's attrs so we can pass values through untouched.
 */
export type ImageAdjust = {
    /** Konva Brighten: -1..1. 0 = no change. */
    brightness?: number;
    /** Konva Contrast: -100..100. 0 = no change. */
    contrast?: number;
    /** Konva HSL saturation: -1..1. 0 = no change. */
    saturation?: number;
    /** Konva HSL hue rotation in degrees: -180..180. 0 = no change. */
    hue?: number;
    /** Konva Blur radius in px. 0 or undefined = no blur. */
    blur?: number;
    grayscale?: boolean;
    invert?: boolean;
    /**
     * Solid-color wash mixed into the image via Konva RGBA filter. `color` is
     * `#RRGGBB`; `amount` is the blend strength 0..1. 0 or missing = no tint.
     */
    tint?: { color: string; amount: number };
};

/**
 * Per-side crop as fractions (0..1) of the source image. Resolution-independent:
 * swapping in a higher-res image keeps the same visible region. Missing sides
 * default to 0 (no crop). Render must clamp so remaining width/height > 0.
 */
export type ImageCrop = {
    top?: number;
    right?: number;
    bottom?: number;
    left?: number;
};

/**
 * Shared transform extras for image layers. Lets users pan/zoom within a
 * Cover/Contain fit, mirror on either axis, skew, or 3D-tilt without touching
 * the layer rotation. (App-screen content lives on `FrameLayer.screen`; the
 * frame carries its own `FrameTransform` for skew/tilt/flip.)
 */
type RasterTransform = {
    flipX?: boolean;
    flipY?: boolean;
    /** Multiplier on top of the fit-computed scale. 1 or undefined = stock fit. */
    zoom?: number;
    /** Horizontal pan as a fraction of the layer rect width (-1..1). */
    offsetX?: number;
    /** Vertical pan as a fraction of the layer rect height (-1..1). */
    offsetY?: number;
    /**
     * Horizontal skew in degrees (-45..45). Stored in degrees for UI parity;
     * render converts to Konva's slope factor via `Math.tan(rad)`.
     */
    skewX?: number;
    skewY?: number;
    /**
     * 3D perspective tilt around the vertical (Y) axis, in degrees (-45..45).
     * Positive angles recede the right edge (image looks rotated clockwise from
     * above). Renders via strip-based trapezoid — image adjust filters are
     * skipped while tilted since Konva's filter pipeline requires the native
     * Image node. When both tiltX and tiltY are set, the dominant axis wins.
     */
    tiltY?: number;
    /**
     * 3D perspective tilt around the horizontal (X) axis, in degrees (-45..45).
     * Positive angles recede the top edge (looking down at the image). Same
     * strip-based rendering as tiltY; see note on compound tilt above.
     */
    tiltX?: number;
    crop?: ImageCrop;
    adjust?: ImageAdjust;
};

export type ImageLayer = LayerBase &
    RasterTransform & {
        type: 'image';
        storagePath: string;
        fit: 'cover' | 'contain' | 'fill';
        alt?: string;
        /**
         * When set, the image renders clipped to the bound shape layer's path.
         * The image keeps its own anchors (user can still reposition/crop/tilt
         * independently of the shape); the shape acts purely as a mask.
         * Bound layer's shape must be closed + non-degenerate (not line/arrow).
         */
        clipShapeId?: string;
        /**
         * Color tint painted on top of the image. Rendered via `source-atop`
         * composite so it respects the image's alpha — transparent PNG regions
         * stay clear, only visible pixels get tinted. Useful for adding a brand
         * cast or darkening for foreground text contrast.
         */
        overlay?: { color: string; opacity: number };
    };

export type ShapeKind = 'rect' | 'ellipse' | 'line' | 'polygon' | 'arrow' | 'star' | 'path';

/**
 * Pen-tool anchor. All coordinates are fractions (0..1) of the layer's bbox,
 * matching how other shapes derive geometry from the rect at render time —
 * swapping device sizes or sibling-seeding a factor scales the path with
 * the layer instead of needing re-authoring.
 *
 * `handleIn` / `handleOut` are bezier control points relative to the anchor
 * (same 0..1 fraction space). Missing handles = corner anchor (straight
 * segments). Present handles = smooth anchor (cubic bezier segments).
 */
export type PathNode = {
    x: number;
    y: number;
    handleIn?: { x: number; y: number };
    handleOut?: { x: number; y: number };
};

export type ShapeLayer = LayerBase & {
    type: 'shape';
    shape: ShapeKind;
    /** Reserved for irregular polygons (v2). Regular polygons use `sides`. */
    points?: Array<{ x: number; y: number }>;
    /** For shape === 'path': pen-tool anchors in fraction-of-bbox space. */
    nodes?: PathNode[];
    /** For shape === 'path': whether to close the last segment back to nodes[0]. */
    closed?: boolean;
    /** For shape === 'polygon': number of sides (3–12). Default 6. */
    sides?: number;
    /** For shape === 'star': number of star points (3–12). Default 5. */
    starPoints?: number;
    /**
     * For shape === 'star': inner radius as fraction of outer (0.1–0.9). Default 0.4.
     * For shape === 'ellipse': inner hole radius as fraction of outer (0–0.95). Default 0 (solid disc).
     */
    innerRatio?: number;
    /**
     * For shape === 'ellipse': arc start angle in degrees. 0 = top, clockwise positive.
     * Only meaningful when `sweepAngle` is less than 360. Default 0.
     */
    startAngle?: number;
    /**
     * For shape === 'ellipse': arc sweep in degrees (0–360). Default 360 (full ellipse).
     * Combined with `innerRatio`, produces arcs, pies, rings, and ring segments.
     */
    sweepAngle?: number;
    /**
     * For shape === 'arrow': arrowhead size multiplier. Effective pointer
     * length/width = `strokeWidth * 3 * arrowHead`. Default 1.
     */
    arrowHead?: number;
    /**
     * For shape === 'arrow': which end(s) get a head. Default 'end'.
     */
    arrowAt?: 'start' | 'end' | 'both';
    /**
     * Vector ICON glyph. A list of SVG path `d` strings in a 24×24 viewBox. When
     * present the shape renders as an icon badge: the normal `shape` geometry
     * becomes the tile (fill/radius/shadow) and these paths are stroked centered
     * + inset on top in `iconColor`.
     */
    iconPaths?: string[];
    /** Stroke color for `iconPaths` (the glyph). Falls back to the stroke color. */
    iconColor?: string;
    /**
     * Filled vector graphic (a decorative SVG silhouette — laurel wreaths,
     * ribbons, dividers). A list of SVG path `d` strings in the `vectorViewBox`
     * coordinate space, FILLED with `style.fill` and fit (contain, centered)
     * into the layer's resolved box. Bypasses normal shape geometry. Powers the
     * curated "elements" (e.g. laurel stat badges) — the graphic stays decorative
     * while editable text layers sit on top.
     */
    vectorPaths?: string[];
    /** [width, height] of the `vectorPaths` coordinate space. Default [24, 24]. */
    vectorViewBox?: [number, number];
    /** Fill rule for `vectorPaths`. Default 'nonzero'. */
    vectorFillRule?: 'nonzero' | 'evenodd';
    /**
     * Content-aware horizontal spacing: this layer's inner edge auto-tracks the
     * width of the referenced text layer (the laurel-badge gap follows the value
     * as it's edited). `side` = which edge pins to the value; `margin` = px gap;
     * `w` = last-applied value width (so manual drags are preserved until the
     * value's width actually changes). Driven by `applyLaurelSpacing`.
     */
    spacingLink?: { valueId: string; side: 'left' | 'right'; margin: number; w: number };
    /**
     * Rating-driven stars: this layer's gold star `vectorPaths` are regenerated
     * to match the numeric rating in the referenced score text (full + half
     * stars, capped at `max`). `r` = last-applied rounded rating, so the paths
     * only rebuild when the score actually changes. Driven by `applyStarRating`.
     */
    ratingLink?: { scoreId: string; max: number; r: number };
};

/**
 * Non-visual container for a set of children. Groups only affect selection
 * bundling + z-order; children keep their own absolute anchors. Nesting is
 * allowed (a GroupLayer may itself have a parentId).
 */
export type GroupLayer = LayerBase & {
    type: 'group';
    name: string;
};

/**
 * Optional leader line connecting a callout's `sourceRect` to its
 * `displayRect`. Auto-routes from the source's nearest edge to the
 * callout's nearest edge unless overridden by `from` / `to` (px in canvas
 * space — future-proofing for manual control).
 */
export type CalloutLeader = {
    enabled: boolean;
    style: 'line' | 'arrow' | 'curve';
    stroke: Color;
    /** px */
    width: number;
    from?: { x: number; y: number };
    to?: { x: number; y: number };
};

/**
 * Zoom-callout layer — the "circle pulled out from app screenshot with a
 * leader line" pattern standard on app store pages. Sources its pixels
 * from another layer (`sourceLayerId` — must be a frame or image), zooms
 * the specified region (`sourceRect` in percent of source's resolved
 * rect), and renders into the callout's own anchors-resolved rect clipped
 * by `shape`. Recursive callouts (callout sourcing another callout) are
 * rejected at authoring time. See `callouts-plan.md`.
 */
export type CalloutLayer = LayerBase & {
    type: 'callout';
    /** Layer id of the frame/image whose pixels this callout zooms. */
    sourceLayerId: string;
    /**
     * Region of the source layer being zoomed, in source's coordinate space,
     * as percent of source's resolved rect. Percent (not px) so cross-factor
     * derivation keeps the right region when the source's anchors rescale.
     */
    sourceRect: {
        /** 0-100 */
        x: number;
        y: number;
        width: number;
        height: number;
    };
    /**
     * Shape the callout renders as. v1 keeps the picker tight to three
     * primitives; `path` shapes deferred to v2 (would need pen-tool
     * integration).
     */
    shape: 'ellipse' | 'rounded-rect' | 'pill';
    /** px — only honored when `shape === 'rounded-rect'`. */
    cornerRadius?: number;
    leader?: CalloutLeader;
};

export type Layer =
    | FrameLayer
    | BackgroundLayer
    | TextLayer
    | ImageLayer
    | ShapeLayer
    | GroupLayer
    | CalloutLayer;

export type LayerType = Layer['type'];

/* -------------------------------------------------------------------------- */
/* Canvas + Layout                                                            */
/* -------------------------------------------------------------------------- */

export type SafeArea = { top: number; right: number; bottom: number; left: number };

export type Canvas = {
    /** Base editing resolution — device renders scale from this. */
    width: number;
    height: number;
    /** Optional safe-area insets from the project's base device. */
    safeArea?: SafeArea;
    background?: Color;
};

export type LayoutMeta = {
    createdAt?: string;
    updatedAt?: string;
    /** Free-form author notes — not rendered. */
    notes?: string;
};

export type Layout = {
    version: LayoutVersion;
    canvas: Canvas;
    layers: Layer[];
    meta?: LayoutMeta;
};

export type Layouts = Partial<Record<FormFactor, Layout>>;

/* -------------------------------------------------------------------------- */
/* Factories                                                                  */
/* -------------------------------------------------------------------------- */

export const LAYOUT_VERSION: LayoutVersion = 3;

export function emptyLayout(canvas: Canvas): Layout {
    return {
        version: LAYOUT_VERSION,
        canvas,
        layers: [],
    };
}
