<!--
  SPDX-License-Identifier: GPL-3.0-or-later
  Copyright (C) 2026 Tvheadend contributors
-->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import NavRail from '@/components/NavRail.vue'
import TopBar from '@/components/TopBar.vue'
import IdnodeEditor from '@/components/IdnodeEditor.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import VideoPlayerDialog from '@/components/VideoPlayerDialog.vue'
import ErrorDialog from '@/components/ErrorDialog.vue'
import CommandPalette from '@/components/CommandPalette.vue'
import ConfirmDialog from 'primevue/confirmdialog'
import Toast from 'primevue/toast'
import { HelpCircle } from 'lucide-vue-next'
import { useRailPreference } from '@/composables/useRailPreference'
import { usePageTitle } from '@/composables/usePageTitle'
import { useDvrEditor } from '@/composables/useDvrEditor'
import { useEntityEditor } from '@/composables/useEntityEditor'
import { useAccessStore } from '@/stores/access'
import { useCommandPalette } from '@/composables/useCommandPalette'
import { registerShortcut } from '@/composables/useGlobalShortcuts'
import { useI18n } from '@/composables/useI18n'

const { t } = useI18n()

/* Mount the document.title manager once, here at the persistent
 * root, so it survives every navigation. See usePageTitle.ts. */
usePageTitle()

/* Singleton DVR-entry editor — overlays whatever view is currently
 * shown. EPG callers (TimelineView / MagazineView / EpgEventDrawer)
 * call `useDvrEditor().open(uuid)` instead of routing to the DVR
 * list page; the drawer slides in over the EPG view, leaving the
 * user's day picker / scroll position / hover state intact when
 * closed. The composable owns URL sync (router.replace, EPG-routes
 * only) and route-change auto-close. */
const { editingUuid, close: closeDvrEditor } = useDvrEditor()
/* Singleton drill-down editor — driven by `DrillDownCell`
 * chevrons on grid cells with a UUID companion. Same overlay
 * shape as the DVR editor above but class-agnostic: the
 * IdnodeEditor calls `api/idnode/load` which resolves the
 * class for any UUID, so no per-class `list` restriction.
 * Route-change auto-close lives in the composable. */
const {
  editingUuid: drillUuid,
  pickerRows: drillPickerRows,
  pickerColumns: drillPickerColumns,
  pickerTitle: drillPickerTitle,
  close: closeDrillEditor,
} = useEntityEditor()
/* Picker-mode row switch — move the editor to the picked entity
 * without disturbing the picker table (which `open()` would clear).
 * IdnodeEditor's `pick` already ran the dirty-discard confirm. */
function pickDrillEntry(uuid: string) {
  drillUuid.value = uuid
}
const access = useAccessStore()
/* Union of the field sets the per-view DVR editors use
 * (UpcomingView.vue:164-174 + FinishedView.vue:75-79 + the analogous
 * FailedView list) so this singleton renders meaningfully regardless
 * of whether the EPG-clicked entry is upcoming, finished, or failed.
 * The server returns only the props that exist on the loaded entry,
 * so unused fields just don't render. */
const dvrEditList =
  'enabled,disp_title,disp_extratext,episode_disp,channel,start,start_extra,' +
  'stop,stop_extra,pri,uri,config_name,playcount,owner,creator,comment,' +
  'retention,removal'

const railOpen = ref(false)
const route = useRoute()

function toggleRail() {
  railOpen.value = !railOpen.value
}

function closeRail() {
  railOpen.value = false
}

/*
 * "Full-bleed" layouts manage their own padding so the inner layout
 * (e.g. ConfigurationLayout's L2 sidebar) can sit flush against
 * NavRail's right edge instead of floating in a strip of page-bg
 * gap. Opt in via `meta.fullBleed: true` on the route. Required for
 * layouts that introduce a second-column nav surface; standard
 * page-padded views (EPG / DVR / Status) don't set the flag and
 * keep the comfortable margin.
 */
const fullBleed = computed(() => !!route.meta?.fullBleed)

/*
 * Mid-width L1 collapse. When a route declares `meta.hasL2`, the
 * layout already owns a second-column sidebar — so on screens
 * narrower than 1280px we collapse the L1 NavRail to icons-only
 * (56px) to keep two nav columns + content readable. Above 1280px
 * the rail stays at full width. Below 768px the existing phone
 * drawer takes over and `compact` is moot — NavRail's @media block
 * shadows it.
 *
 * Reactive viewport tracking lives here (rather than inside NavRail)
 * because AppShell already orchestrates layout decisions across the
 * shell — keeping the rule next to fullBleed makes the policy easy
 * to read, and NavRail stays a dumb presentational component that
 * just consumes the boolean.
 */
