import { invoke } from "@tauri-apps/api/core";
import type {
  EnergyLevel,
  PageState,
  Profile,
  Segment,
  SettingsState,
  TaskDraft,
  TaskDto,
  TaskEntry,
  TaskList,
  TaskUpdate,
  Template,
  TranscriptDto,
  TranscriptEntry,
  TranscriptMetaPatch,
  TranscriptWriteEntry,
} from "$lib/types/app";
import { toasts } from "$lib/stores/toasts.svelte.ts";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import {
  loadSettingsWithMigration,
  saveSettingsWithVersion,
} from "$lib/utils/settingsMigrations.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js";

export const page = $state<PageState>({
  current: "dictation",
  status: "Ready",
  statusColor: "#7ec89a",
  activeProfile: "None",
  recording: false,
  timerText: "00:00",
  handedness: "Right",
  taskSidebarOpen: false,
});

const SETTINGS_KEY = "kon_settings";
const PROFILES_KEY = "kon_profiles";
const TASK_LISTS_KEY = "kon_task_lists";
const TEMPLATES_KEY = "kon_templates";

const defaults: SettingsState = {
  engine: "whisper",
  modelSize: "Base",
  language: "en",
  device: "auto",
  formatMode: "Smart",
  removeFillers: true,
  antiHallucination: true,
  britishEnglish: true,
  autoCopy: true,
  autoPaste: false,
  transcriptionPreview: false,
  meetingAutoCapture: false,
  meetingAutoCaptureApps: ["zoom", "teams"],
  soundCues: false,
  soundCueVolume: 0.15,
  includeTimestamps: true,
  theme: "Dark",
  fontSize: 14,
  aiTier: "cleanup",
  llmModelId: null,
  llmPromptPreset: "default",
  aiGpuConcurrency: "parallel",
  prewarmModelOnStartup: false,
  saveAudio: false,
  outputFolder: "",
  globalHotkey: "Ctrl+Shift+R",
  sidebarCollapsed: false,
  microphoneDevice: "",
  currentEnergy: null,
  matchMyEnergy: false,
  ttsVoice: null,
  ttsRate: 1.0,
  ritualsMorning: false,
  ritualsMorningTime: "08:00",
  ritualsEvening: false,
  launchAtLogin: false,
  ritualsPromptSeen: false,
  nudgesEnabled: false,
  nudgesMuted: false,
  nudgesSpeakAloud: false,
  showMomentumSparkline: true,
  // B2b — additive schema fields. Fresh users get sane defaults; existing
  // users get them spread over their saved blob and then the migration
  // step in loadSettings() backfills nudgeMode from nudgesEnabled.
  lastLaunchAt: null,
  reentryFreshStartUntil: null,
  lastActiveProfileId: null,
  microStepGranularity: "default",
  // For fresh users we default off; existing users with nudgesEnabled
  // = true get bumped to "immediate" by the migration in loadSettings.
  nudgeMode: "off",
  nudgeDigestTimes: [],
  sparklineRangeDays: 7,
  // Defaults mirror current EnergyChip copy so existing behaviour is
  // unchanged until the user edits via the (B3.6) UI.
  energyLabels: {
    high: { label: "High", description: "Sharp focus, big lifts" },
    medium: { label: "Medium", description: "Steady work, moderate lifts" },
    brain_dead: { label: "Zero", description: "Rest only, low-touch admin" },
  },
};

function canUseStorage(): boolean {
  return typeof localStorage !== "undefined";
}

function loadSettings(): SettingsState {
  if (!canUseStorage()) return { ...defaults };
  // Versioned migration: reads the envelope or an unversioned legacy
  // blob, runs the migration chain (currently identity: bare object ->
  // v1), and returns the current-shape data. Falls back to defaults
  // on corruption — the toast below surfaces the reset to the user.
  const migrated = loadSettingsWithMigration<Partial<SettingsState>>(SETTINGS_KEY);
  if (migrated === null && localStorage.getItem(SETTINGS_KEY) !== null) {
    // There was data but it failed to migrate — tell the user we
    // reset rather than silently mangling their preferences.
    queueMicrotask(() => {
      toasts.warn(
        "Settings reset",
        "Your saved settings couldn't be read, so Kon fell back to defaults. "
          + "Earlier Kon builds can still read your old data.",
      );
    });
  }
  const merged: SettingsState = {
    ...defaults,
    ...(migrated ?? {}),
  };
  // B2b nudge migration: if the loaded blob carries a boolean
  // `nudgesEnabled` but no `nudgeMode`, derive the new tri-state from it.
  // We keep `nudgesEnabled` in the schema so callers that haven't been
  // migrated yet still see a sane value. The new `nudgeMode` is the
  // source of truth for the digest/immediate/off split going forward.
  // This is a *one-time* derivation: once the merged blob has nudgeMode
  // set explicitly (next save), the boolean stops driving anything.
  const blob = migrated as Partial<SettingsState> | undefined;
  if (blob && blob.nudgeMode === undefined && typeof blob.nudgesEnabled === "boolean") {
    merged.nudgeMode = blob.nudgesEnabled ? "immediate" : "off";
  }
  return merged;
}

