// Fields beyond mode/scale/transform/x/y/primary are optional on a record.
// Absent means "this layout says nothing about it", which every check below
// treats as valid: arrangements stored before the color and mirror fields
// existed must keep validating.
const colorProfiles = ["auto", "srgb", "wide", "hdr"];
const vrrModes = [-1, 0, 1, 2];
const bitdepths = [8, 10];
const sdrBrightnessRange = { min: 0.8, max: 2.0 };
const sdrSaturationRange = { min: 0.8, max: 1.2 };

function validColorProfile(value) {
    return colorProfiles.indexOf(value) >= 0;
}

function validVrrMode(value) {
    return Number.isInteger(value) && vrrModes.indexOf(value) >= 0;
}

function validBitdepth(value) {
    return Number.isInteger(value) && bitdepths.indexOf(value) >= 0;
}

function inRange(value, range) {
    return Number.isFinite(value) && value >= range.min && value <= range.max;
}

function validSdrBrightness(value) {
    return inRange(value, sdrBrightnessRange);
}

function validSdrSaturation(value) {
    return inRange(value, sdrSaturationRange);
}

// "" for a display that is not mirroring, so callers never branch on undefined.
function mirrorTarget(record) {
    return record && typeof record.mirrorOf === "string" ? record.mirrorOf : "";
}

function isMirrored(record) {
    return mirrorTarget(record) !== "";
}

function logicalSize(record) {
    if (!record)
        return { width: 0, height: 0 };

    const width = Number(record.width);
    const height = Number(record.height);
    const scale = Number(record.scale);
    const transform = Number(record.transform);
    if (!Number.isFinite(width) || !Number.isFinite(height)
            || !Number.isFinite(scale) || scale <= 0)
        return { width: 0, height: 0 };

    const rotated = transform === 1 || transform === 3;
    return {
        width: (rotated ? height : width) / scale,
        height: (rotated ? width : height) / scale
    };
}

function validCoordinate(value) {
    return Number.isFinite(value) && Number.isInteger(value)
        && value >= -100000 && value <= 100000;
}

function validOptionalFields(record) {
    if (record.vrrMode !== undefined && !validVrrMode(record.vrrMode))
        return false;
    if (record.colorProfile !== undefined && !validColorProfile(record.colorProfile))
        return false;
    if (record.bitdepth !== undefined && !validBitdepth(record.bitdepth))
        return false;
    if (record.sdrBrightness !== undefined && !validSdrBrightness(record.sdrBrightness))
        return false;
    if (record.sdrSaturation !== undefined && !validSdrSaturation(record.sdrSaturation))
        return false;
    if (record.mirrorOf !== undefined && typeof record.mirrorOf !== "string")
        return false;
    return !isMirrored(record) || /^[A-Za-z0-9_.-]+$/.test(record.mirrorOf);
}

function validate(layout) {
    if (!Array.isArray(layout) || layout.length === 0)
        return false;

    const names = {};
    let primaryCount = 0;
    for (const record of layout) {
        if (!record || typeof record.name !== "string"
                || !/^[A-Za-z0-9_.-]+$/.test(record.name)
                || names[record.name])
            return false;
        names[record.name] = true;

        if (!Number.isFinite(record.width) || record.width <= 0
                || !Number.isFinite(record.height) || record.height <= 0
                || !Number.isFinite(record.scale) || record.scale <= 0
                || !Number.isInteger(record.transform)
                || record.transform < 0 || record.transform > 3
                || !validCoordinate(record.x) || !validCoordinate(record.y)
                || typeof record.primary !== "boolean"
                || !validOptionalFields(record))
            return false;

        const size = logicalSize(record);
        if (!Number.isFinite(size.width) || size.width <= 0
                || !Number.isFinite(size.height) || size.height <= 0)
            return false;
        if (record.primary)
            primaryCount += 1;
    }

    // Mirroring is checked once every name is known. A mirror needs a target
    // that is part of this same layout and is not itself a mirror, because
    // Hyprland has no chain to follow; and the primary may not mirror, since
    // the arrangement is anchored on it and a mirror has no position of its own.
    for (const record of layout) {
        const target = mirrorTarget(record);
        if (target === "")
            continue;
        if (record.primary === true || target === record.name)
            return false;
        const other = layout.find(candidate => candidate.name === target);
        if (!other || isMirrored(other))
            return false;
    }
    return primaryCount === 1;
}

function cloneLayout(layout) {
    return (layout || []).map(record => Object.assign({}, record));
}

// A mirrored output is placed by the compositor, not by us, so it is left out
// of every geometry pass: shifting it, measuring the desktop by it, or snapping
// to it would all be arithmetic on a position nothing reads back.
function placedRecords(layout) {
    const placed = (layout || []).filter(record => !isMirrored(record));
    return placed.length > 0 ? placed : (layout || []);
}