const COMPACT_BELOW = 1280
const PHONE_BELOW = 768

const viewportWidth = ref(globalThis.window?.innerWidth ?? 1920)
function onResize() {
  viewportWidth.value = globalThis.window.innerWidth
}
onMounted(() => globalThis.window.addEventListener('resize', onResize))
onBeforeUnmount(() => globalThis.window.removeEventListener('resize', onResize))

/*
 * Effective compact-rail rule combines three pieces of state from
 * the rail-preference composable:
 *
 *   manualCollapsed   user's persistent preference (localStorage).
 *   autoActive        derived right here — the auto rule's "wants
 *                     compact right now" signal, written into the
 *                     composable so its toggle function can see it.
 *   autoOverride      transient per-view override; non-null when the
 *                     user clicked the chevron while autoActive was
 *                     true. Reset on every route change below.
 *
 * Rule:
 *   < 768px           expanded (phone hamburger drawer takes over)
 *   override !== null override
 *   otherwise         manual || autoActive
 *
 * Full rationale and the case-by-case walkthrough live in
 * useRailPreference.ts's header.
 */
const { manualCollapsed, autoActive, autoOverride, clearAutoOverride } = useRailPreference()

watchEffect(() => {
  autoActive.value =
    !!route.meta?.hasL2 && viewportWidth.value >= PHONE_BELOW && viewportWidth.value < COMPACT_BELOW
})

/* Reset the per-view override on every route change so re-entering
 * an auto-active route always re-fires the auto rule fresh —
 * matches the original Commit-13 spec ("for configuration we keep
 * to auto collapse"). Includes Configuration sub-tab navigation
 * (e.g., /configuration/general → /configuration/users); each is a
 * fresh visit. */
watch(() => route.path, clearAutoOverride)

const compactRail = computed(() => {
  if (viewportWidth.value < PHONE_BELOW) return false
  if (autoOverride.value !== null) return autoOverride.value
  return manualCollapsed.value || autoActive.value
})

/*
 * Global command-palette shortcuts. Cmd-K (Mac) / Ctrl-K
 * (Win/Linux) toggle the palette; '/' opens it but only when
 * focus isn't in an input (otherwise it would eat the user's
 * typing). Registered on mount, cleaned up on unmount — AppShell
 * never unmounts in practice so cleanup never runs, but the
 * pairing keeps the lifecycle honest if AppShell is ever re-mounted
 * for testing or HMR.
 */
const commandPalette = useCommandPalette()
let unregisterCmdK: (() => void) | null = null
let unregisterSlash: (() => void) | null = null

onMounted(() => {
  unregisterCmdK = registerShortcut(
    { key: 'k', eitherMetaOrCtrl: true },
    () => commandPalette.toggle(),
  )
  unregisterSlash = registerShortcut(
    { key: '/', ignoreInEditable: true },
    () => commandPalette.open(),
  )
})

onBeforeUnmount(() => {
  unregisterCmdK?.()
  unregisterSlash?.()
  unregisterCmdK = null
  unregisterSlash = null
})

/* Skip-link target jump in JS: the document carries a <base> tag
 * (webroot support), so a bare href="#main-content" would resolve
 * against the base URL and navigate away instead of jumping
 * in-page. Focus the landmark directly (tabindex=-1 makes <main>
 * focusable), which is also the more robust skip-link behaviour. */
function skipToMain(): void {
  const el = document.getElementById('main-content')
  if (!el) return
  el.setAttribute('tabindex', '-1')
  el.focus()
  el.scrollIntoView()
}
</script>

