<script lang="ts">
  import { onMount } from 'svelte';
  import { flip } from 'svelte/animate';
  import { quintOut } from 'svelte/easing';
  import { crossfade } from 'svelte/transition';
  import { getCurrentWindow } from '@tauri-apps/api/window';
  import { getVersion } from '@tauri-apps/api/app';
  import { relaunch } from '@tauri-apps/plugin-process';
  import { check, type Update } from '@tauri-apps/plugin-updater';
  import {
    Archive,
    Check,
    ChevronLeft,
    ChevronRight,
    Columns3,
    LayoutList,
    Minus,
    Pencil,
    Plus,
    RefreshCw,
    Search,
    Settings,
    Square,
    Trash2,
    X
  } from '@lucide/svelte';
  import { cssColor, normalizeColor, textColor } from './lib/color';
  import { shouldAutoRefresh } from './lib/auto-refresh';
  import { loadPassword, removePassword, savePassword } from './lib/credentials';
  import { cardUpdateFrom, DeckApiClient } from './lib/deck-api';
  import { loadSettings, saveSettings } from './lib/settings';
  import { moveCard as moveCardLocally, moveStack as moveStackLocally } from './lib/reorder';
  import type { Board, BoardOrientation, Card, CardDraft, Stack } from './lib/types';

  let settings = loadSettings();
  let serverUrl = settings.serverUrl;
  let username = settings.username;
  let appPassword = '';
  let rememberPassword = true;
  let client: DeckApiClient | null = null;
  let boards: Board[] = [];
  let stacks: Stack[] = [];
  let selectedBoard: Board | null = null;
  let busy = false;
  let status = 'Connect to Nextcloud to load your boards.';
  let search = '';
  let settingsOpen = false;
  let colorPickerOpen = false;
  let dragKind: 'card' | 'stack' | null = null;
  let draggedCard: { cardId: number; sourceStackId: number } | null = null;
  let draggedStackId: number | null = null;
  let dragOriginStacks: Stack[] | null = null;
  let dragGhost: { kind: 'card' | 'stack'; title: string; color?: string | null; x: number; y: number; width: number; offsetX: number; offsetY: number } | null = null;
  let reduceMotion = false;
  let currentVersion = '0.3.1';
  let pendingUpdate: Update | null = null;
  let updateState: 'idle' | 'checking' | 'available' | 'current' | 'downloading' | 'installing' | 'error' = 'idle';
  let updateMessage = 'Updates are checked securely against releases from git.elijahkuntz.com.';
  let updateProgress = 0;
  let editor: { mode: 'create' | 'edit'; stackId: number; card?: Card; draft: CardDraft } | null = null;
  let structureEditor:
    | { kind: 'board'; mode: 'create' | 'edit'; id?: number; title: string; color: string }
    | { kind: 'stack'; mode: 'create' | 'edit'; id?: number; title: string; order: number }
    | null = null;
  let pendingDeletion: { kind: 'board' | 'stack'; id: number; title: string; cardCount: number } | null = null;
  let lastInteractionAt = Date.now();
  let lastAutoRefreshAt = 0;
  let autoRefreshInFlight = false;
  const appWindow = '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null;
  const [sendCard, receiveCard] = crossfade({
    duration: (distance) => reduceMotion ? 0 : Math.min(190, Math.max(120, Math.sqrt(distance * 220))),
    easing: quintOut
  });
  let pointerMoveHandler: ((event: PointerEvent) => void) | null = null;
  let pointerUpHandler: ((event: PointerEvent) => void) | null = null;
  let pointerCancelHandler: (() => void) | null = null;
  let keyCancelHandler: ((event: KeyboardEvent) => void) | null = null;

  const colorPresets = [
    'C84DA5', 'D66A91', 'E89989', 'EBBF72', 'F5D94E',
    'B4CF7A', '7EC1AD', '3EA7C3', '0898D0', '3D87D4',
    '6875D1', '9B60C5', '000000', 'E6E6E6'
  ];

  $: orientation = (selectedBoard && settings.boardViews[selectedBoard.id]?.orientation) || 'horizontal';
  $: visibleStacks = stacks.map((stack) => ({
    ...stack,
    cards: (stack.cards ?? [])
      .filter((card) => !card.archived && card.title.toLowerCase().includes(search.trim().toLowerCase()))
      .sort((a, b) => a.order - b.order)
  }));

  onMount(async () => {
    reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (appWindow) {
      try { currentVersion = await getVersion(); } catch { /* retain the packaged fallback */ }
    }
    if (import.meta.env.DEV && new URLSearchParams(location.search).has('demo')) {
      selectedBoard = { id: 1, title: 'Homework', color: '8B7CF6' };
      boards = [selectedBoard, { id: 2, title: 'Misc. To Do', color: 'E69A88' }, { id: 3, title: 'Test', color: '80C3A5' }];
      stacks = [
        { id: 11, boardId: 1, title: 'Saturday', order: 1, cards: [
          { id: 101, stackId: 11, title: 'Preclinical Paperwork', description: 'Review the final checklist before submitting.', type: 'plain', owner: 'admin', order: 1, color: '80C3A5', done: '2026-07-19T12:00:00Z' },
          { id: 102, stackId: 11, title: '126L Mix Up Assignment', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
        ]},
        { id: 12, boardId: 1, title: 'Sunday', order: 2, cards: [
          { id: 103, stackId: 12, title: 'Pharm Med Assignment 1', type: 'plain', owner: 'admin', order: 1, color: '80C3A5', done: '2026-07-19T12:00:00Z' },
          { id: 104, stackId: 12, title: 'Laundry', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null, duedate: '2026-07-20T18:00:00Z' }
        ]},
        { id: 13, boardId: 1, title: 'Monday', order: 3, cards: [
          { id: 105, stackId: 13, title: 'Psych Discussion Board', description: 'Post the initial response and reply to two classmates.', type: 'plain', owner: 'admin', order: 1, color: 'E69A88', done: null },
          { id: 106, stackId: 13, title: 'Patho Concept Map', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
        ]}
      ];
      status = 'Preview data';
      return;
    }
    if (!serverUrl || !username) return;
    try {
      appPassword = await loadPassword(serverUrl, username);
      rememberPassword = Boolean(appPassword);
      if (appPassword) await connect(true);
    } catch (error) {
      status = friendlyError(error);
    }
  });

  onMount(() => {
    const noteInteraction = () => { lastInteractionAt = Date.now(); };
    const events: (keyof WindowEventMap)[] = ['pointerdown', 'keydown', 'wheel'];
    for (const event of events) window.addEventListener(event, noteInteraction, { passive: true });
    const timer = window.setInterval(() => void runIdleRefresh(), 1_000);
    return () => {
      window.clearInterval(timer);
      for (const event of events) window.removeEventListener(event, noteInteraction);
    };
  });

  function friendlyError(error: unknown): string {
    if (error instanceof Error) return error.message;
    return String(error);
  }

  async function checkForUpdates() {
    if (!appWindow) {
      updateState = 'error';
      updateMessage = 'Update checks are available in the installed desktop application.';
      return;
    }
    updateState = 'checking';
    updateMessage = 'Checking Forgejo for a newer signed release...';
    updateProgress = 0;
    pendingUpdate = null;
    try {
      pendingUpdate = await check({ timeout: 30_000 });
      if (pendingUpdate) {
        updateState = 'available';
        updateMessage = `Version ${pendingUpdate.version} is ready to install.${pendingUpdate.body ? ` ${pendingUpdate.body}` : ''}`;
      } else {
        updateState = 'current';
        updateMessage = `Decky ${currentVersion} is up to date.`;
      }
    } catch (error) {
      updateState = 'error';
      updateMessage = `Update check failed: ${friendlyError(error)}`;
    }
  }

  async function installUpdate() {
    if (!pendingUpdate) return;
    updateState = 'downloading';
    updateMessage = `Downloading Decky ${pendingUpdate.version}...`;
    let downloaded = 0;
    let total = 0;
    try {
      await pendingUpdate.downloadAndInstall((event) => {
        if (event.event === 'Started') {
          total = event.data.contentLength ?? 0;
          updateProgress = 0;
        } else if (event.event === 'Progress') {
          downloaded += event.data.chunkLength;
          updateProgress = total > 0 ? Math.min(100, Math.round(downloaded / total * 100)) : 0;
        } else if (event.event === 'Finished') {
          updateState = 'installing';
          updateProgress = 100;
          updateMessage = 'Download verified. Installing and restarting Decky...';
        }
      });
      await relaunch();
    } catch (error) {
      updateState = 'error';
      updateMessage = `Update installation failed: ${friendlyError(error)}`;
    }
  }

  async function connect(silent = false) {
    busy = true;
    status = 'Connecting...';
    try {
      client = await DeckApiClient.connect({ serverUrl, username, appPassword });
      boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
      settings = { ...settings, serverUrl, username };
      saveSettings(settings);
      if (rememberPassword) await savePassword(serverUrl, username, appPassword);
      else await removePassword(serverUrl, username);
      settingsOpen = false;
      const preferred = boards.find((board) => board.id === settings.selectedBoardId) ?? boards[0];
      if (preferred) await selectBoard(preferred);
      else status = 'Connected, but no active Deck boards were found.';
    } catch (error) {
      client = null;
      if (!silent) settingsOpen = true;
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  async function selectBoard(board: Board) {
    if (!client) return;
    selectedBoard = board;
    settings = { ...settings, selectedBoardId: board.id };
    saveSettings(settings);
    await refreshBoard();
  }

  async function refreshBoard() {
    if (!client || !selectedBoard) return;
    busy = true;
    status = 'Refreshing...';
    try {
      stacks = (await client.getStacks(selectedBoard.id))
        .filter((stack) => !stack.deletedAt)
        .sort((a, b) => a.order - b.order);
      status = `Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  async function refreshFromNextcloud(background = false) {
    if (!client) return;
    const activeClient = client;
    const startingBoardId = selectedBoard?.id;
    const interactionAtStart = lastInteractionAt;
    if (!background) {
      busy = true;
      status = 'Refreshing...';
    }
    try {
      const refreshedBoards = (await activeClient.getBoards()).filter((board) => !board.archived && !board.deletedAt);
      if (client !== activeClient || (background && (selectedBoard?.id !== startingBoardId || lastInteractionAt !== interactionAtStart || document.visibilityState !== 'visible'))) return;
      boards = refreshedBoards;
      const board = refreshedBoards.find((item) => item.id === startingBoardId) ?? refreshedBoards[0];
      if (!board) {
        selectedBoard = null;
        stacks = [];
        status = 'Connected, but no active Deck boards were found.';
        return;
      }

      selectedBoard = board;
      if (settings.selectedBoardId !== board.id) {
        settings = { ...settings, selectedBoardId: board.id };
        saveSettings(settings);
      }
      const refreshedStacks = (await activeClient.getStacks(board.id))
        .filter((stack) => !stack.deletedAt)
        .sort((a, b) => a.order - b.order);
      if (client !== activeClient || selectedBoard?.id !== board.id || (background && (lastInteractionAt !== interactionAtStart || document.visibilityState !== 'visible'))) return;
      stacks = refreshedStacks;
      status = `Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
    } catch (error) {
      status = `${background ? 'Background refresh failed' : 'Refresh failed'}: ${friendlyError(error)}`;
    } finally {
      if (!background) busy = false;
    }
  }

  async function runIdleRefresh() {
    const now = Date.now();
    if (autoRefreshInFlight || !shouldAutoRefresh({
      now,
      lastInteractionAt,
      lastRefreshAt: lastAutoRefreshAt,
      connected: Boolean(client),
      busy,
      editing: Boolean(editor || structureEditor || pendingDeletion || settingsOpen),
      dragging: Boolean(dragKind),
      visible: document.visibilityState === 'visible'
    })) return;

    autoRefreshInFlight = true;
    lastAutoRefreshAt = now;
    try {
      await refreshFromNextcloud(true);
    } finally {
      autoRefreshInFlight = false;
    }
  }

  function canEdit(board: Board | null): boolean {
    return Boolean(board) && (board?.permissions?.PERMISSION_EDIT ?? true);
  }

  function canManage(board: Board | null): boolean {
    return Boolean(board) && (board?.permissions?.PERMISSION_MANAGE ?? true);
  }

  function openCreateBoard() {
    colorPickerOpen = false;
    structureEditor = { kind: 'board', mode: 'create', title: '', color: '0082C9' };
  }

  function openEditBoard(board: Board) {
    colorPickerOpen = false;
    structureEditor = { kind: 'board', mode: 'edit', id: board.id, title: board.title, color: normalizeColor(board.color) };
  }

  function openCreateStack() {
    if (!selectedBoard) return;
    const order = stacks.length ? Math.max(...stacks.map((stack) => stack.order)) + 1 : 0;
    structureEditor = { kind: 'stack', mode: 'create', title: '', order };
  }

  function openEditStack(stack: Stack) {
    structureEditor = { kind: 'stack', mode: 'edit', id: stack.id, title: stack.title, order: stack.order };
  }

  async function saveStructure() {
    if (!client || !structureEditor || !structureEditor.title.trim()) return;
    const draft = structureEditor;
    busy = true;
    try {
      if (draft.kind === 'board') {
        const saved = draft.mode === 'create'
          ? await client.createBoard(draft.title.trim(), draft.color)
          : await client.updateBoard(draft.id!, draft.title.trim(), draft.color);
        boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
        const board = boards.find((item) => item.id === saved.id || item.id === draft.id);
        structureEditor = null;
        colorPickerOpen = false;
        if (board) await selectBoard(board);
      } else if (selectedBoard) {
        if (draft.mode === 'create') await client.createStack(selectedBoard.id, draft.title.trim(), draft.order);
        else await client.updateStack(selectedBoard.id, draft.id!, draft.title.trim(), draft.order);
        structureEditor = null;
        await refreshBoard();
      }
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  function requestBoardDeletion(board: Board) {
    const cardCount = selectedBoard?.id === board.id
      ? stacks.reduce((total, stack) => total + stack.cards.length, 0)
      : 0;
    pendingDeletion = { kind: 'board', id: board.id, title: board.title, cardCount };
  }

  function requestStackDeletion(stack: Stack) {
    pendingDeletion = { kind: 'stack', id: stack.id, title: stack.title, cardCount: stack.cards.length };
  }

  async function confirmStructureDeletion() {
    if (!client || !pendingDeletion) return;
    const deletion = pendingDeletion;
    busy = true;
    try {
      if (deletion.kind === 'board') {
        await client.deleteBoard(deletion.id);
        const oldIndex = boards.findIndex((board) => board.id === deletion.id);
        boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
        const boardViews = { ...settings.boardViews };
        delete boardViews[deletion.id];
        settings = { ...settings, boardViews, selectedBoardId: undefined };
        saveSettings(settings);
        pendingDeletion = null;
        selectedBoard = null;
        stacks = [];
        const next = boards[Math.min(Math.max(oldIndex, 0), boards.length - 1)];
        if (next) await selectBoard(next);
        else status = 'Board deleted. Create a board to keep organizing.';
      } else if (selectedBoard) {
        await client.deleteStack(selectedBoard.id, deletion.id);
        pendingDeletion = null;
        await refreshBoard();
      }
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  function setOrientation(value: BoardOrientation) {
    if (!selectedBoard) return;
    settings = {
      ...settings,
      boardViews: { ...settings.boardViews, [selectedBoard.id]: { orientation: value } }
    };
    saveSettings(settings);
  }

  function openCreate(stack: Stack) {
    colorPickerOpen = false;
    editor = {
      mode: 'create',
      stackId: stack.id,
      draft: { title: '', description: '', dueDate: '', color: settings.activeColor }
    };
  }

  async function openEdit(card: Card) {
    if (!client || !selectedBoard) return;
    busy = true;
    try {
      const fullCard = await client.getCard(selectedBoard.id, card.stackId, card.id);
      colorPickerOpen = false;
      editor = {
        mode: 'edit',
        stackId: card.stackId,
        card: fullCard,
        draft: {
          title: fullCard.title,
          description: fullCard.description ?? '',
          dueDate: toLocalDateTime(fullCard.duedate),
          color: normalizeColor(fullCard.color)
        }
      };
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  function toLocalDateTime(value?: string | null): string {
    if (!value) return '';
    const date = new Date(value);
    const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
    return local.toISOString().slice(0, 16);
  }

  async function saveCard() {
    if (!client || !selectedBoard || !editor || !editor.draft.title.trim()) return;
    busy = true;
    try {
      if (editor.mode === 'create') {
        await client.createCard(selectedBoard.id, editor.stackId, { ...editor.draft, title: editor.draft.title.trim() });
      } else if (editor.card) {
        await client.updateCard(
          selectedBoard.id,
          editor.stackId,
          editor.card.id,
          cardUpdateFrom(editor.card, {
            title: editor.draft.title.trim(),
            description: editor.draft.description.trim() || null,
            duedate: editor.draft.dueDate ? new Date(editor.draft.dueDate).toISOString() : null,
            color: normalizeColor(editor.draft.color)
          })
        );
      }
      editor = null;
      colorPickerOpen = false;
      await refreshBoard();
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  async function toggleDone(card: Card) {
    if (!client || !selectedBoard) return;
    busy = true;
    try {
      const fullCard = await client.getCard(selectedBoard.id, card.stackId, card.id);
      const completing = !fullCard.done;
      await client.updateCard(
        selectedBoard.id,
        fullCard.stackId,
        fullCard.id,
        cardUpdateFrom(fullCard, {
          done: completing ? new Date().toISOString() : null,
          color: completing ? settings.completedColor : settings.activeColor
        })
      );
      await refreshBoard();
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  async function deleteCard(card: Card) {
    if (!client || !selectedBoard || !confirm(`Delete "${card.title}"?`)) return;
    busy = true;
    try {
      await client.deleteCard(selectedBoard.id, card.stackId, card.id);
      await refreshBoard();
    } catch (error) {
      status = friendlyError(error);
    } finally {
      busy = false;
    }
  }

  function saveAppearance() {
    settings = {
      ...settings,
      activeColor: normalizeColor(settings.activeColor),
      completedColor: normalizeColor(settings.completedColor)
    };
    saveSettings(settings);
    settingsOpen = false;
  }

  function disconnect() {
    client = null;
    boards = [];
    stacks = [];
    selectedBoard = null;
    appPassword = '';
    status = 'Disconnected from Nextcloud.';
  }

  function setSidebarCollapsed(collapsed: boolean) {
    settings = { ...settings, sidebarCollapsed: collapsed };
    saveSettings(settings);
  }

  async function runWindowAction(action: 'minimize' | 'maximize' | 'close') {
    if (!appWindow) return;
    try {
      if (action === 'minimize') await appWindow.minimize();
      else if (action === 'maximize') await appWindow.toggleMaximize();
      else await appWindow.close();
    } catch { /* The browser preview has no native window. */ }
  }

  async function dragWindow(event: MouseEvent) {
    if (event.button !== 0 || !appWindow) return;
    try { await appWindow.startDragging(); } catch { /* browser preview */ }
  }

  function cloneStacks(value: Stack[]): Stack[] {
    return value.map((stack) => ({ ...stack, cards: stack.cards.map((card) => ({ ...card })) }));
  }

  function sameOrder(left: Stack[], right: Stack[]): boolean {
    return left.map((stack) => `${stack.id}:${stack.cards.map((card) => card.id).join(',')}`).join('|')
      === right.map((stack) => `${stack.id}:${stack.cards.map((card) => card.id).join(',')}`).join('|');
  }

  function startPointerDrag(event: PointerEvent, kind: 'card' | 'stack', title: string, color?: string | null) {
    if (event.button !== 0 || search || busy || (event.target as HTMLElement).closest('button')) return false;
    event.preventDefault();
    const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
    dragOriginStacks = cloneStacks(stacks);
    dragKind = kind;
    dragGhost = {
      kind, title, color, x: event.clientX, y: event.clientY,
      width: kind === 'card' ? rect.width : Math.min(rect.width, 330),
      offsetX: Math.min(event.clientX - rect.left, kind === 'card' ? rect.width - 20 : 280),
      offsetY: Math.min(event.clientY - rect.top, kind === 'card' ? rect.height - 10 : 24)
    };
    document.body.classList.add('sorting');
    pointerMoveHandler = handlePointerMove;
    pointerUpHandler = () => void commitPointerDrag();
    pointerCancelHandler = cancelPointerDrag;
    keyCancelHandler = (keyEvent) => {
      if (keyEvent.key === 'Escape') cancelPointerDrag();
    };
    window.addEventListener('pointermove', pointerMoveHandler, { passive: false });
    window.addEventListener('pointerup', pointerUpHandler, { once: true });
    window.addEventListener('pointercancel', pointerCancelHandler, { once: true });
    window.addEventListener('blur', pointerCancelHandler, { once: true });
    window.addEventListener('keydown', keyCancelHandler);
    return true;
  }

  function beginCardPointer(event: PointerEvent, card: Card) {
    if (startPointerDrag(event, 'card', card.title, card.color)) {
      draggedCard = { cardId: card.id, sourceStackId: card.stackId };
    }
  }

  function previewCardOrder(stackId: number, index: number) {
    if (!draggedCard) return;
    const currentStack = stacks.find((stack) => stack.cards.some((card) => card.id === draggedCard?.cardId));
    if (!currentStack) return;
    const result = moveCardLocally(stacks, draggedCard.cardId, currentStack.id, stackId, index);
    if (result && !sameOrder(stacks, result.stacks)) stacks = result.stacks;
  }

  function beginStackPointer(event: PointerEvent, stack: Stack) {
    if (startPointerDrag(event, 'stack', stack.title)) draggedStackId = stack.id;
  }

  function previewStackOrder(targetStackId: number, after: boolean) {
    if (draggedStackId === null) return;
    const result = moveStackLocally(stacks, draggedStackId, targetStackId, after);
    if (result && !sameOrder(stacks, result.stacks)) stacks = result.stacks;
  }

  function handlePointerMove(event: PointerEvent) {
    if (!dragKind || !dragGhost) return;
    event.preventDefault();
    dragGhost = { ...dragGhost, x: event.clientX, y: event.clientY };
    autoScrollBoard(event.clientX, event.clientY);
    const target = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
    if (dragKind === 'card' && draggedCard) {
      const cardElement = target?.closest<HTMLElement>('[data-card-id]');
      const listElement = target?.closest<HTMLElement>('[data-stack-cards]');
      if (cardElement) {
        const rect = cardElement.getBoundingClientRect();
        const stackId = Number(cardElement.dataset.stackId);
        const index = Number(cardElement.dataset.cardIndex);
        const after = orientation === 'vertical'
          ? event.clientX > rect.left + rect.width / 2
          : event.clientY > rect.top + rect.height / 2;
        previewCardOrder(stackId, index + (after ? 1 : 0));
      } else if (listElement) {
        previewCardOrder(Number(listElement.dataset.stackCards), Number(listElement.dataset.cardCount));
      }
    } else if (dragKind === 'stack' && draggedStackId !== null) {
      const stackElement = target?.closest<HTMLElement>('[data-stack-id]');
      if (stackElement) {
        const stackId = Number(stackElement.dataset.stackId);
        if (stackId === draggedStackId) return;
        const rect = stackElement.getBoundingClientRect();
        const after = orientation === 'horizontal'
          ? event.clientX > rect.left + rect.width / 2
          : event.clientY > rect.top + rect.height / 2;
        previewStackOrder(stackId, after);
      }
    }
  }

  function autoScrollBoard(x: number, y: number) {
    const canvas = document.querySelector<HTMLElement>('.board-canvas');
    if (!canvas) return;
    const rect = canvas.getBoundingClientRect();
    const edge = 48;
    const speedY = y < rect.top + edge ? -12 : y > rect.bottom - edge ? 12 : 0;
    const speedX = x < rect.left + edge ? -12 : x > rect.right - edge ? 12 : 0;
    if (speedX || speedY) canvas.scrollBy({ left: speedX, top: speedY });
  }

  async function commitPointerDrag() {
    const origin = dragOriginStacks;
    const card = draggedCard;
    const stackId = draggedStackId;
    const board = selectedBoard;
    cleanupPointerDrag();
    if (!origin || !board || sameOrder(origin, stacks) || !client) return;
    busy = true;
    try {
      if (card) {
        const targetStack = stacks.find((stack) => stack.cards.some((item) => item.id === card.cardId));
        const targetIndex = targetStack?.cards.findIndex((item) => item.id === card.cardId) ?? -1;
        if (!targetStack || targetIndex < 0) throw new Error('The card drop position could not be resolved.');
        await client.reorderCard(board.id, card.sourceStackId, card.cardId, targetStack.id, targetIndex);
        await refreshBoard();
      } else if (stackId !== null) {
        const targetIndex = stacks.findIndex((stack) => stack.id === stackId);
        stacks = await client.reorderStack(board.id, stackId, targetIndex);
        status = `Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
      }
    } catch (error) {
      stacks = origin;
      status = friendlyError(error);
      await refreshBoard();
    } finally { busy = false; }
  }

  function cancelPointerDrag() {
    if (dragOriginStacks) stacks = dragOriginStacks;
    cleanupPointerDrag();
  }

  function cleanupPointerDrag() {
    if (pointerMoveHandler) window.removeEventListener('pointermove', pointerMoveHandler);
    if (pointerUpHandler) window.removeEventListener('pointerup', pointerUpHandler);
    if (pointerCancelHandler) {
      window.removeEventListener('pointercancel', pointerCancelHandler);
      window.removeEventListener('blur', pointerCancelHandler);
    }
    if (keyCancelHandler) window.removeEventListener('keydown', keyCancelHandler);
    document.body.classList.remove('sorting');
    pointerMoveHandler = null;
    pointerUpHandler = null;
    pointerCancelHandler = null;
    keyCancelHandler = null;
    dragKind = null;
    draggedCard = null;
    draggedStackId = null;
    dragOriginStacks = null;
    dragGhost = null;
  }
</script>

<svelte:head><title>{selectedBoard ? `${selectedBoard.title} - Decky` : 'Decky'}</title></svelte:head>

<div class:sidebar-collapsed={settings.sidebarCollapsed} class="app-shell">
  <header class="window-titlebar" role="presentation" on:mousedown={dragWindow} on:dblclick={() => runWindowAction('maximize')}>
    <div class="window-title"><span class="window-mark"><Columns3 size={14} /></span><span>Decky</span></div>
    <div class="window-controls" role="group" aria-label="Window controls">
      <button aria-label="Minimize" title="Minimize" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('minimize')}><Minus size={15} /></button>
      <button aria-label="Maximize" title="Maximize" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('maximize')}><Square size={12} /></button>
      <button class="window-close" aria-label="Close" title="Close" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('close')}><X size={15} /></button>
    </div>
  </header>

  <aside class="sidebar">
    <div class="sidebar-content">
      {#if false}
        <form class="connection-form" on:submit|preventDefault={() => connect()}>
          <label>Server<input bind:value={serverUrl} autocomplete="url" placeholder="https://cloud.example.com" /></label>
          <label>Username<input bind:value={username} autocomplete="username" /></label>
          <label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
          <label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
          <button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting...' : 'Connect'}</button>
        </form>
      {/if}

      <div class="section-heading"><span>Boards</span><small>{boards.length}</small></div>
      <nav class="board-list" aria-label="Deck boards">
        {#each boards as board (board.id)}
          <div class:active={selectedBoard?.id === board.id} class="board-row">
            <button class="board-select" on:click={() => selectBoard(board)}>
              <span class="board-dot" style:background={cssColor(board.color)}></span>
              <span>{board.title}</span>
            </button>
            {#if canManage(board)}
              <span class="board-actions">
                <button title={`Edit ${board.title}`} on:click={() => openEditBoard(board)}><Pencil size={14} /></button>
                <button title={`Delete ${board.title}`} on:click={() => requestBoardDeletion(board)}><Trash2 size={14} /></button>
              </span>
            {/if}
          </div>
        {/each}
        {#if client}<button class="add-board" on:click={openCreateBoard}><Plus size={16} /><span>Add board</span></button>{/if}
      </nav>
    </div>
    <div class="sidebar-tools">
      <div class="sidebar-layout" aria-label="Board orientation">
        <button class:active={orientation === 'horizontal'} title="Horizontal layout" on:click={() => setOrientation('horizontal')}><Columns3 size={18} /><span>Columns</span></button>
        <button class:active={orientation === 'vertical'} title="Vertical layout" on:click={() => setOrientation('vertical')}><LayoutList size={18} /><span>List</span></button>
      </div>
      <div class="refresh-row">
        <button class="sidebar-tool" title="Refresh" disabled={!client || busy} on:click={() => refreshFromNextcloud()}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
        <span class="sync-status" title={status}>{status}</span>
      </div>
      <button class="sidebar-tool" title="Settings" on:click={() => (settingsOpen = true)}><Settings size={18} /><span>Settings</span></button>
    </div>
  </aside>

  <main class="workspace">
    {#if selectedBoard}
      <div class="workspace-tools">
        <div class="board-title">
          <button class="sidebar-toggle" title={settings.sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'} on:click={() => setSidebarCollapsed(!settings.sidebarCollapsed)}>
            {#if settings.sidebarCollapsed}<ChevronRight size={18} />{:else}<ChevronLeft size={18} />{/if}
          </button>
          {#if selectedBoard}<span class="board-dot" style:background={cssColor(selectedBoard.color)}></span><strong>{selectedBoard.title}</strong>{/if}
        </div>
        <label class="search-box"><Search size={17} /><input bind:value={search} placeholder="Find a card" /></label>
        <span class="stack-summary">{visibleStacks.length} stack{visibleStacks.length === 1 ? '' : 's'}{#if canEdit(selectedBoard)}<button class="icon-button subtle" title="Add a stack" on:click={openCreateStack}><Plus size={17} /></button>{/if}</span>
      </div>

      <div class:vertical={orientation === 'vertical'} class="board-canvas">
        {#each visibleStacks as stack (stack.id)}
          <section
            class:dragging={draggedStackId === stack.id}
            class="stack"
            role="group"
            aria-label={`${stack.title} stack`}
            data-stack-id={stack.id}
            animate:flip={{ duration: reduceMotion ? 0 : 180 }}
          >
            <header class="stack-header" role="presentation" on:pointerdown={(event) => beginStackPointer(event, stack)}>
              <div><h2>{stack.title}</h2><span>{stack.cards.length}</span></div>
              <span class="stack-actions">
                {#if canEdit(selectedBoard)}<button class="icon-button subtle" title="Edit stack" on:click={() => openEditStack(stack)}><Pencil size={15} /></button>{/if}
                {#if canManage(selectedBoard)}<button class="icon-button subtle" title="Delete stack" on:click={() => requestStackDeletion(stack)}><Trash2 size={15} /></button>{/if}
                {#if canEdit(selectedBoard)}<button class="icon-button subtle" title="Add a card" on:click={() => openCreate(stack)}><Plus size={18} /></button>{/if}
              </span>
            </header>
            <div class:card-drag-target={dragKind === 'card'} class="card-list" role="list" data-stack-cards={stack.id} data-card-count={stack.cards.length}>
              {#each stack.cards as card, cardIndex (card.id)}
                <article
                  class:completed={Boolean(card.done)}
                  class:dragging={draggedCard?.cardId === card.id}
                  class="deck-card"
                  role="listitem"
                  data-card-id={card.id}
                  data-stack-id={stack.id}
                  data-card-index={cardIndex}
                  style:--card-color={cssColor(card.color)}
                  style:--card-text={textColor(card.color)}
                  on:pointerdown={(event) => beginCardPointer(event, card)}
                  animate:flip={{ duration: reduceMotion ? 0 : 170 }}
                  in:receiveCard={{ key: card.id }}
                  out:sendCard={{ key: card.id }}
                >
                  <button class="complete-button" class:checked={Boolean(card.done)} title={card.done ? 'Mark active' : 'Mark complete'} on:click={() => toggleDone(card)}>
                    {#if card.done}<Check size={15} strokeWidth={3} />{/if}
                  </button>
                  <div class="card-body">
                    <h3>{card.title}</h3>
                    {#if card.description}<p>{card.description}</p>{/if}
                    {#if card.duedate}<span class="due">Due {new Date(card.duedate).toLocaleDateString([], { month: 'short', day: 'numeric' })}</span>{/if}
                  </div>
                  <div class="card-actions">
                    <button title="Edit card" on:click={() => openEdit(card)}><Pencil size={14} /></button>
                    <button title="Delete card" on:click={() => deleteCard(card)}><Trash2 size={14} /></button>
                  </div>
                </article>
              {/each}
              {#if stack.cards.length === 0}<button class="empty-stack" on:click={() => openCreate(stack)}><Plus size={16} /> Add a card</button>{/if}
            </div>
          </section>
        {/each}
      </div>
    {:else}
      <div class="empty-state">
        <div class="empty-illustration"><Archive size={34} /><span></span><span></span></div>
        <h1>Your boards, without the browser clutter.</h1>
        <p>Connect Decky to Nextcloud, then choose a board from the sidebar.</p>
        <button class="primary-button" on:click={() => (settingsOpen = true)}>Set up connection</button>
      </div>
    {/if}
  </main>

</div>

{#if dragGhost}
  <div
    class:card-drag-ghost={dragGhost.kind === 'card'}
    class:stack-drag-ghost={dragGhost.kind === 'stack'}
    class="drag-ghost"
    style:left={`${dragGhost.x - dragGhost.offsetX}px`}
    style:top={`${dragGhost.y - dragGhost.offsetY}px`}
    style:width={`${dragGhost.width}px`}
    style:--ghost-color={cssColor(dragGhost.color)}
  >
    {#if dragGhost.kind === 'card'}<span class="ghost-check"></span>{/if}
    <strong>{dragGhost.title}</strong>
  </div>
{/if}

{#if editor}
  <div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (editor = null, colorPickerOpen = false)}>
    <form class="modal" on:submit|preventDefault={saveCard}>
      <header><div><small>{editor.mode === 'create' ? 'NEW CARD' : 'EDIT CARD'}</small><h2>{editor.mode === 'create' ? 'Create a card' : editor.card?.title}</h2></div><button type="button" class="icon-button" on:click={() => (editor = null, colorPickerOpen = false)}><X size={20} /></button></header>
      <label>Title<input bind:value={editor.draft.title} maxlength="255" /></label>
      <label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details..."></textarea></label>
      <div class="form-row">
        <label>Due date<input type="datetime-local" bind:value={editor.draft.dueDate} /></label>
        <label>Card color
          <span class="color-control">
            <button type="button" class="color-trigger" aria-expanded={colorPickerOpen} on:click={() => (colorPickerOpen = !colorPickerOpen)}>
              <span class="color-preview" style:background={cssColor(editor.draft.color)}></span>
              <span>#{normalizeColor(editor.draft.color)}</span>
            </button>
            {#if colorPickerOpen}
              <span class="color-popover">
                <span class="swatch-grid">
                  {#each colorPresets as color}
                    <button type="button" class:active={normalizeColor(editor.draft.color) === color} class="color-swatch" style:background={cssColor(color)} title={`#${color}`} on:click={() => { if (editor) editor.draft.color = color; colorPickerOpen = false; }}>
                      {#if normalizeColor(editor.draft.color) === color}<Check size={16} strokeWidth={3} />{/if}
                    </button>
                  {/each}
                </span>
                <span class="custom-color"><span>Custom color</span><input type="color" value={cssColor(editor.draft.color)} on:input={(event) => editor && (editor.draft.color = (event.currentTarget as HTMLInputElement).value.slice(1))} /></span>
                <input class="color-hex" aria-label="Hex color" bind:value={editor.draft.color} maxlength="7" />
              </span>
            {/if}
          </span>
        </label>
      </div>
      <footer><button type="button" class="secondary-button" on:click={() => (editor = null, colorPickerOpen = false)}>Cancel</button><button class="primary-button" disabled={busy || !editor.draft.title.trim()}>{editor.mode === 'create' ? 'Create card' : 'Save changes'}</button></footer>
    </form>
  </div>
{/if}

{#if structureEditor}
  <div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (structureEditor = null, colorPickerOpen = false)}>
    <form class="modal structure-modal" on:submit|preventDefault={saveStructure}>
      <header>
        <div>
          <small>{structureEditor.mode === 'create' ? 'CREATE' : 'EDIT'} {structureEditor.kind.toUpperCase()}</small>
          <h2>{structureEditor.mode === 'create' ? `Add a ${structureEditor.kind}` : `Edit ${structureEditor.title}`}</h2>
        </div>
        <button type="button" class="icon-button" aria-label="Close" on:click={() => (structureEditor = null, colorPickerOpen = false)}><X size={20} /></button>
      </header>
      <label>{structureEditor.kind === 'board' ? 'Board name' : 'Stack name'}<input bind:value={structureEditor.title} maxlength="100" /></label>
      {#if structureEditor.kind === 'board'}
        <label>Board color
          <span class="color-control">
            <button type="button" class="color-trigger" aria-expanded={colorPickerOpen} on:click={() => (colorPickerOpen = !colorPickerOpen)}>
              <span class="color-preview" style:background={cssColor(structureEditor.color)}></span>
              <span>#{normalizeColor(structureEditor.color)}</span>
            </button>
            {#if colorPickerOpen}
              <span class="color-popover structure-color-popover">
                <span class="swatch-grid">
                  {#each colorPresets as color}
                    <button type="button" class:active={normalizeColor(structureEditor.color) === color} class="color-swatch" style:background={cssColor(color)} title={`#${color}`} on:click={() => { if (structureEditor?.kind === 'board') structureEditor.color = color; colorPickerOpen = false; }}>
                      {#if normalizeColor(structureEditor.color) === color}<Check size={16} strokeWidth={3} />{/if}
                    </button>
                  {/each}
                </span>
                <span class="custom-color"><span>Custom color</span><input type="color" value={cssColor(structureEditor.color)} on:input={(event) => structureEditor?.kind === 'board' && (structureEditor.color = (event.currentTarget as HTMLInputElement).value.slice(1))} /></span>
                <input class="color-hex" aria-label="Hex color" bind:value={structureEditor.color} maxlength="7" />
              </span>
            {/if}
          </span>
        </label>
      {/if}
      <footer>
        <button type="button" class="secondary-button" on:click={() => (structureEditor = null, colorPickerOpen = false)}>Cancel</button>
        <button class="primary-button" disabled={busy || !structureEditor.title.trim()}>{structureEditor.mode === 'create' ? `Create ${structureEditor.kind}` : 'Save changes'}</button>
      </footer>
    </form>
  </div>
{/if}

{#if pendingDeletion}
  <div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (pendingDeletion = null)}>
    <div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
      <header>
        <div><small>CONFIRM DELETE</small><h2 id="delete-title">Delete "{pendingDeletion.title}"?</h2></div>
        <button type="button" class="icon-button" aria-label="Close" on:click={() => (pendingDeletion = null)}><X size={20} /></button>
      </header>
      <p class="modal-copy">
        {#if pendingDeletion.kind === 'board'}
          This permanently deletes the board, all of its stacks, and every card inside it from Nextcloud.
        {:else}
          This permanently deletes the stack and its {pendingDeletion.cardCount} card{pendingDeletion.cardCount === 1 ? '' : 's'} from Nextcloud.
        {/if}
      </p>
      <footer><button type="button" class="secondary-button" on:click={() => (pendingDeletion = null)}>Cancel</button><button type="button" class="danger-button" disabled={busy} on:click={confirmStructureDeletion}>{busy ? 'Deleting...' : `Delete ${pendingDeletion.kind}`}</button></footer>
    </div>
  </div>
{/if}

{#if settingsOpen}
  <div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (settingsOpen = false)}>
    <div class="modal settings-modal">
      <header><div><small>SETTINGS</small><h2>Decky settings</h2></div><button type="button" class="icon-button" on:click={() => (settingsOpen = false)}><X size={20} /></button></header>

      <section class="settings-section">
        <div class="settings-heading"><div><h3>Nextcloud connection</h3><p>Use a revocable Nextcloud app password.</p></div><span class:connected={Boolean(client)} class="connection-badge"><span class="status-dot"></span>{client ? `Connected as ${username}` : 'Not connected'}</span></div>
        <form class="settings-connection" on:submit|preventDefault={() => connect()}>
          <label>Server<input bind:value={serverUrl} autocomplete="url" placeholder="https://cloud.example.com" /></label>
          <label>Username<input bind:value={username} autocomplete="username" /></label>
          <label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
          <label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
          <div class="settings-actions">
            {#if client}<button type="button" class="secondary-button" on:click={disconnect}>Disconnect</button>{/if}
            <button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting...' : client ? 'Reconnect' : 'Connect'}</button>
          </div>
        </form>
      </section>

      <section class="settings-section update-section">
        <div class="settings-heading">
          <div><h3>Decky updates</h3><p>Installer updates are signed and delivered through your Forgejo instance.</p></div>
          <span class="version-badge">Version {currentVersion}</span>
        </div>
        <div class:error={updateState === 'error'} class="update-status" aria-live="polite">
          <div>
            <strong>{updateState === 'available' ? 'Update available' : updateState === 'current' ? 'Up to date' : updateState === 'error' ? 'Update unavailable' : 'Release channel: stable'}</strong>
            <p>{updateMessage}</p>
          </div>
          {#if updateState === 'downloading' || updateState === 'installing'}
            <span class="update-progress" aria-label={`Update progress ${updateProgress}%`}><span style:width={`${updateProgress}%`}></span></span>
          {/if}
        </div>
        <div class="settings-actions update-actions">
          {#if updateState === 'available'}
            <button type="button" class="primary-button" on:click={installUpdate}>Download and install {pendingUpdate?.version}</button>
          {:else}
            <button type="button" class="secondary-button" disabled={updateState === 'checking' || updateState === 'downloading' || updateState === 'installing'} on:click={checkForUpdates}>
              {updateState === 'checking' ? 'Checking...' : 'Check for updates'}
            </button>
          {/if}
        </div>
      </section>

      <form class="settings-section" on:submit|preventDefault={saveAppearance}>
        <div class="settings-heading"><div><h3>Card colors</h3><p>Completing a card applies the completed color; unchecking restores the active color.</p></div></div>
        <div class="color-settings">
          <label>Active cards<span class="color-field"><input type="color" value={cssColor(settings.activeColor)} on:input={(event) => (settings.activeColor = (event.currentTarget as HTMLInputElement).value.slice(1))} /><input bind:value={settings.activeColor} /></span></label>
          <label>Completed cards<span class="color-field"><input type="color" value={cssColor(settings.completedColor)} on:input={(event) => (settings.completedColor = (event.currentTarget as HTMLInputElement).value.slice(1))} /><input bind:value={settings.completedColor} /></span></label>
        </div>
        <footer><button type="button" class="secondary-button" on:click={() => (settingsOpen = false)}>Cancel</button><button class="primary-button">Save settings</button></footer>
      </form>
    </div>
  </div>
{/if}