function normalize(layout) {
    const result = cloneLayout(layout);
    const primary = result.find(record => record.primary === true);
    if (!primary)
        return result;
    const anchorX = primary.x;
    const anchorY = primary.y;
    for (const record of result) {
        if (isMirrored(record))
            continue;
        record.x -= anchorX;
        record.y -= anchorY;
    }
    // A mirror sits on its target, so it is given the target's place rather
    // than a shifted version of coordinates that stopped meaning anything the
    // moment mirroring was turned on. Nothing asserts them -- the compositor
    // chooses -- but a stale pair would still be drawn on the canvas.
    for (const record of result) {
        if (!isMirrored(record))
            continue;
        const target = result.find(candidate => candidate.name === record.mirrorOf);
        if (!target)
            continue;
        record.x = target.x;
        record.y = target.y;
    }
    return result;
}

function bounds(layout) {
    if (!Array.isArray(layout) || layout.length === 0)
        return { x: 0, y: 0, width: 0, height: 0 };

    let left = Infinity;
    let top = Infinity;
    let right = -Infinity;
    let bottom = -Infinity;
    for (const record of placedRecords(layout)) {
        const size = logicalSize(record);
        left = Math.min(left, record.x);
        top = Math.min(top, record.y);
        right = Math.max(right, record.x + size.width);
        bottom = Math.max(bottom, record.y + size.height);
    }
    return { x: left, y: top, width: right - left, height: bottom - top };
}

function snap(layout, movingName, threshold) {
    const result = cloneLayout(layout);
    const moving = result.find(record => record.name === movingName);
    if (!moving || isMirrored(moving))
        return result;

    const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
    const movingSize = logicalSize(moving);
    const movingXEdges = [moving.x, moving.x + movingSize.width];
    const movingYEdges = [moving.y, moving.y + movingSize.height];
    const stationary = result
        .filter(record => record.name !== movingName && !isMirrored(record))
        .sort((left, right) => left.name.localeCompare(right.name));

    let bestX = null;
    let bestY = null;
    for (const record of stationary) {
        const size = logicalSize(record);
        const xEdges = [record.x, record.x + size.width];
        const yEdges = [record.y, record.y + size.height];
        for (const source of movingXEdges) {
            for (const target of xEdges) {
                const delta = target - source;
                const distance = Math.abs(delta);
                if (distance <= limit && (bestX === null || distance < bestX.distance))
                    bestX = { delta, distance };
            }
        }
        for (const source of movingYEdges) {
            for (const target of yEdges) {
                const delta = target - source;
                const distance = Math.abs(delta);
                if (distance <= limit && (bestY === null || distance < bestY.distance))
                    bestY = { delta, distance };
            }
        }
    }

    if (bestX !== null)
        moving.x += bestX.delta;
    if (bestY !== null)
        moving.y += bestY.delta;
    return result;
}

function canvasRects(layout, canvasWidth, canvasHeight, padding) {
    const desktopBounds = bounds(layout);
    const inset = Math.max(0, Number(padding) || 0);
    const availableWidth = Math.max(0, Number(canvasWidth) - inset * 2);
    const availableHeight = Math.max(0, Number(canvasHeight) - inset * 2);
    const scale = desktopBounds.width > 0 && desktopBounds.height > 0
        ? Math.min(availableWidth / desktopBounds.width,
            availableHeight / desktopBounds.height)
        : 0;
    const contentWidth = desktopBounds.width * scale;
    const contentHeight = desktopBounds.height * scale;
    const originX = inset + (availableWidth - contentWidth) / 2;
    const originY = inset + (availableHeight - contentHeight) / 2;
    const rects = (layout || []).map(record => {
        const size = logicalSize(record);
        return {
            name: record.name,
            x: originX + (record.x - desktopBounds.x) * scale,
            y: originY + (record.y - desktopBounds.y) * scale,
            width: size.width * scale,
            height: size.height * scale,
            primary: record.primary === true,
            mirrorOf: mirrorTarget(record),
            mirrored: isMirrored(record)
        };
    });

    // A mirror shows the same picture in the same place as its target, so it is
    // drawn stacked on it with a badge rather than at coordinates of its own.
    // Its target is guaranteed present by validate(); an unvalidated layout that
    // names a missing one keeps its own rectangle instead of vanishing.
    for (const rect of rects) {
        if (!rect.mirrored)
            continue;
        const target = rects.find(candidate => candidate.name === rect.mirrorOf);
        if (!target)
            continue;
        rect.x = target.x;
        rect.y = target.y;
        rect.width = target.width;
        rect.height = target.height;
    }

    return { bounds: desktopBounds, scale, rects };
}