export const settings = $state<SettingsState>(loadSettings());

export function saveSettings() {
  if (!canUseStorage()) return;
  // Versioned envelope {version, data}. Older Kon builds that used
  // the bare-object layout can still spread the `data` key over
  // their defaults — they will just silently drop fields they don't
  // know about, which is the same behaviour as today.
  saveSettingsWithVersion(SETTINGS_KEY, { ...settings });
}

function loadProfiles(): Profile[] {
  if (!canUseStorage()) return [];
  return parseStoredJson<Profile[]>(localStorage.getItem(PROFILES_KEY)) ?? [];
}

export const profiles = $state<Profile[]>(loadProfiles());

export function saveProfiles() {
  if (!canUseStorage()) return;
  try {
    localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
  } catch {}
}

export const history = $state<TranscriptEntry[]>([]);

export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
  const text = row.text ?? "";
  const rawTags = row.manualTags ?? "";
  const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
  const rawLlmTags = row.llmTags ?? "";
  const llmTags = rawLlmTags ? rawLlmTags.split(",").filter(Boolean) : [];
  const rawSegments = row.segmentsJson ?? "";
  let segments: Segment[] = [];
  if (rawSegments) {
    try {
      const parsed = JSON.parse(rawSegments);
      if (Array.isArray(parsed)) segments = parsed as Segment[];
    } catch (err) {
      console.warn("mapTranscriptRow: segmentsJson parse failed", err);
    }
  }

  return {
    id: row.id,
    text,
    source: row.source ?? "",
    profileId: row.profileId ?? "00000000-0000-0000-0000-000000000001",
    title: row.title ?? "",
    audioPath: row.audioPath ?? null,
    duration: Number(row.duration ?? 0),
    engine: row.engine ?? null,
    modelId: row.modelId ?? null,
    createdAt: row.createdAt ?? null,
    date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "",
    preview: text.slice(0, 120),
    starred: !!row.starred,
    manualTags,
    template: row.template ?? "",
    language: row.language ?? "",
    segments,
    llmTags,
  };
}

function normaliseTranscriptEntry(entry: TranscriptWriteEntry): TranscriptEntry {
  const createdAt = entry.createdAt ?? new Date().toISOString();
  return {
    id: String(entry.id),
    text: entry.text ?? "",
    source: entry.source ?? "microphone",
    profileId: entry.profileId ?? "00000000-0000-0000-0000-000000000001",
    title: entry.title ?? "",
    audioPath: entry.audioPath ?? null,
    duration: Number(entry.duration ?? 0),
    engine: entry.engine ?? null,
    modelId: entry.modelId ?? null,
    createdAt,
    date: entry.date ?? new Date(createdAt).toLocaleString("en-GB"),
    preview: entry.preview ?? entry.text.slice(0, 120),
    starred: !!entry.starred,
    manualTags: Array.isArray(entry.manualTags) ? entry.manualTags : [],
    template: entry.template ?? "",
    language: entry.language ?? "",
    segments: Array.isArray(entry.segments) ? entry.segments : [],
    llmTags: Array.isArray(entry.llmTags) ? entry.llmTags : [],
  };
}

async function loadHistory() {
  if (!hasTauriRuntime()) {
    history.length = 0;
    return;
  }

  try {
    const rows = await invoke<TranscriptDto[]>("list_transcripts", { limit: 500, offset: 0 });
    history.splice(0, history.length, ...(Array.isArray(rows) ? rows.map(mapTranscriptRow) : []));
  } catch (err) {
    console.error("loadHistory failed", err);
    history.length = 0;
    toasts.error("Failed to load history", errorMessage(err));
  }
}

if (typeof window !== "undefined" && hasTauriRuntime()) {
  loadHistory().catch(() => {});
}

/**
 * Result of an `addToHistory` call. `persisted: true` means the entry is
 * safely on disk (Tauri SQLite write succeeded, or we're in the non-Tauri
 * shell where there is no disk). `persisted: false` means the in-memory
 * history was NOT updated either — the caller is responsible for keeping
 * the user's source content (e.g. the dictation draft) alive so a retry
 * is possible. Surfacing this is what closes the historical silent-loss
 * bug where SQLite failures were swallowed by `console.warn`.
 */
export interface AddToHistoryResult {
  persisted: boolean;
  error?: unknown;
}

