<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
import { useReminderStore } from "../stores/reminders";
import { useUiStore } from "../stores/ui";
import BaseModal from "./BaseModal.vue";
import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue";
import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import { isDesktop } from "../desktop/bridge";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";

const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const config = useConfigStore();
const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const reminders = useReminderStore();
const ui = useUiStore();
// Sync is a desktop-app concern: the web build already IS the server's UI.
const desktopApp = isDesktop();

async function removeView(f: SavedFilter) {
  if (!window.confirm(`Delete the "${f.name}" view?`)) return;
  try {
    await savedFilters.remove(f.id);
  } catch {
    ui.showToast("Couldn't delete that view.");
  }
}

const managing = ref(false);
const showShortcuts = ref(false);
const paletteOpen = ref(false);
const drawer = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
let searchTimer: ReturnType<typeof setTimeout> | undefined;

const shortcuts = [
  { label: "Command palette", keys: ["⌘/Ctrl", "K"] },
  { label: "New note (or just start typing)", keys: ["Enter", "c"] },
  { label: "Search", keys: ["/"] },
  { label: "Go to Board", keys: ["g", "b"] },
  { label: "Go to Reminders", keys: ["g", "r"] },
  { label: "Go to Timeline", keys: ["g", "t"] },
  { label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
  { label: "Move / open focused card", keys: ["j", "k", "Enter"] },
  { label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
  { label: "Finish & close note", keys: ["⌘/Ctrl", "Enter"] },
  { label: "Save & new (composing)", keys: ["Shift", "Enter"] },
  { label: "Stop browsing / back to Notes", keys: ["Esc"] },
  { label: "This help", keys: ["?"] },
];

let gPending = false;
let gTimer: ReturnType<typeof setTimeout> | undefined;

function isTyping(e: KeyboardEvent): boolean {
  const el = e.target as HTMLElement | null;
  return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
}

function focusSearch() {
  searchInput.value?.focus();
  searchInput.value?.select();
}

// New note (the "+ New" button and the `c` shortcut): ensure we're on the board,
// then open the compose modal. Type-to-compose seeds it instead (see onKeydown).
async function newNote() {
  if (route.name !== "board") {
    await router.push("/");
    await nextTick();
  }
  ui.requestCompose();
}

// Global keyboard shortcuts, active only inside the authed shell. Ignored while
// typing in a field (except Esc, which blurs it). `g` starts a two-key jump
// (g b / g g / g r) with a short timeout.
function onKeydown(e: KeyboardEvent) {
  // Cmd/Ctrl+K toggles the command palette — works even while typing.
  if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
    e.preventDefault();
    paletteOpen.value = !paletteOpen.value;
    return;
  }
  if (isTyping(e)) {
    if (e.key === "Escape") (e.target as HTMLElement).blur();
    return;
  }
  if (e.key === "Escape" && showShortcuts.value) {
    showShortcuts.value = false;
    return;
  }
  if (e.key === "Escape" && drawer.value) {
    drawer.value = false;
    return;
  }
  // Esc with nothing open = back to the base Notes board. (An open editor gets Esc
  // first via its own handler, which stops propagation so it doesn't also navigate.)
  if (e.key === "Escape") {
    if (route.name !== "board") void router.push("/");
    return;
  }
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  if (gPending) {
    gPending = false;
    clearTimeout(gTimer);
    if (e.key === "b") {
      e.preventDefault();
      void router.push("/");
      return;
    }
    if (e.key === "r") {
      e.preventDefault();
      void router.push("/reminders");
      return;
    }
    if (e.key === "t") {
      e.preventDefault();
      void router.push("/timeline");
      return;
    }
  }
  if (e.key === "/") {
    e.preventDefault();
    focusSearch();
    return;
  }
  if (e.key === "c") {
    e.preventDefault();
    void newNote();
    return;
  }
  if (e.key === "?") {
    e.preventDefault();
    showShortcuts.value = true;
    return;
  }
  if (e.key === "g") {
    gPending = true;
    clearTimeout(gTimer);
    gTimer = setTimeout(() => (gPending = false), 800);
    return;
  }
  // Type-to-compose: any OTHER single printable key, on the board with no card
  // focused (browse mode owns j/k/e/x/#), opens a new note seeded with that key.
  if (route.name === "board" && !ui.boardCardFocused && e.key.length === 1 && /\S/.test(e.key)) {
    e.preventDefault();
    ui.requestCompose(e.key);
  }
}

onMounted(() => {
  if (!labels.loaded) void labels.load();
  if (!savedFilters.loaded) void savedFilters.load();
  reminders.start(); // foreground reminder delivery while the app is open
  window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
  reminders.stop();
  window.removeEventListener("keydown", onKeydown);
});

const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));

