/**
 * Advanced Calibration — multimonitor line-based calibration editor.
 *
 * Lines map LED segments to edges of different picture sources (monitors).
 * The canvas shows monitor rectangles that can be repositioned for visual clarity.
 */

import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getPictureSourceIcon } from '../core/icons.ts';
import type { Calibration, CalibrationLine, PictureSource } from '../types.ts';

/* ── Types ──────────────────────────────────────────────────── */

interface MonitorRect {
    id: string;
    name: string;
    width: number;
    height: number;
    cx: number;
    cy: number;
    cw: number;
    ch: number;
}

interface CalibrationState {
    cssId: string | null;
    lines: CalibrationLine[];
    monitors: MonitorRect[];
    pictureSources: PictureSource[];
    totalLedCount: number;
    selectedLine: number;
    dragging: DragState | null;
}

interface DragMonitorState {
    type: 'monitor';
    monIdx: number;
    startX: number;
    startY: number;
    origCx: number;
    origCy: number;
}

interface DragPanState {
    type: 'pan';
    startX: number;
    startY: number;
    origPanX: number;
    origPanY: number;
}

type DragState = DragMonitorState | DragPanState;

interface ViewState {
    panX: number;
    panY: number;
    zoom: number;
}

interface LineCoords {
    x1: number;
    y1: number;
    x2: number;
    y2: number;
}

/* ── Constants ──────────────────────────────────────────────── */

const EDGE_COLORS = {
    top:    'rgba(0, 180, 255, 0.8)',
    right:  'rgba(255, 100, 0, 0.8)',
    bottom: 'rgba(0, 220, 100, 0.8)',
    left:   'rgba(200, 0, 255, 0.8)',
};

const EDGE_COLORS_DIM = {
    top:    'rgba(0, 180, 255, 0.3)',
    right:  'rgba(255, 100, 0, 0.3)',
    bottom: 'rgba(0, 220, 100, 0.3)',
    left:   'rgba(200, 0, 255, 0.3)',
};

const MONITOR_BG = 'rgba(30, 40, 60, 0.9)';
const MONITOR_BORDER = 'rgba(80, 100, 140, 0.7)';
const MONITOR_SELECTED_BORDER = 'rgba(0, 180, 255, 0.9)';
const LINE_THICKNESS_PX = 6;

/* ── State ──────────────────────────────────────────────────── */

let _state: CalibrationState = {
    cssId: null,
    lines: [],
    monitors: [],
    pictureSources: [],
    totalLedCount: 0,
    selectedLine: -1,
    dragging: null,
};

// Zoom/pan view state
let _view: ViewState = { panX: 0, panY: 0, zoom: 1.0 };
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 4.0;

/* ── Modal ──────────────────────────────────────────────────── */

class AdvancedCalibrationModal extends Modal {
    constructor() { super('advanced-calibration-modal'); }
    snapshotValues(): Record<string, string> {
        return {
            lines: JSON.stringify(_state.lines),
            offset: (document.getElementById('advcal-offset') as HTMLInputElement)?.value || '0',
            skipStart: (document.getElementById('advcal-skip-start') as HTMLInputElement)?.value || '0',
            skipEnd: (document.getElementById('advcal-skip-end') as HTMLInputElement)?.value || '0',
        };
    }
    onForceClose(): void {
        if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
        _state.cssId = null;
        _state.lines = [];
        _state.totalLedCount = 0;
        _state.selectedLine = -1;
    }
}

const _modal = new AdvancedCalibrationModal();

/* ── Public API ─────────────────────────────────────────────── */