export async function addToHistory(entry: TranscriptWriteEntry): Promise<AddToHistoryResult> {
  const normalised = normaliseTranscriptEntry(entry);

  // Non-Tauri shell (browser preview, SSR test): no disk to fail. Update
  // the in-memory store and report success so callers don't think the
  // write was lost — there was no write to lose.
  if (!hasTauriRuntime()) {
    history.unshift(normalised);
    if (history.length > 500) history.length = 500;
    return { persisted: true };
  }

  try {
    await invoke("add_transcript", {
      transcript: {
        id: normalised.id,
        text: normalised.text,
        source: normalised.source,
        profileId: normalised.profileId,
        title: normalised.title || null,
        audioPath: normalised.audioPath ?? null,
        duration: normalised.duration,
        engine: normalised.engine ?? null,
        modelId: normalised.modelId ?? null,
        inferenceMs: entry.inferenceMs ?? null,
        sampleRate: entry.sampleRate ?? null,
        audioChannels: entry.audioChannels ?? null,
        formatMode: entry.formatMode ?? null,
        removeFillers: !!entry.removeFillers,
        britishEnglish: !!entry.britishEnglish,
        antiHallucination: !!entry.antiHallucination,
      },
    });
    // Only mutate the in-memory history after the disk write lands.
    // Adding to memory while the disk write fails is exactly the misleading
    // state PR 1.4 set out to fix — the row would appear in History, then
    // silently vanish on next launch.
    history.unshift(normalised);
    if (history.length > 500) history.length = 500;
    // Fire-and-forget auto-title. Same gate as the existing auto-cleanup
    // path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in
    // by the same Settings choice — no new toggle. Skipped silently if
    // the LLM isn't loaded; HistoryPage's per-row "Title" button is the
    // retry path. Never overwrites a title the caller already set.
    void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title);
    return { persisted: true };
  } catch (err) {
    console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
    return { persisted: false, error: err };
  }
}

/// Run the auto-title pass when the same gate that drives auto-cleanup
/// is met. Best-effort: any failure (LLM not loaded, model rejected the
/// transcript, sanitisation produced an empty string) is swallowed
/// because the user can still title the row manually via the per-row
/// button in History.
async function maybeAutoGenerateTitle(
  id: string,
  text: string,
  existingTitle: string | undefined | null,
): Promise<void> {
  if (!hasTauriRuntime()) return;
  if (settings.aiTier === "off" || settings.formatMode === "Raw") return;
  if (!text.trim()) return;
  if (existingTitle && existingTitle.trim()) return;
  try {
    const title = await invoke<string>("generate_title_cmd", { transcript: text });
    if (title && title.trim()) {
      await renameHistoryEntry(id, { title: title.trim() });
    }
  } catch (err) {
    // Auto-title is a nice-to-have, not core flow. The most common
    // failure (LLM not loaded yet) is exactly the case where the
    // user can re-trigger via the per-row "Title" button.
    console.warn("maybeAutoGenerateTitle skipped", err);
  }
}

export async function renameHistoryEntry(
  id: string,
  updates: { title?: string; text?: string },
) {
  const idx = history.findIndex((entry) => String(entry.id) === String(id));
  if (idx >= 0) {
    if (updates.title !== undefined) history[idx].title = updates.title;
    if (updates.text !== undefined) history[idx].text = updates.text;
  }

  if (!hasTauriRuntime()) return;

  try {
    await invoke("update_transcript", {
      id: String(id),
      text: updates.text ?? null,
      title: updates.title ?? null,
    });
  } catch (err) {
    console.warn("renameHistoryEntry: SQLite update failed", err);
    throw err;
  }
}

export async function saveTranscriptMeta(id: string, patch: TranscriptMetaPatch) {
  if (!hasTauriRuntime()) return;

  try {
    const payload = {
      starred: patch.starred,
      manualTags: patch.manualTags == null
        ? undefined
        : (Array.isArray(patch.manualTags)
            ? patch.manualTags.join(",")
            : String(patch.manualTags)),
      template: patch.template,
      language: patch.language,
      segmentsJson: patch.segments === undefined ? undefined : JSON.stringify(patch.segments),
      llmTags: patch.llmTags == null
        ? undefined
        : (Array.isArray(patch.llmTags)
            ? patch.llmTags.join(",")
            : String(patch.llmTags)),
    };
    const row = await invoke<TranscriptDto>("update_transcript_meta_cmd", {
      id: String(id),
      patch: payload,
    });
    const idx = history.findIndex((entry) => String(entry.id) === String(id));
    if (idx !== -1) history[idx] = mapTranscriptRow(row);
  } catch (err) {
    toasts.error("Failed to save transcript metadata", errorMessage(err));
  }
}