<template>
  <a class="skip-link" href="#main-content" @click.prevent="skipToMain">{{
    t('Skip to main content')
  }}</a>
  <div class="app-shell">
    <TopBar @toggle-rail="toggleRail" />
    <div class="app-shell__body">
      <NavRail :open="railOpen" :compact="compactRail" @navigate="closeRail" />
      <main
        id="main-content"
        class="app-shell__main"
        :class="{ 'app-shell__main--full-bleed': fullBleed }"
      >
        <RouterView />
      </main>
      <div v-if="railOpen" class="app-shell__scrim" aria-hidden="true" @click="closeRail" />
    </div>

    <!--
      Singleton-instance overlays. Both PrimeVue services are
      registered in main.ts; their visual instances mount once here
      so any consumer that calls the confirm or toast composable
      shares the same DOM root. Default Aura styling picks up the
      tvh CSS-variable tokens via styles/primevue.css.

      The icon slot on ConfirmDialog injects a Lucide HelpCircle —
      a neutral question-mark icon that identifies the dialog as a
      confirmation prompt without claiming a severity. The
      destructive signal lives on the Yes button's danger severity
      (red fill) via the per-call ask-options flag. Using Lucide
      for the icon keeps the app on a single icon package —
      primeicons (the conventional PrimeVue path) would add a font
      file and a second visual style.
    -->
    <ConfirmDialog>
      <template #icon>
        <HelpCircle :size="22" :stroke-width="2" />
      </template>
    </ConfirmDialog>
    <Toast position="top-right" />
    <!-- Singleton error dialog. Save failures across the app
         (IdnodeEditor drawer, in-grid inline edit, batch ops) pop
         this modal via `useErrorDialog().show(...)` — gives
         server-side validation errors enough screen real estate
         to read and a clear OK-to-dismiss contract that a toast
         can't provide. -->
    <ErrorDialog />
    <!-- Singleton in-app help modal. Mounted once at AppShell
         level so every non-wizard route has it available; the
         IdnodeGrid `helpPage` prop opens it via the
         `useHelp().toggle(page)` composable. The wizard has its
         own surface (`WizardHelpDock`) and bypasses this. -->
    <HelpDialog />
    <!-- Singleton command palette. Cmd-K / Ctrl-K / `/` from
         anywhere outside a text input toggle it open. The NavRail
         and TopBar carry visible triggers for pointer / touch
         users; the dialog itself lives here so the shortcut works
         on every route. -->
    <CommandPalette />
    <!-- Singleton in-browser video player. Opened via
         `useVideoPlayer().open(...)` from the EPG event drawer's
         "Play in browser" action; mounted here so it survives
         the drawer closing. -->
    <VideoPlayerDialog />
    <!-- Singleton DVR-entry editor. PrimeVue's Drawer auto-teleports
         to <body>, so the mount point's location in this template
         only matters logically; the actual DOM renders at body level
         and overlays everything regardless of the wrapper z-index. -->
    <IdnodeEditor
      :uuid="editingUuid"
      :level="access.uilevel"
      :list="dvrEditList"
      :title="t('Edit Recording')"
      @close="closeDvrEditor"
    />
    <!-- Singleton drill-down editor. Independent IdnodeEditor
         instance driven by useEntityEditor: opens whichever
         class the UUID points at (server resolves), with no
         `list` restriction so every property the user has
         access to is rendered. In picker mode it also shows a
         single-select table of the passed entities above the
         form (Home dashboard "This week's recordings"). -->
    <IdnodeEditor
      :uuid="drillUuid"
      :level="access.uilevel"
      :title="drillPickerTitle ?? undefined"
      :picker-rows="drillPickerRows"
      :picker-columns="drillPickerColumns"
      @pick="pickDrillEntry"
      @close="closeDrillEditor"
    />
  </div>
</template>

<style scoped>
.app-shell {
  display: flex;
  flex-direction: column;
  /* `100vh` is the fallback for browsers that don't support
   * `100dvh`. On iOS Safari (and modern Chrome / Firefox)
   * `vh` resolves to the LARGE viewport, which overshoots the
   * visible area whenever the URL bar is still on-screen — the
   * shell would overflow at the bottom and the TopBar's pinned
   * position could be dragged out of view by page-level scroll.
   * `dvh` tracks the dynamic viewport so the shell exactly fits
   * what the user can see. Same pattern PhoneSortPopover uses. */
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--tvh-bg-page);
}

.app-shell__body {
  display: flex;
  flex: 1;
  min-height: 0;
  position: relative;
}

.app-shell__main {
  flex: 1;
  overflow: auto;
  /* Tighter top padding (was --tvh-space-6 = 24px). Pages with
   * a PageTabs L2 nav at the top sat ~36 px below the TopBar
   * before the first tab-text baseline (24 main-top + 12 tab-
   * top); admins felt this wasted vertical real estate that
   * could go to grid rows. Side + bottom keep the 24 px
   * breathing room from NavRail and the page bottom. */
  padding: var(--tvh-space-2) var(--tvh-space-6) var(--tvh-space-6);
}

/*
 * Full-bleed mode for layouts with their own internal nav columns
 * (Configuration's L2 sidebar). The layout sits flush against
 * NavRail's right edge — its own internal panes manage padding so
 * content stays comfortable.
 */
.app-shell__main--full-bleed {
  padding: 0;
}

/* Mobile scrim — clicking outside the open rail closes it */
.app-shell__scrim {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  z-index: 9;
}

@media (min-width: 768px) {
  .app-shell__scrim {
    display: none;
  }
}
</style>