export async function showAdvancedCalibration(cssId: string): Promise<void> {
    try {
        const [cssSources, psResp] = await Promise.all([
            colorStripSourcesCache.fetch(),
            fetchWithAuth('/picture-sources'),
        ]);
        const source = cssSources.find(s => s.id === cssId);
        if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
        const calibration: Calibration = source.calibration || {} as Calibration;
        const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];

        _state.cssId = cssId;
        _state.pictureSources = psList;
        _state.totalLedCount = source.led_count || 0;

        (document.getElementById('advcal-css-id') as HTMLInputElement).value = cssId;

        // Populate picture source selector
        _populateSourceSelect(psList);

        // Load lines from existing advanced calibration, or start empty
        if (calibration.mode === 'advanced' && Array.isArray(calibration.lines)) {
            _state.lines = calibration.lines.map(l => ({ ...l }));
        } else {
            _state.lines = [];
        }

        (document.getElementById('advcal-offset') as HTMLInputElement).value = String(calibration.offset || 0);
        (document.getElementById('advcal-skip-start') as HTMLInputElement).value = String(calibration.skip_leds_start || 0);
        (document.getElementById('advcal-skip-end') as HTMLInputElement).value = String(calibration.skip_leds_end || 0);

        // Build monitor rectangles from used picture sources and fit view
        _rebuildUsedMonitors();
        _fitView();

        _state.selectedLine = _state.lines.length > 0 ? 0 : -1;

        _renderLineList();
        _showLineProps();
        _renderCanvas();
        _updateTotalLeds();

        _modal.open();
        _modal.snapshot();

        // Set up canvas interaction
        _initCanvasHandlers();

    } catch (error) {
        if (error.isAuth) return;
        console.error('Failed to load advanced calibration:', error);
        showToast(t('calibration.error.load_failed'), 'error');
    }
}

export async function closeAdvancedCalibration(): Promise<void> {
    await _modal.close();
}

export async function saveAdvancedCalibration(): Promise<void> {
    const cssId = _state.cssId;
    if (!cssId) return;

    if (_state.lines.length === 0) {
        showToast(t('calibration.advanced.no_lines_warning') || 'Add at least one line', 'error');
        return;
    }

    const calibration = {
        mode: 'advanced',
        lines: _state.lines.map(l => ({
            picture_source_id: l.picture_source_id,
            edge: l.edge,
            led_count: l.led_count,
            span_start: l.span_start,
            span_end: l.span_end,
            reverse: l.reverse,
            border_width: l.border_width,
        })),
        offset: parseInt((document.getElementById('advcal-offset') as HTMLInputElement).value) || 0,
        skip_leds_start: parseInt((document.getElementById('advcal-skip-start') as HTMLInputElement).value) || 0,
        skip_leds_end: parseInt((document.getElementById('advcal-skip-end') as HTMLInputElement).value) || 0,
    };

    try {
        const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
            method: 'PUT',
            body: JSON.stringify({ calibration }),
        });

        if (resp.ok) {
            showToast(t('calibration.saved'), 'success');
            colorStripSourcesCache.invalidate();
            _modal.forceClose();
        } else {
            const err = await resp.json().catch(() => ({}));
            const detail = err.detail || err.message || '';
            const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
            showToast(detailStr || t('calibration.error.save_failed'), 'error');
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(error.message || t('calibration.error.save_failed'), 'error');
    }
}

export function addCalibrationLine(): void {
    const defaultSource = _state.pictureSources[0]?.id || '';
    const hadMonitors = _state.monitors.length;
    _state.lines.push({
        picture_source_id: defaultSource,
        edge: 'top',
        led_count: 10,
        span_start: 0.0,
        span_end: 1.0,
        reverse: false,
        border_width: 10,
    });
    _state.selectedLine = _state.lines.length - 1;
    _rebuildUsedMonitors();
    // If a new monitor was added, position it next to existing ones and fit view
    if (_state.monitors.length > hadMonitors) {
        _placeNewMonitor();
        _fitView();
    }
    _renderLineList();
    _showLineProps();
    _renderCanvas();
    _updateTotalLeds();
}

export function removeCalibrationLine(idx: number): void {
    if (idx < 0 || idx >= _state.lines.length) return;
    _state.lines.splice(idx, 1);
    if (_state.selectedLine >= _state.lines.length) {
        _state.selectedLine = _state.lines.length - 1;
    }
    _rebuildUsedMonitors();
    _renderLineList();
    _showLineProps();
    _renderCanvas();
    _updateTotalLeds();
}