function labelDot(color: string): string {
  return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}

// The board lenses — the routes a search can happen *within*. Searching while looking
// at Trash should search Trash, not silently move you.
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);

/**
 * Search is a FACET, not a destination.
 *
 * It used to navigate to a `/search` view backed by a different endpoint with no
 * facets at all — so the one screen you landed on when you searched was the one
 * screen where you could not also narrow by tag, which is precisely what tags are
 * for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
 * already there, and the same AND-ed query serves both.
 *
 * Existing facets are preserved, so "filter by #grocery, then search" and the reverse
 * both work.
 */
function onSearch(value: string) {
  searchText.value = value;
  clearTimeout(searchTimer);
  searchTimer = setTimeout(() => {
    const q = searchText.value.trim();
    const onBoard = BOARD_ROUTES.has(String(route.name));
    const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
    if (q) query.q = q;
    else delete query.q;
    void router.push({ path: onBoard ? route.path : "/", query });
  }, 250);
}

// The URL is the filter state (see notes/facets.ts), so the box READS from it rather
// than holding its own copy — which is also what keeps it in step with the Filters
// panel's Clear button and with a saved view opened from the sidebar.
watch(
  () => route.query.q,
  (q) => {
    searchText.value = typeof q === "string" ? q : "";
  },
  { immediate: true },
);
watch(
  () => route.name,
  () => {
    drawer.value = false;
  },
);

/**
 * What to call the lens currently in view.
 *
 * Keyed off the route name rather than each view declaring its own title, so the
 * label sits in one place and can't go missing (the board never had one)
 * or drift in styling (timeline and reminders each had their own h1).
 *
 * A label lens is named by the label itself — "Groceries" is what the user came
 * looking for; "Label" would tell them nothing they didn't already know.
 */
const lensName = computed<string>(() => {
  switch (route.name) {
    case "archive":
      return "Archive";
    case "trash":
      return "Trash";
    case "timeline":
      return "Timeline";
    case "reminders":
      return "Reminders";
    case "label":
      // The store may not have loaded yet on a deep link; fall back rather than
      // flashing an empty slot.
      return labels.items.find((l) => l.id === String(route.params.id))?.name ?? "Label";
    default:
      return "Notes";
  }
});

async function signOut() {
  await session.logout();
  await router.replace("/login");
}
</script>