export function deleteFromHistory(index: number) {
  const entry = history[index];
  history.splice(index, 1);

  if (!hasTauriRuntime() || !entry?.id) return;

  invoke("delete_transcript", { id: String(entry.id) })
    .catch((err) => console.warn("deleteFromHistory: SQLite delete failed", err));
}

export function deleteFromHistoryById(id: string) {
  const idx = history.findIndex((entry) => String(entry.id) === String(id));
  if (idx === -1) return;
  deleteFromHistory(idx);
}

export const tasks = $state<TaskEntry[]>([]);

function mapTaskRow(row: TaskDto): TaskEntry {
  return {
    id: row.id,
    text: row.text ?? "",
    bucket: (row.bucket ?? "inbox") as TaskEntry["bucket"],
    listId: row.listId ?? null,
    effort: row.effort ?? "",
    notes: row.notes ?? "",
    done: !!row.done,
    doneAt: row.doneAt ?? null,
    createdAt: row.createdAt ?? new Date().toISOString(),
    sourceTranscriptId: row.sourceTranscriptId ?? null,
    parentTaskId: row.parentTaskId ?? null,
    energy: row.energy ?? null,
    archived: !!row.archived,
    archivedAt: row.archivedAt ?? null,
  };
}

async function loadTasks() {
  if (!hasTauriRuntime()) {
    tasks.length = 0;
    return;
  }

  try {
    const rows = await invoke<TaskDto[]>("list_tasks_cmd");
    tasks.splice(0, tasks.length, ...(Array.isArray(rows) ? rows.map(mapTaskRow) : []));
  } catch (err) {
    console.error("loadTasks failed", err);
    tasks.length = 0;
    toasts.error("Failed to load tasks", errorMessage(err));
  }
}

if (typeof window !== "undefined" && hasTauriRuntime()) {
  loadTasks().catch(() => {});
}

export async function addTask(task: TaskDraft) {
  if (!hasTauriRuntime()) return;

  const id = crypto.randomUUID();
  try {
    const row = await invoke<TaskDto>("create_task_cmd", {
      request: {
        id,
        text: task.text,
        bucket: task.bucket || "inbox",
        sourceTranscriptId: task.sourceTranscriptId || null,
        listId: task.listId ?? null,
        effort: task.effort ?? null,
        energy: task.energy ?? null,
      },
    });
    tasks.unshift(mapTaskRow(row));
    broadcastTasks();
  } catch (err) {
    toasts.error("Couldn't add task", errorMessage(err));
  }
}

function applyLocalTaskUpdate(id: string, updates: Partial<TaskEntry>) {
  const idx = tasks.findIndex((task) => task.id === id);
  if (idx >= 0) {
    Object.assign(tasks[idx], updates);
    broadcastTasks();
  }
}

/**
 * PR 1.5: replace an in-store task with a fresh DTO from the backend
 * (e.g. the auto-completed parent returned by `complete_subtask_cmd`).
 * No-op if the task is not in the store — that's expected when a parent
 * has already been pruned client-side. Broadcasts on success so other
 * windows pick up the new state.
 */
export function replaceTaskFromDto(row: TaskDto) {
  const idx = tasks.findIndex((task) => task.id === row.id);
  if (idx >= 0) {
    tasks[idx] = mapTaskRow(row);
    broadcastTasks();
  }
}

export async function updateTask(id: string, updates: TaskUpdate) {
  if (!hasTauriRuntime()) {
    applyLocalTaskUpdate(id, updates as Partial<TaskEntry>);
    return;
  }

  try {
    const row = await invoke<TaskDto>("update_task_cmd", {
      id,
      patch: {
        text: updates.text ?? null,
        bucket: updates.bucket ?? null,
        listId: updates.listId ?? null,
        effort: updates.effort ?? null,
        notes: updates.notes ?? null,
      },
    });
    const idx = tasks.findIndex((task) => task.id === id);
    if (idx >= 0) {
      tasks[idx] = mapTaskRow(row);
      broadcastTasks();
    }
  } catch (err) {
    toasts.error("Couldn't update task", errorMessage(err));
  }
}

/**
 * Phase 3: explicit tri-state energy setter. Lives outside `updateTask`
 * because the backend uses a dedicated command for clearing — see
 * `set_task_energy_cmd` in src-tauri/src/commands/tasks.rs. Pass `null`
 * to clear the tag entirely; pass an `EnergyLevel` string to set it.
 */
export async function setTaskEnergy(id: string, energy: EnergyLevel | null) {
  if (!hasTauriRuntime()) {
    applyLocalTaskUpdate(id, { energy });
    return;
  }
  try {
    const row = await invoke<TaskDto>("set_task_energy_cmd", { id, energy });
    const idx = tasks.findIndex((task) => task.id === id);
    if (idx >= 0) {
      tasks[idx] = mapTaskRow(row);
      broadcastTasks();
    }
  } catch (err) {
    toasts.error("Couldn't change energy", errorMessage(err));
  }
}