export function selectCalibrationLine(idx: number): void {
    const prev = _state.selectedLine;
    _state.selectedLine = idx;
    // Update selection in-place without rebuilding the list DOM
    const container = document.getElementById('advcal-line-list')!;
    const items = container.querySelectorAll('.advcal-line-item');
    if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
    if (idx >= 0 && idx < items.length) items[idx].classList.add('selected');
    _showLineProps();
    _renderCanvas();
}

export function moveCalibrationLine(idx: number, direction: number): void {
    const newIdx = idx + direction;
    if (newIdx < 0 || newIdx >= _state.lines.length) return;
    [_state.lines[idx], _state.lines[newIdx]] = [_state.lines[newIdx], _state.lines[idx]];
    _state.selectedLine = newIdx;
    _renderLineList();
    _showLineProps();
    _renderCanvas();
}

export function updateCalibrationLine(): void {
    const idx = _state.selectedLine;
    if (idx < 0 || idx >= _state.lines.length) return;
    const line = _state.lines[idx];
    const hadMonitors = _state.monitors.length;
    line.picture_source_id = (document.getElementById('advcal-line-source') as HTMLSelectElement).value;
    line.edge = (document.getElementById('advcal-line-edge') as HTMLSelectElement).value as CalibrationLine['edge'];
    line.led_count = Math.max(1, parseInt((document.getElementById('advcal-line-leds') as HTMLInputElement).value) || 1);
    line.span_start = parseFloat((document.getElementById('advcal-line-span-start') as HTMLInputElement).value) || 0;
    line.span_end = parseFloat((document.getElementById('advcal-line-span-end') as HTMLInputElement).value) || 1;
    line.border_width = Math.max(1, parseInt((document.getElementById('advcal-line-border-width') as HTMLInputElement).value) || 10);
    line.reverse = (document.getElementById('advcal-line-reverse') as HTMLInputElement).checked;
    _rebuildUsedMonitors();
    if (_state.monitors.length > hadMonitors) {
        _placeNewMonitor();
        _fitView();
    }
    _renderLineList();
    _renderCanvas();
    _updateTotalLeds();
}

/* ── Internals ──────────────────────────────────────────────── */

let _lineSourceEntitySelect: EntitySelect | null = null;

function _populateSourceSelect(psList: any[]) {
    const sel = document.getElementById('advcal-line-source') as HTMLSelectElement;
    sel.innerHTML = '';
    psList.forEach(ps => {
        const opt = document.createElement('option');
        opt.value = ps.id;
        opt.textContent = ps.name || ps.id;
        sel.appendChild(opt);
    });

    // Entity palette for picture source per line
    if (_lineSourceEntitySelect) _lineSourceEntitySelect.destroy();
    _lineSourceEntitySelect = new EntitySelect({
        target: sel,
        getItems: () => psList.map(ps => ({
            value: ps.id,
            label: ps.name || ps.id,
            icon: getPictureSourceIcon(ps.stream_type),
        })),
        placeholder: t('palette.search'),
        onChange: () => updateCalibrationLine(),
    });
}

function _rebuildUsedMonitors(): void {
    const usedIds = new Set(_state.lines.map(l => l.picture_source_id));
    const currentIds = new Set(_state.monitors.map(m => m.id));
    // Only rebuild if the set of used sources changed
    if (usedIds.size === currentIds.size && [...usedIds].every(id => currentIds.has(id))) return;
    const usedSources = _state.pictureSources.filter(ps => usedIds.has(ps.id));
    _buildMonitorLayout(usedSources, _state.cssId);
}