<template>
  <div class="flex min-h-full flex-col">
    <!-- First tab stop on every page. The header and sidebar are a dozen-odd tab
         stops that repeat on every navigation; without this a keyboard user walks
         all of them again to reach their own notes. Hidden until focused. -->
    <a
      href="#main"
      class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-brand dark:focus:bg-neutral-900"
    >
      Skip to notes
    </a>
    <!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
         under the status bar / notch, and this is the one box that sits there — a
         sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
         it costs nothing on a desktop or a flat-topped phone. -->
    <header
      class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 pt-[env(safe-area-inset-top,0px)] backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
    >
      <!-- Wraps, so the search field can take a line of its own on a narrow screen.
           In one row it was sharing ~360px with a menu button, the logo, the lens
           name and four icons, which left every one of them truncated — the lens read
           as "N…" and the search box as an empty pill. -->
      <div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
        <button
          type="button"
          class="icon-btn sm:hidden"
          title="Menu"
          aria-label="Open menu"
          @click="drawer = true"
        >
          <svg
            class="h-5 w-5"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
          >
            <line x1="3" y1="6" x2="21" y2="6" />
            <line x1="3" y1="12" x2="21" y2="12" />
            <line x1="3" y1="18" x2="21" y2="18" />
          </svg>
        </button>
        <RouterLink to="/" class="flex shrink-0 items-center gap-2" title="Home" aria-label="Home">
          <img src="/icon.svg" alt="" class="h-8 w-8 rounded-lg" width="32" height="32" />
          <span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
        </RouterLink>

        <!-- The active lens, named in the persistent chrome rather than inside each
             view. Which lens you're looking at is a property of the SPACE, not of a
             page you navigated to — so it sits in the bar that never moves, beside
             the app name, and stays in one place while everything beneath it
             re-filters. Replaces the per-view <h1>s, which sat in a different spot
             in each view and were absent entirely on the board. -->
        <span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
          <!-- The separator only makes sense next to the app name, which is itself
               hidden on narrow screens. There, the lens name simply takes the space
               the app name vacated — you already know which app you're in. -->
          <span aria-hidden="true" class="hidden sm:inline">/</span>
          <span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
        </span>

        <!-- `order-last w-full` drops this onto its own line below sm (a full-width
             flex item forces the wrap); from sm it returns to the middle of the row.
             ONE input either way, moved by CSS rather than duplicated — the `/`
             shortcut focuses `searchInput`, and two of those would be one ref too
             many. -->
        <div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
          <input
            ref="searchInput"
            :value="searchText"
            type="search"
            placeholder="Search notes…"
            aria-label="Search notes"
            class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand sm:py-1.5 sm:text-sm dark:border-neutral-700 dark:bg-neutral-900"
            @input="onSearch(($event.target as HTMLInputElement).value)"
          />
        </div>

        <div class="ml-auto flex shrink-0 items-center gap-1 sm:ml-0 sm:gap-3">
          <button
            type="button"
            class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
            title="New note (or just start typing)"
            @click="newNote"
          >
            <Icon name="plus" /> <span class="hidden sm:inline">New</span>
          </button>
          <!-- Whose account this is only means something when there IS an account.
               The desktop signs in as a synthetic local user so the shared router's
               auth guard resolves; naming it would invent a login the app doesn't
               have. -->
          <span
            v-if="!desktopApp"
            class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400"
            >{{ session.user?.display_name }}</span
          >
          <RouterLink
            v-if="desktopApp"
            to="/sync"
            class="icon-btn"
            title="Sync"
            aria-label="Sync"
          >
            <Icon name="sync" />
          </RouterLink>
          <!-- Server-side concept: it lists the tokens a server has issued to native
               clients. The desktop IS one of those clients, so here the list is always
               empty and issuing a token rejects — its own relationship with a server
               lives under /sync instead. -->
          <RouterLink
            v-if="!desktopApp"
            to="/account"
            class="icon-btn hidden sm:inline-flex"
            title="Linked devices"
            aria-label="Linked devices"
          >
            <Icon name="device" />
          </RouterLink>
          <RouterLink
            v-if="session.user?.is_admin"
            to="/settings"
            class="icon-btn hidden sm:inline-flex"
            title="Settings"
            aria-label="Settings"
          >
            <Icon name="settings" />
          </RouterLink>
          <!-- Hidden on the desktop, where it was a trap rather than an action:
               logout nulls the synthetic local user and redirects to /login, but the
               offline adapter rejects every sign-in ("there's no account to sign in
               to"), leaving no way back in short of restarting the app. There is
               nothing to sign out OF — the notes are on this machine either way. -->
          <button
            v-if="!desktopApp"
            type="button"
            class="icon-btn hidden sm:inline-flex"
            title="Sign out"
            aria-label="Sign out"
            @click="signOut"
          >
            <Icon name="logout" />
          </button>
        </div>
      </div>
    </header>

    <div class="flex flex-1">
      <div
        v-if="drawer"
        class="fixed inset-0 z-30 bg-black/40 sm:hidden"
        aria-hidden="true"
        @click="drawer = false"
      ></div>
      <aside
        class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
        :class="drawer ? 'translate-x-0' : ''"
      >
        <nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
          <RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
            <Icon name="note" /> Notes
          </RouterLink>

          <div class="mt-3 flex items-center justify-between px-3 pb-1">
            <span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
            <button
              type="button"
              class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
              title="Edit labels"
              aria-label="Edit labels"
              @click="managing = true"
            >
              <Icon name="pencil" />
            </button>
          </div>
          <p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
          <RouterLink
            v-for="lb in labels.items"
            :key="lb.id"
            :to="`/label/${lb.id}`"
            class="nav-link"
            :class="currentLabelId === lb.id ? 'nav-link-active' : ''"
          >
            <span
              class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
              :class="labelDot(lb.color)"
            ></span>
            <span class="truncate">{{ lb.name }}</span>
          </RouterLink>

          <template v-if="savedFilters.items.length">
            <div class="mt-3 px-3 pb-1">
              <span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Views</span>
            </div>
            <RouterLink
              v-for="f in savedFilters.items"
              :key="f.id"
              :to="{ path: '/', query: facetsToQuery(f.params) }"
              class="nav-link group/view"
            >
              <Icon name="filter" />
              <span class="flex-1 truncate">{{ f.name }}</span>
              <button
                type="button"
                class="hover-reveal text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
                :aria-label="`Delete view ${f.name}`"
                @click.prevent.stop="removeView(f)"
              >
                ×
              </button>
            </RouterLink>
          </template>

          <RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
            <Icon name="archive" /> Archive
          </RouterLink>
          <RouterLink to="/trash" class="nav-link" :class="route.name === 'trash' ? 'nav-link-active' : ''">
            <Icon name="trash" /> Trash
          </RouterLink>
          <RouterLink
            to="/reminders"
            class="nav-link"
            :class="route.name === 'reminders' ? 'nav-link-active' : ''"
          >
            <Icon name="bell" /> Reminders
          </RouterLink>
          <RouterLink
            to="/timeline"
            class="nav-link"
            :class="route.name === 'timeline' ? 'nav-link-active' : ''"
          >
            <Icon name="calendar" /> Timeline
          </RouterLink>
          <!-- Direct download (same-origin GET, session cookie sent); not a route. -->
          <a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
            <Icon name="download" /> Export
          </a>
          <ImportNotes />

          <!-- Account, settings and sign-out, for the screens where they are NOT in
               the header. Four icons plus a search field never fit one phone-width
               row, and the header is the wrong place to lose: it holds the only way
               back to the board. Here they get room to be named instead of guessed
               at from a glyph.
               Hidden from sm up, where the header carries them again — so they are
               in exactly one place at any width, never both. -->
          <div
            v-if="!desktopApp"
            class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
          >
            <p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
            <RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
              <Icon name="device" /> Linked devices
            </RouterLink>
            <RouterLink
              v-if="session.user?.is_admin"
              to="/settings"
              class="nav-link"
              :class="route.name === 'settings' ? 'nav-link-active' : ''"
            >
              <Icon name="settings" /> Settings
            </RouterLink>
            <button type="button" class="nav-link w-full text-left" @click="signOut">
              <Icon name="logout" /> Sign out
            </button>
          </div>
        </nav>
      </aside>

      <!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
           the viewport — several browsers scroll to a plain anchor without focusing
           it, which leaves the next Tab back at the top of the page. -->
      <!-- Cross-fade between lenses so switching reads as the same space
           re-framing rather than a page swap.
           Deliberately UNKEYED: board / archive / trash / label all render the same
           BoardView, so keying on the route would remount it — blanking the board
           and refetching, which is precisely the page-change feeling this is meant
           to remove. Unkeyed, Vue only transitions when the component TYPE changes
           (board ↔ timeline ↔ reminders), and moving between the board's own
           lenses stays an in-place reflow that NoteGrid animates. -->
      <main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
        <RouterView v-slot="{ Component }">
          <Transition name="lens" mode="out-in">
            <component :is="Component" />
          </Transition>
        </RouterView>
      </main>
    </div>

    <LabelsModal v-if="managing" @close="managing = false" />

    <CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />

    <BaseModal
      v-if="showShortcuts"
      panel-class="w-full max-w-sm p-5 shadow-xl"
      align="center"
      aria-label="Keyboard shortcuts"
      @close="showShortcuts = false"
    >
        <div class="mb-3 flex items-center justify-between">
          <h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
          <button
            type="button"
            class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
            aria-label="Close"
            @click="showShortcuts = false"
          >
            ×
          </button>
        </div>
        <dl class="flex flex-col gap-2 text-sm">
          <div v-for="s in shortcuts" :key="s.label" class="flex items-center justify-between gap-4">
            <dt class="text-neutral-600 dark:text-neutral-300">{{ s.label }}</dt>
            <dd class="flex items-center gap-1">
              <kbd
                v-for="(k, i) in s.keys"
                :key="i"
                class="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200"
                >{{ k }}</kbd
              >
            </dd>
          </div>
        </dl>
  </BaseModal>
  </div>
</template>