export async function deleteTask(id: string) {
  if (!hasTauriRuntime()) return;

  try {
    await invoke("delete_task_cmd", { id });
    const idx = tasks.findIndex((task) => task.id === id);
    if (idx >= 0) {
      tasks.splice(idx, 1);
      broadcastTasks();
      if (typeof window !== "undefined") {
        window.dispatchEvent(new CustomEvent("kon:task-deleted", { detail: { id } }));
      }
    }
  } catch (err) {
    toasts.error("Couldn't delete task", errorMessage(err));
  }
}

export async function completeTask(id: string) {
  if (!hasTauriRuntime()) return;

  try {
    await invoke("complete_task_cmd", { id });
    applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
    // Phase 6 nudge-bus signal. Fire-and-forget; the bus subscribes
    // to this to clear micro-step-idle timers for the completed task
    // and, later, to drive Phase 7 "after a task completes" rules.
    if (typeof window !== "undefined") {
      window.dispatchEvent(new CustomEvent("kon:task-completed", { detail: { id } }));
    }
  } catch (err) {
    toasts.error("Couldn't complete task", errorMessage(err));
  }
}

export async function uncompleteTask(id: string) {
  if (!hasTauriRuntime()) return;

  try {
    await invoke("uncomplete_task_cmd", { id });
    applyLocalTaskUpdate(id, { done: false, doneAt: null });
    if (typeof window !== "undefined") {
      window.dispatchEvent(new CustomEvent("kon:task-uncompleted", { detail: { id } }));
    }
  } catch (err) {
    toasts.error("Couldn't uncomplete task", errorMessage(err));
  }
}

interface TaskChannelMessage {
  type: "tasks_updated";
  tasks: TaskEntry[];
}

const taskChannel = typeof BroadcastChannel !== "undefined"
  ? new BroadcastChannel("kon_task_sync")
  : null;

if (taskChannel) {
  taskChannel.onmessage = (event: MessageEvent<TaskChannelMessage>) => {
    if (event.data.type === "tasks_updated") {
      tasks.length = 0;
      tasks.push(...event.data.tasks);
    }
  };
}

function broadcastTasks() {
  if (!taskChannel) return;
  taskChannel.postMessage({
    type: "tasks_updated",
    tasks: $state.snapshot(tasks),
  } satisfies TaskChannelMessage);
}