function _buildMonitorLayout(psList: any[], cssId: string | null): void {
    // Load saved positions from localStorage
    const savedKey = `advcal_positions_${cssId}`;
    let saved = {};
    try { saved = JSON.parse(localStorage.getItem(savedKey) ?? '{}') || {}; } catch { /* ignore */ }

    const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
    const canvasW = canvas.width;
    const canvasH = canvas.height;

    // Default layout: arrange monitors in a row
    const monitors: any[] = [];
    const padding = 20;
    const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
    const monH = canvasH * 0.6;

    psList.forEach((ps, i) => {
        const ratio = (ps.width || 1920) / (ps.height || 1080);
        let w = Math.min(maxMonW, monH * ratio);
        let h = w / ratio;
        if (h > monH) { h = monH; w = h * ratio; }

        const defaultCx = padding + i * (maxMonW + 10) + maxMonW / 2 - w / 2;
        const defaultCy = (canvasH - h) / 2;

        const s = saved[ps.id];
        monitors.push({
            id: ps.id,
            name: ps.name || `Monitor ${i + 1}`,
            width: ps.width || 1920,
            height: ps.height || 1080,
            cx: s?.cx ?? defaultCx,
            cy: s?.cy ?? defaultCy,
            cw: w,
            ch: h,
        });
    });

    _state.monitors = monitors;
}

function _saveMonitorPositions(): void {
    if (!_state.cssId) return;
    const savedKey = `advcal_positions_${_state.cssId}`;
    const positions = {};
    _state.monitors.forEach(m => { positions[m.id] = { cx: m.cx, cy: m.cy }; });
    localStorage.setItem(savedKey, JSON.stringify(positions));
}

/** Place the last monitor next to the rightmost existing one. */
function _placeNewMonitor(): void {
    if (_state.monitors.length < 2) return;
    const newMon = _state.monitors[_state.monitors.length - 1];
    const others = _state.monitors.slice(0, -1);
    let rightmost = others[0];
    for (const m of others) {
        if (m.cx + m.cw > rightmost.cx + rightmost.cw) rightmost = m;
    }
    const gap = 20;
    newMon.cx = rightmost.cx + rightmost.cw + gap;
    newMon.cy = rightmost.cy + rightmost.ch / 2 - newMon.ch / 2;
    _saveMonitorPositions();
}

function _updateTotalLeds(): void {
    const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
    const el = document.getElementById('advcal-total-leds')!;
    if (_state.totalLedCount > 0) {
        el.textContent = `${used}/${_state.totalLedCount}`;
        el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
    } else {
        el.textContent = String(used);
        el.style.color = '';
    }
}

/* ── Line list rendering ────────────────────────────────────── */

function _renderLineList(): void {
    const container = document.getElementById('advcal-line-list')!;
    container.innerHTML = '';

    _state.lines.forEach((line, i) => {
        const div = document.createElement('div');
        div.className = 'advcal-line-item' + (i === _state.selectedLine ? ' selected' : '');
        div.onclick = () => selectCalibrationLine(i);

        const psName = _state.pictureSources.find(p => p.id === line.picture_source_id)?.name || '?';
        const edgeLabel = line.edge.charAt(0).toUpperCase() + line.edge.slice(1);
        const color = EDGE_COLORS[line.edge] || '#888';

        div.innerHTML = `
            <span class="advcal-line-color" style="background:${color}"></span>
            <span class="advcal-line-label">${psName} &middot; ${edgeLabel}</span>
            <span class="advcal-line-leds">${line.led_count}</span>
            <span class="advcal-line-actions">
                <button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, -1)" title="Move up" ${i === 0 ? 'disabled' : ''}>&#x25B2;</button>
                <button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, 1)" title="Move down" ${i === _state.lines.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
                <button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">&#x2715;</button>
            </span>
        `;
        container.appendChild(div);
    });

    // Add button as last item
    const addDiv = document.createElement('div');
    addDiv.className = 'advcal-line-add';
    addDiv.onclick = () => addCalibrationLine();
    addDiv.innerHTML = `<span>+</span>`;
    container.appendChild(addDiv);
}

function _showLineProps(): void {
    const propsEl = document.getElementById('advcal-line-props')!;
    const idx = _state.selectedLine;
    if (idx < 0 || idx >= _state.lines.length) {
        propsEl.style.display = 'none';
        return;
    }
    propsEl.style.display = '';
    const line = _state.lines[idx];
    (document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
    if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
    (document.getElementById('advcal-line-edge') as HTMLSelectElement).value = line.edge;
    (document.getElementById('advcal-line-leds') as HTMLInputElement).value = String(line.led_count);
    (document.getElementById('advcal-line-span-start') as HTMLInputElement).value = String(line.span_start);
    (document.getElementById('advcal-line-span-end') as HTMLInputElement).value = String(line.span_end);
    (document.getElementById('advcal-line-border-width') as HTMLInputElement).value = String(line.border_width);
    (document.getElementById('advcal-line-reverse') as HTMLInputElement).checked = line.reverse;
}

/* ── Coordinate helpers ─────────────────────────────────────── */

/** Convert a mouse event to world-space (pre-transform) canvas coordinates. */
function _mouseToWorld(e: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } {
    const rect = canvas.getBoundingClientRect();
    const sx = (e.clientX - rect.left) * (canvas.width / rect.width);
    const sy = (e.clientY - rect.top) * (canvas.height / rect.height);
    return {
        x: (sx - _view.panX) / _view.zoom,
        y: (sy - _view.panY) / _view.zoom,
    };
}

/** Convert a mouse event to raw screen-space canvas coordinates (for pan). */
function _mouseToScreen(e: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } {
    const rect = canvas.getBoundingClientRect();
    return {
        x: (e.clientX - rect.left) * (canvas.width / rect.width),
        y: (e.clientY - rect.top) * (canvas.height / rect.height),
    };
}

export function resetCalibrationView(): void {
    _fitView();
    _renderCanvas();
}

function _fitView(): void {
    const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
    if (!canvas || _state.monitors.length === 0) {
        _view = { panX: 0, panY: 0, zoom: 1.0 };
        return;
    }
    const W = canvas.width;
    const H = canvas.height;
    const padding = 40;

    // Bounding box of all monitors
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const mon of _state.monitors) {
        minX = Math.min(minX, mon.cx);
        minY = Math.min(minY, mon.cy);
        maxX = Math.max(maxX, mon.cx + mon.cw);
        maxY = Math.max(maxY, mon.cy + mon.ch);
    }
    const bw = maxX - minX;
    const bh = maxY - minY;
    if (bw <= 0 || bh <= 0) {
        _view = { panX: 0, panY: 0, zoom: 1.0 };
        return;
    }

    const zoom = Math.min((W - padding * 2) / bw, (H - padding * 2) / bh, MAX_ZOOM);
    const cx = (minX + maxX) / 2;
    const cy = (minY + maxY) / 2;
    _view.zoom = zoom;
    _view.panX = W / 2 - cx * zoom;
    _view.panY = H / 2 - cy * zoom;
}

/* ── Canvas rendering ───────────────────────────────────────── */

function _renderCanvas(): void {
    const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    const W = canvas.width;
    const H = canvas.height;

    ctx.clearRect(0, 0, W, H);

    // Apply zoom/pan
    ctx.save();
    ctx.translate(_view.panX, _view.panY);
    ctx.scale(_view.zoom, _view.zoom);

    // Draw monitors
    _state.monitors.forEach((mon) => {
        ctx.fillStyle = MONITOR_BG;
        ctx.strokeStyle = MONITOR_BORDER;
        ctx.lineWidth = 2;
        ctx.fillRect(mon.cx, mon.cy, mon.cw, mon.ch);
        ctx.strokeRect(mon.cx, mon.cy, mon.cw, mon.ch);

        // Label (zoom-independent size)
        ctx.fillStyle = 'rgba(200, 210, 230, 0.7)';
        const nameFontSize = Math.min(11 / _view.zoom, 22);
        ctx.font = `${nameFontSize}px sans-serif`;
        ctx.textAlign = 'center';
        ctx.fillText(mon.name, mon.cx + mon.cw / 2, mon.cy + mon.ch / 2);
    });

    // Pass 1: Draw all line strokes
    _state.lines.forEach((line, i) => {
        const mon = _state.monitors.find(m => m.id === line.picture_source_id);
        if (!mon) return;
        const isSelected = i === _state.selectedLine;
        const color = isSelected ? EDGE_COLORS[line.edge] : EDGE_COLORS_DIM[line.edge];
        const thick = isSelected ? LINE_THICKNESS_PX + 2 : LINE_THICKNESS_PX;
        const { x1, y1, x2, y2 } = _getLineCoords(mon, line);
        ctx.strokeStyle = color;
        ctx.lineWidth = thick;
        ctx.lineCap = 'round';
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.stroke();
    });

    // Pass 2: Draw all ticks and arrows on top of lines
    let ledStart = 0;
    _state.lines.forEach((line, i) => {
        const mon = _state.monitors.find(m => m.id === line.picture_source_id);
        if (!mon) { ledStart += line.led_count; return; }
        const isSelected = i === _state.selectedLine;
        const { x1, y1, x2, y2 } = _getLineCoords(mon, line);
        _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected);
        if (isSelected) {
            const color = EDGE_COLORS[line.edge];
            _drawArrow(ctx, x1, y1, x2, y2, line.reverse, color);
        }
        ledStart += line.led_count;
    });

    ctx.restore();
}