// B2a: task_lists are now persisted in SQLite (migration v1 created
// the table; the actual CRUD wiring happens via the *_task_list_cmd
// handlers). The frontend keeps the two synthetic built-in entries
// (`all`, `inbox`) in-memory so the sidebar always shows them — neither
// has a corresponding DB row.
function defaultTaskLists(): TaskList[] {
  return [
    { id: "all", name: "All Tasks", builtIn: true, createdAt: null },
    { id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
  ];
}

interface TaskListDto {
  id: string;
  name: string;
  builtIn: boolean;
  profileId: string | null;
  createdAt: string;
}

function mapTaskListRow(row: TaskListDto): TaskList {
  return {
    id: row.id,
    name: row.name,
    builtIn: !!row.builtIn,
    profileId: row.profileId ?? null,
    createdAt: row.createdAt ?? null,
  };
}

export const taskLists = $state<TaskList[]>(defaultTaskLists());

async function loadTaskListsFromSqlite() {
  if (!hasTauriRuntime()) return;
  try {
    const rows = await invoke<TaskListDto[]>("list_task_lists_cmd");
    const mapped = Array.isArray(rows) ? rows.map(mapTaskListRow) : [];
    taskLists.length = 0;
    taskLists.push(...defaultTaskLists(), ...mapped);
  } catch (err) {
    console.error("loadTaskListsFromSqlite failed", err);
    toasts.error("Failed to load task lists", errorMessage(err));
  }
}

// One-time migration: pull the legacy `kon_task_lists` blob into SQLite
// via import_task_lists_cmd. Idempotent because the import command
// skips ids that already exist; we still only run it when the
// localStorage key is present so we don't pay a no-op round trip on
// every launch. The localStorage key is removed only on a successful
// import so a transient backend error doesn't lose data.
async function migrateTaskListsFromLocalStorage() {
  if (!canUseStorage()) return;
  const raw = localStorage.getItem(TASK_LISTS_KEY);
  if (!raw) return;

  const parsed = parseStoredJson<TaskList[]>(raw);
  if (!parsed || parsed.length === 0) {
    // Empty or corrupt — clear and skip so we don't keep retrying.
    localStorage.removeItem(TASK_LISTS_KEY);
    return;
  }

  // Drop the synthetic built-ins (`all`, `inbox`) — they're recreated
  // in-memory each launch and have no DB row, so importing them would
  // collide with the next launch's defaults.
  const importable = parsed.filter((l) => l.id !== "all" && l.id !== "inbox");
  if (importable.length === 0) {
    localStorage.removeItem(TASK_LISTS_KEY);
    return;
  }

  try {
    const summary = await invoke<{ imported: number; skipped: number }>(
      "import_task_lists_cmd",
      {
        items: importable.map((l) => ({
          id: l.id,
          name: l.name,
          builtIn: l.builtIn,
          profileId: l.profileId ?? null,
          createdAt: l.createdAt ?? null,
        })),
      },
    );
    // Only clear localStorage AFTER the import succeeds.
    localStorage.removeItem(TASK_LISTS_KEY);
    // Re-load from SQLite so the store reflects the imported rows.
    await loadTaskListsFromSqlite();
    toasts.info(
      "Task lists migrated",
      `${summary.imported} list${summary.imported === 1 ? "" : "s"} moved to local database.`,
    );
  } catch (err) {
    console.error("migrateTaskListsFromLocalStorage failed", err);
    toasts.error(
      "Couldn't migrate task lists",
      "Your data is safe — Kon will retry on next launch.",
    );
  }
}

if (typeof window !== "undefined" && hasTauriRuntime()) {
  // Sequencing matters: load first so the store reflects existing DB
  // state, then run the import which is idempotent vs. that state. The
  // import re-loads on success so the toasted summary matches what's
  // visible.
  (async () => {
    await loadTaskListsFromSqlite();
    await migrateTaskListsFromLocalStorage();
  })().catch(() => {});
}

export async function addTaskList(name: string, profileId: string | null = null) {
  if (!hasTauriRuntime()) return;
  const id = crypto.randomUUID();
  try {
    const row = await invoke<TaskListDto>("create_task_list_cmd", {
      request: {
        id,
        name,
        builtIn: false,
        profileId,
      },
    });
    taskLists.push(mapTaskListRow(row));
    broadcastTaskLists();
  } catch (err) {
    toasts.error("Couldn't add list", errorMessage(err));
  }
}

export async function addProfileTaskList(profileName: string) {
  const existing = taskLists.find((list) => list.profileId === profileName);
  if (existing) return existing.id;

  if (!hasTauriRuntime()) return null;
  const id = crypto.randomUUID();
  try {
    const row = await invoke<TaskListDto>("create_task_list_cmd", {
      request: {
        id,
        name: profileName,
        builtIn: false,
        profileId: profileName,
      },
    });
    taskLists.push(mapTaskListRow(row));
    broadcastTaskLists();
    return id;
  } catch (err) {
    toasts.error("Couldn't add profile list", errorMessage(err));
    return null;
  }
}

export async function removeProfileTaskList(profileName: string) {
  const idx = taskLists.findIndex((list) => list.profileId === profileName);
  if (idx < 0) return;

  const listId = taskLists[idx].id;
  if (!hasTauriRuntime()) return;
  try {
    await invoke("delete_task_list_cmd", { id: listId });
    // delete_task_list nulls dependent tasks server-side; mirror that
    // locally so the UI doesn't show stale list pointers.
    for (const task of tasks) {
      if (task.listId === listId) task.listId = null;
    }
    broadcastTasks();
    taskLists.splice(idx, 1);
    broadcastTaskLists();
  } catch (err) {
    toasts.error("Couldn't remove profile list", errorMessage(err));
  }
}

export function moveTaskToList(taskId: string, listId: string) {
  updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
}

export async function moveTaskListToProfile(listId: string, profileId: string | null) {
  const list = taskLists.find((entry) => entry.id === listId);
  if (!list || list.builtIn) return;
  if (!hasTauriRuntime()) return;
  try {
    const row = await invoke<TaskListDto>("update_task_list_cmd", {
      id: listId,
      patch: {
        // Only patch profile_id; leave name alone.
        profileId: profileId || null,
      },
    });
    const idx = taskLists.findIndex((l) => l.id === listId);
    if (idx >= 0) {
      taskLists[idx] = mapTaskListRow(row);
      broadcastTaskLists();
    }
  } catch (err) {
    toasts.error("Couldn't move list", errorMessage(err));
  }
}

export async function renameTaskList(id: string, name: string) {
  const list = taskLists.find((entry) => entry.id === id);
  if (!list || list.builtIn) return;
  if (!hasTauriRuntime()) return;
  try {
    const row = await invoke<TaskListDto>("update_task_list_cmd", {
      id,
      patch: { name },
    });
    const idx = taskLists.findIndex((l) => l.id === id);
    if (idx >= 0) {
      taskLists[idx] = mapTaskListRow(row);
      broadcastTaskLists();
    }
  } catch (err) {
    toasts.error("Couldn't rename list", errorMessage(err));
  }
}

export async function deleteTaskList(id: string) {
  const idx = taskLists.findIndex((list) => list.id === id);
  if (idx < 0 || taskLists[idx].builtIn) return;
  if (!hasTauriRuntime()) return;
  try {
    await invoke("delete_task_list_cmd", { id });
    for (const task of tasks) {
      if (task.listId === id) task.listId = null;
    }
    broadcastTasks();
    taskLists.splice(idx, 1);
    broadcastTaskLists();
  } catch (err) {
    toasts.error("Couldn't delete list", errorMessage(err));
  }
}

// NOTE: `moveTaskList` (manual reorder) and `sortTaskLists` mutate
// the in-memory array only. Persisting display order requires a new
// `display_order` column on `task_lists`; until then, the order survives
// only for the current session. This is a known, scoped limitation
// called out in B2a part 2.
export function moveTaskList(id: string, direction: number) {
  const idx = taskLists.findIndex((list) => list.id === id);
  const targetIdx = idx + direction;
  if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return;
  if (taskLists[targetIdx].builtIn) return;
  [taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
  broadcastTaskLists();
}

export function sortTaskLists(mode: "alpha" | "date") {
  const builtIn = taskLists.filter((list) => list.builtIn);
  const custom = taskLists.filter((list) => !list.builtIn);

  if (mode === "alpha") {
    custom.sort((a, b) => a.name.localeCompare(b.name));
  } else {
    custom.sort(
      (a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime(),
    );
  }

  taskLists.length = 0;
  taskLists.push(...builtIn, ...custom);
  broadcastTaskLists();
}

interface TaskListChannelMessage {
  type: "task_lists_updated";
  lists: TaskList[];
}

const taskListChannel = typeof BroadcastChannel !== "undefined"
  ? new BroadcastChannel("kon_task_lists")
  : null;

if (taskListChannel) {
  taskListChannel.onmessage = (event: MessageEvent<TaskListChannelMessage>) => {
    if (event.data.type === "task_lists_updated") {
      taskLists.length = 0;
      taskLists.push(...event.data.lists);
    }
  };
}

function broadcastTaskLists() {
  if (!taskListChannel) return;
  taskListChannel.postMessage({
    type: "task_lists_updated",
    lists: $state.snapshot(taskLists),
  } satisfies TaskListChannelMessage);
}

// B2b — templates moved from localStorage["kon_templates"] to SQLite
// (migration v17). The store starts empty and is populated asynchronously
// from the backend. The Dictation "Section scaffold" picker reads
// `templates.name` / `templates.sections`, both of which survive the new
// shape — the migration just gains `id`, `createdAt`, `updatedAt`.
//
// Default seeding is gated on a *truly fresh install* (no legacy
// localStorage key AND empty SQLite), so existing users keep their
// custom templates and never get the v3 defaults sprayed on top of
// their data.

interface TemplateDto {
  id: string;
  name: string;
  sections: string[];
  createdAt: string;
  updatedAt: string;
}

/**
 * v3-spec defaults seeded only on a truly fresh install (no legacy
 * kon_templates and no rows in SQLite). Existing user templates
 * (Meeting Notes / Report / Interview from the pre-B2b defaults) are
 * preserved verbatim by the migration — they appear in localStorage
 * because the user's first launch wrote them out, so the "fresh
 * install" check correctly skips seeding for them.
 */
function freshInstallTemplateSeed() {
  return [
    { name: "Meeting notes", sections: ["Action items", "Decisions", "Team members", "Open questions"] },
    { name: "Daily check-in", sections: ["Wins", "Blockers", "Next"] },
  ];
}

function mapTemplateRow(row: TemplateDto): Template {
  return {
    id: row.id,
    name: row.name,
    sections: Array.isArray(row.sections) ? row.sections : [],
    createdAt: row.createdAt ?? "",
    updatedAt: row.updatedAt ?? "",
  };
}

export const templates = $state<Template[]>([]);

async function loadTemplatesFromSqlite(): Promise<TemplateDto[]> {
  if (!hasTauriRuntime()) return [];
  try {
    const rows = await invoke<TemplateDto[]>("list_templates_cmd");
    const mapped = Array.isArray(rows) ? rows.map(mapTemplateRow) : [];
    templates.length = 0;
    templates.push(...mapped);
    return Array.isArray(rows) ? rows : [];
  } catch (err) {
    console.error("loadTemplatesFromSqlite failed", err);
    toasts.error("Failed to load templates", errorMessage(err));
    return [];
  }
}

// One-time migration: pull the legacy kon_templates blob into SQLite.
// Idempotent because import_templates_cmd skips ids that already exist;
// we still gate on the localStorage key's presence to avoid a no-op
// round-trip on every launch. The localStorage key is removed only on
// a successful import so a transient backend error doesn't lose data.
//
// Each item gets a server-minted uuid because the legacy shape didn't
// store one — TemplateImport.id is optional on purpose for this path.
async function migrateTemplatesFromLocalStorage(): Promise<boolean> {
  if (!canUseStorage()) return false;
  const raw = localStorage.getItem(TEMPLATES_KEY);
  if (!raw) return false;

  const parsed = parseStoredJson<Array<{ name: string; sections: string[] }>>(raw);
  if (!parsed || parsed.length === 0) {
    // Empty or corrupt — clear and skip so we don't keep retrying.
    localStorage.removeItem(TEMPLATES_KEY);
    return false;
  }

  try {
    const summary = await invoke<{ imported: number; skipped: number }>(
      "import_templates_cmd",
      {
        items: parsed.map((t) => ({
          name: t.name,
          sections: Array.isArray(t.sections) ? t.sections : [],
        })),
      },
    );
    // Only clear localStorage AFTER the import succeeds.
    localStorage.removeItem(TEMPLATES_KEY);
    // Re-load from SQLite so the store reflects the imported rows.
    await loadTemplatesFromSqlite();
    toasts.info(
      "Templates migrated",
      `${summary.imported} template${summary.imported === 1 ? "" : "s"} moved to local database.`,
    );
    return true;
  } catch (err) {
    console.error("migrateTemplatesFromLocalStorage failed", err);
    toasts.error(
      "Couldn't migrate templates",
      "Your data is safe — Kon will retry on next launch.",
    );
    return false;
  }
}

// Fresh-install seeding. Only runs when:
//   - SQLite returned zero rows (nothing migrated, nothing pre-existing)
//   - localStorage key is absent (no legacy data the migration could
//     have moved over — i.e. truly first launch)
// Existing users with localStorage templates take the migration path
// above; existing users who already migrated have non-empty SQLite and
// fall through here without seeding.
async function maybeSeedDefaultTemplates(sqliteRows: TemplateDto[]): Promise<void> {
  if (!hasTauriRuntime()) return;
  if (sqliteRows.length > 0) return;
  if (canUseStorage() && localStorage.getItem(TEMPLATES_KEY) !== null) return;

  try {
    await invoke<{ imported: number; skipped: number }>("import_templates_cmd", {
      items: freshInstallTemplateSeed(),
    });
    await loadTemplatesFromSqlite();
  } catch (err) {
    console.error("maybeSeedDefaultTemplates failed", err);
    // Non-fatal: the user can still create templates by hand.
  }
}

if (typeof window !== "undefined" && hasTauriRuntime()) {
  // Sequencing: load → migrate-if-needed → seed-if-fresh-install. The
  // migration re-loads on success so the store reflects DB state at
  // each step, and the seeding gate sees the post-migration row count.
  (async () => {
    const initialRows = await loadTemplatesFromSqlite();
    const migrated = await migrateTemplatesFromLocalStorage();
    // After a migration the row count changed; re-fetch before deciding
    // whether to seed defaults.
    const rowsForSeed = migrated ? await invoke<TemplateDto[]>("list_templates_cmd").catch(() => []) : initialRows;
    await maybeSeedDefaultTemplates(Array.isArray(rowsForSeed) ? rowsForSeed : []);
  })().catch(() => {});
}

export async function createTemplate(name: string, sections: string[]): Promise<Template | null> {
  if (!hasTauriRuntime()) return null;
  const id = crypto.randomUUID();
  try {
    const row = await invoke<TemplateDto>("create_template_cmd", {
      request: { id, name, sections },
    });
    const mapped = mapTemplateRow(row);
    templates.push(mapped);
    return mapped;
  } catch (err) {
    toasts.error("Couldn't create template", errorMessage(err));
    return null;
  }
}

export async function updateTemplate(
  id: string,
  patch: { name?: string; sections?: string[] },
): Promise<void> {
  if (!hasTauriRuntime()) return;
  try {
    const row = await invoke<TemplateDto>("update_template_cmd", {
      id,
      patch: {
        name: patch.name ?? null,
        sections: patch.sections ?? null,
      },
    });
    const idx = templates.findIndex((t) => t.id === id);
    if (idx >= 0) templates[idx] = mapTemplateRow(row);
  } catch (err) {
    toasts.error("Couldn't update template", errorMessage(err));
  }
}

export async function deleteTemplate(id: string): Promise<void> {
  if (!hasTauriRuntime()) return;
  try {
    await invoke("delete_template_cmd", { id });
    const idx = templates.findIndex((t) => t.id === id);
    if (idx >= 0) templates.splice(idx, 1);
  } catch (err) {
    toasts.error("Couldn't delete template", errorMessage(err));
  }
}