function _getLineCoords(mon: MonitorRect, line: CalibrationLine): LineCoords {
    const s = line.span_start;
    const e = line.span_end;
    let x1, y1, x2, y2;

    switch (line.edge) {
        case 'top':
            x1 = mon.cx + mon.cw * s;
            x2 = mon.cx + mon.cw * e;
            y1 = y2 = mon.cy;
            break;
        case 'bottom':
            x1 = mon.cx + mon.cw * s;
            x2 = mon.cx + mon.cw * e;
            y1 = y2 = mon.cy + mon.ch;
            break;
        case 'left':
            y1 = mon.cy + mon.ch * s;
            y2 = mon.cy + mon.ch * e;
            x1 = x2 = mon.cx;
            break;
        case 'right':
            y1 = mon.cy + mon.ch * s;
            y2 = mon.cy + mon.ch * e;
            x1 = x2 = mon.cx + mon.cw;
            break;
    }
    return { x1, y1, x2, y2 };
}

function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1: number, y1: number, x2: number, y2: number, ledStart: number, mon: MonitorRect, isSelected: boolean): void {
    const count = line.led_count;
    if (count <= 0) return;

    const isHoriz = line.edge === 'top' || line.edge === 'bottom';
    const edgeLen = isHoriz ? Math.abs(x2 - x1) : Math.abs(y2 - y1);

    // Tick direction: outward from monitor (zoom-independent sizes)
    const tickDir = (line.edge === 'top' || line.edge === 'left') ? -1 : 1;
    const invZoom = 1 / _view.zoom;
    const tickLenLong = (isSelected ? 10 : 7) * Math.min(invZoom, 2);
    const tickLenShort = (isSelected ? 5 : 4) * Math.min(invZoom, 2);
    const labelOffset = 12 * Math.min(invZoom, 2);

    // Decide which ticks to show labels for (smart spacing like simple calibration)
    const edgeBounds = new Set([0]);
    if (count > 1) edgeBounds.add(count - 1);

    const labelsToShow = new Set();

    if (count > 2) {
        const maxDigits = String(ledStart + count - 1).length;
        const minSpacing = (isHoriz ? maxDigits * 6 + 8 : 18) * Math.min(invZoom, 2);
        const niceSteps = [5, 10, 25, 50, 100, 250, 500];
        let step = niceSteps[niceSteps.length - 1];
        for (const s of niceSteps) {
            if (Math.floor(count / s) <= 5) { step = s; break; }
        }

        const tickPx = i => {
            const f = i / (count - 1);
            return (line.reverse ? (1 - f) : f) * edgeLen;
        };

        const placed: number[] = [];

        // Place intermediate labels at nice steps
        for (let i = 0; i < count; i++) {
            if ((ledStart + i) % step === 0 || edgeBounds.has(i)) {
                const px = tickPx(i);
                if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
                    labelsToShow.add(i);
                    placed.push(px);
                }
            }
        }

        // Always show labels for edge bounds
        edgeBounds.forEach(bi => {
            if (labelsToShow.has(bi)) return;
            labelsToShow.add(bi);
            placed.push(tickPx(bi));
        });
    } else {
        edgeBounds.forEach(i => labelsToShow.add(i));
    }

    ctx.save();
    ctx.strokeStyle = isSelected ? 'rgba(255, 255, 255, 0.5)' : 'rgba(255, 255, 255, 0.35)';
    ctx.fillStyle = isSelected ? 'rgba(255, 255, 255, 0.7)' : 'rgba(255, 255, 255, 0.45)';
    ctx.lineWidth = Math.min(invZoom, 2);
    const tickFontSize = Math.min(9 / _view.zoom, 18);
    ctx.font = `${tickFontSize}px sans-serif`;

    const drawTick = (i, withLabel) => {
        const fraction = count > 1 ? i / (count - 1) : 0.5;
        const displayFraction = line.reverse ? (1 - fraction) : fraction;
        const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
        const showLabel = withLabel;

        if (isHoriz) {
            const tx = x1 + displayFraction * (x2 - x1);
            const ty = line.edge === 'top' ? mon.cy : mon.cy + mon.ch;
            ctx.beginPath();
            ctx.moveTo(tx, ty);
            ctx.lineTo(tx, ty + tickDir * tickLen);
            ctx.stroke();
            if (showLabel) {
                ctx.textAlign = 'center';
                ctx.textBaseline = tickDir < 0 ? 'bottom' : 'top';
                ctx.fillText(String(ledStart + i), tx, ty + tickDir * labelOffset);
            }
        } else {
            const tx = line.edge === 'left' ? mon.cx : mon.cx + mon.cw;
            const ty = y1 + displayFraction * (y2 - y1);
            ctx.beginPath();
            ctx.moveTo(tx, ty);
            ctx.lineTo(tx + tickDir * tickLen, ty);
            ctx.stroke();
            if (showLabel) {
                ctx.textBaseline = 'middle';
                ctx.textAlign = tickDir < 0 ? 'right' : 'left';
                ctx.fillText(String(ledStart + i), tx + tickDir * labelOffset, ty);
            }
        }
    };

    labelsToShow.forEach(i => drawTick(i, true));

    // For non-selected, also draw boundary ticks if not already labeled
    if (!isSelected) {
        edgeBounds.forEach(i => {
            if (!labelsToShow.has(i)) drawTick(i, false);
        });
    }

    ctx.restore();
}

function _drawArrow(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, reverse: boolean, color: string): void {
    if (reverse) { [x1, y1, x2, y2] = [x2, y2, x1, y1]; }
    const angle = Math.atan2(y2 - y1, x2 - x1);
    const headLen = 8;
    const midX = (x1 + x2) / 2;
    const midY = (y1 + y2) / 2;

    ctx.strokeStyle = color;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(midX - headLen * Math.cos(angle - Math.PI / 6), midY - headLen * Math.sin(angle - Math.PI / 6));
    ctx.lineTo(midX, midY);
    ctx.lineTo(midX - headLen * Math.cos(angle + Math.PI / 6), midY - headLen * Math.sin(angle + Math.PI / 6));
    ctx.stroke();
}

/* ── Canvas interaction (drag monitors) ─────────────────────── */

function _initCanvasHandlers(): void {
    const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
    if (!canvas) return;

    canvas.oncontextmenu = (e) => e.preventDefault();

    canvas.onmousedown = (e) => {
        // Middle-click or right-click → pan
        if (e.button === 1 || e.button === 2) {
            e.preventDefault();
            const s = _mouseToScreen(e, canvas);
            _state.dragging = {
                type: 'pan',
                startX: s.x,
                startY: s.y,
                origPanX: _view.panX,
                origPanY: _view.panY,
            };
            canvas.style.cursor = 'move';
            return;
        }

        const { x, y } = _mouseToWorld(e, canvas);

        // Check if clicking on a line
        const hitThreshold = 10 / _view.zoom;
        for (let i = _state.lines.length - 1; i >= 0; i--) {
            const line = _state.lines[i];
            const mon = _state.monitors.find(m => m.id === line.picture_source_id);
            if (!mon) continue;
            const { x1, y1, x2, y2 } = _getLineCoords(mon, line);
            if (_pointNearLine(x, y, x1, y1, x2, y2, hitThreshold)) {
                selectCalibrationLine(i);
                return;
            }
        }

        // Check if clicking on a monitor (for drag)
        for (let i = _state.monitors.length - 1; i >= 0; i--) {
            const mon = _state.monitors[i];
            if (x >= mon.cx && x <= mon.cx + mon.cw && y >= mon.cy && y <= mon.cy + mon.ch) {
                _state.dragging = {
                    type: 'monitor',
                    monIdx: i,
                    startX: x,
                    startY: y,
                    origCx: mon.cx,
                    origCy: mon.cy,
                };
                canvas.style.cursor = 'grabbing';
                return;
            }
        }

        // Click on empty space → pan
        const s = _mouseToScreen(e, canvas);
        _state.dragging = {
            type: 'pan',
            startX: s.x,
            startY: s.y,
            origPanX: _view.panX,
            origPanY: _view.panY,
        };
        canvas.style.cursor = 'move';
    };

    canvas.onmousemove = (e) => {
        if (!_state.dragging) {
            // Change cursor on hover
            const { x, y } = _mouseToWorld(e, canvas);
            let cursor = 'default';
            for (const mon of _state.monitors) {
                if (x >= mon.cx && x <= mon.cx + mon.cw && y >= mon.cy && y <= mon.cy + mon.ch) {
                    cursor = 'grab';
                    break;
                }
            }
            canvas.style.cursor = cursor;
            return;
        }

        if (_state.dragging.type === 'pan') {
            const s = _mouseToScreen(e, canvas);
            _view.panX = _state.dragging.origPanX + (s.x - _state.dragging.startX);
            _view.panY = _state.dragging.origPanY + (s.y - _state.dragging.startY);
            _renderCanvas();
            return;
        }

        if (_state.dragging.type === 'monitor') {
            const { x, y } = _mouseToWorld(e, canvas);
            const mon = _state.monitors[_state.dragging.monIdx];
            mon.cx = _state.dragging.origCx + (x - _state.dragging.startX);
            mon.cy = _state.dragging.origCy + (y - _state.dragging.startY);
            _renderCanvas();
        }
    };

    canvas.onmouseup = () => {
        if (_state.dragging?.type === 'monitor') {
            _saveMonitorPositions();
        }
        _state.dragging = null;
        canvas.style.cursor = 'default';
    };

    canvas.onmouseleave = () => {
        if (_state.dragging?.type === 'monitor') {
            _saveMonitorPositions();
        }
        _state.dragging = null;
        canvas.style.cursor = 'default';
    };

    canvas.ondblclick = () => {
        _fitView();
        _renderCanvas();
    };

    canvas.onwheel = (e) => {
        e.preventDefault();
        const s = _mouseToScreen(e, canvas);
        const oldZoom = _view.zoom;
        const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
        _view.zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, oldZoom * factor));
        // Zoom toward cursor: adjust pan so the world point under cursor stays fixed
        _view.panX = s.x - (s.x - _view.panX) * (_view.zoom / oldZoom);
        _view.panY = s.y - (s.y - _view.panY) * (_view.zoom / oldZoom);
        _renderCanvas();
    };
}

function _pointNearLine(px: number, py: number, x1: number, y1: number, x2: number, y2: number, threshold: number): boolean {
    const dx = x2 - x1;
    const dy = y2 - y1;
    const lenSq = dx * dx + dy * dy;
    if (lenSq === 0) return Math.hypot(px - x1, py - y1) <= threshold;
    let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
    t = Math.max(0, Math.min(1, t));
    const nearX = x1 + t * dx;
    const nearY = y1 + t * dy;
    return Math.hypot(px - nearX, py - nearY) <= threshold;
}
