/**
 * desktop/src/main.ts — the Electron entry point (glue only).
 *
 * Wires the desktop shell together: starts the embedded server (which hosts the
 * unchanged frontend), opens the window against http://127.0.0.1:<port>, installs
 * the app menu + tray + login item, registers the terminalapp:// deep link with a
 * single-instance lock, and runs a background poller that turns session-status
 * transitions into native notifications (the desktop app's core value over a
 * browser tab). All real logic lives in the pure modules consumed below; this
 * file owns only Electron lifecycle and dependency injection.
 *
 * __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
 */
import { app, BrowserWindow, Menu, Notification, Tray, dialog } from 'electron'
import type { Certificate } from 'electron'
import path from 'node:path'

import { createLogger } from './logger.js'
import { createSettingsStore } from './settings-store.js'
import { startEmbeddedServer } from './embedded-server.js'
import { computeNotifications } from './notifications.js'
import { mapLiveSessions } from './live-poll.js'
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
import { createMainWindow, originOf } from './window.js'
import { buildAppMenu } from './menu.js'
import { createTray, buildTrayMenu } from './tray.js'
import type { TrayHandlers } from './tray.js'
import {
  selectHost,
  selectedRemoteHost,
  remoteHostOrigin,
  makeRemoteHost,
  addRemoteHost,
  removeRemoteHost,
  TERMINAL_ZONE_SUFFIX,
} from './remote-hosts.js'
import type { NotifyOptions } from './notify-policy.js'
import type { DesktopPrefs, EmbeddedServer, SessionStatusSnapshot } from './types.js'

/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
const POLL_INTERVAL_MS = 4000
/** IPC channel used to hand a deep-link path to the (optional) preload bridge. */
const DEEP_LINK_CHANNEL = 'deep-link'
/** Preload bundle name, emitted next to this file by esbuild. */
const PRELOAD_FILE = 'preload.cjs'
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')

/**
 * DEVICE_CA_CN — Common Name of the device client-certificate CA (VPS Track V2,
 * gen-device-ca.sh). The device `.p12` lives in the OS keychain (macOS login
 * keychain / Windows Personal store); Chromium sources client certificates ONLY
 * from the OS store — reading a `.p12` in-app is infeasible — so on an mTLS
 * handshake we select the cert whose issuer CN matches this. Override for a
 * differently-named CA via the WEBTERM_DEVICE_CA_CN env var.
 */
const DEVICE_CA_CN = process.env.WEBTERM_DEVICE_CA_CN ?? 'WebTerminal Device CA'

/** Add-remote-host prompt window dimensions. */
const PROMPT_WIDTH = 460
const PROMPT_HEIGHT = 260

/**
 * Minimal self-contained HTML for the "Add remote host" prompt window. It runs no
 * Node (sandboxed, no preload) and returns its result by setting `location.hash`
 * (read back via did-navigate-in-page); user text is JSON+URI-encoded into the
 * hash, never interpolated into markup, so there is no injection surface. The CSP
 * allows only inline style/script from this trusted, app-authored document.
 */
const PROMPT_HTML = `<!doctype html>
<html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
<style>
  :root { color-scheme: dark light; }
  body { font: 13px -apple-system, Segoe UI, system-ui, sans-serif; margin: 0; padding: 18px;
    background: #0e0f13; color: #e6e6e6; }
  h1 { font-size: 15px; margin: 0 0 14px; }
  label { display: block; margin-bottom: 10px; }
  span { display: block; margin-bottom: 4px; color: #9aa0ab; }
  input { width: 100%; box-sizing: border-box; padding: 7px 8px; border-radius: 6px;
    border: 1px solid #333; background: #16181d; color: #e6e6e6; font: inherit; }
  .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
  button { padding: 7px 14px; border-radius: 6px; border: 1px solid #333;
    background: #22252c; color: #e6e6e6; font: inherit; cursor: pointer; }
  button[type=submit] { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
</style></head>
<body>
  <h1>Add remote host</h1>
  <form id="f">
    <label><span>Name</span><input id="name" autofocus placeholder="Laptop"></label>
    <label><span>URL</span><input id="url" value="https://" placeholder="https://t1.terminal.yaojia.wang"></label>
    <div class="row">
      <button type="button" id="cancel">Cancel</button>
      <button type="submit">Add</button>
    </div>
  </form>
  <script>
    var form = document.getElementById('f');
    form.addEventListener('submit', function (e) {
      e.preventDefault();
      var payload = { name: document.getElementById('name').value, url: document.getElementById('url').value };
      location.hash = 'ok:' + encodeURIComponent(JSON.stringify(payload));
    });
    document.getElementById('cancel').addEventListener('click', function () { location.hash = 'cancel'; });
  </script>
</body></html>`

const logger = createLogger('main')
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)

let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
let server: EmbeddedServer | null = null
// The origin the main window is currently locked to (local embedded server, or a
// selected remote tunnel host). Read live by window.ts's will-navigate guard;
// reassigned (never mutated) whenever we point the window at a different host.
let activeOrigin: string | null = null
let pollTimer: NodeJS.Timeout | null = null
let isQuitting = false
let isShuttingDown = false
// Previous snapshot map, keyed by sessionId; replaced (never mutated) each tick.
let prevSnapshots: ReadonlyMap<string, SessionStatusSnapshot> = new Map()
// Deep link captured before window/server were ready (cold start); dispatched once
// onReady finishes. macOS 'open-url' can fire pre-ready; Win/Linux pass it in argv.
let pendingDeepLink: string | null = null
// The first successful poll seeds the baseline WITHOUT notifying, so launching with
// pre-existing waiting/stuck sessions (e.g. openAtLogin, window unfocused) does not
// burst-notify for transitions that happened before the app started.
let pollPrimed = false

function errorMessage(err: unknown): string {
  return err instanceof Error ? err.message : String(err)
}

/** Bring the window to the foreground (restoring/showing if hidden or minimized). */
function focusWindow(): void {
  if (!mainWindow) return
  if (mainWindow.isMinimized()) mainWindow.restore()
  mainWindow.show()
  mainWindow.focus()
}

/** True iff `value` is a plain, indexable object (not null, not an array). */
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

/** The embedded local server's base URL (`http://127.0.0.1:<port>`), or null. */
function localBaseUrl(): string | null {
  return server ? `http://127.0.0.1:${server.port}` : null
}

/**
 * Resolve the page URL + origin the window should currently be on, given prefs:
 * the selected remote host, else the local embedded server. Returns null only if
 * neither is resolvable (e.g. server not up yet and no valid remote selected).
 */
function resolveTarget(prefs: DesktopPrefs): { url: string; origin: string } | null {
  const remote = selectedRemoteHost(prefs)
  if (remote) {
    const origin = remoteHostOrigin(remote)
    if (origin !== null) return { url: remote.url, origin }
  }
  const base = localBaseUrl()
  if (base === null) return null
  return { url: `${base}/`, origin: base }
}

/** Rebuild the tray context menu from the current host list + selection. */
function refreshTray(): void {
  if (!tray) return
  const prefs = settings.get()
  tray.setContextMenu(
    buildTrayMenu(
      { remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
      trayHandlers,
    ),
  )
}

/**
 * Switch the window to a host (null → local). Persists the selection, updates the
 * origin lock, navigates the window (programmatic loadURL does NOT trip
 * will-navigate), refreshes the tray, and focuses. A no-op if nothing resolves.
 */
function switchToHost(id: string | null): void {
  const prefs = settings.set(selectHost(settings.get(), id))
  const target = resolveTarget(prefs)
  if (!target) {
    logger.warn('cannot switch host: no resolvable target (server not ready?)')
    return
  }
  activeOrigin = target.origin
  if (mainWindow) void mainWindow.loadURL(target.url)
  refreshTray()
  focusWindow()
}

/** Prompt for a new remote host, validate it, persist it, and switch to it. */
async function addRemoteHostFlow(): Promise<void> {
  const input = await promptRemoteHost()
  if (!input) return
  const host = makeRemoteHost(input.name, input.url)
  if (!host) {
    showError(
      'Invalid remote host',
      'That is not a valid tunnel URL.',
      `Enter an https URL under ${TERMINAL_ZONE_SUFFIX}, ` +
        'e.g. https://t1.terminal.yaojia.wang',
    )
    return
  }
  settings.set(addRemoteHost(settings.get(), host))
  switchToHost(host.id)
}

/** Remove a remote host; if it was selected, selection falls back to local. */
function removeRemoteHostFlow(id: string): void {
  const prefs = settings.set(removeRemoteHost(settings.get(), id))
  const target = resolveTarget(prefs)
  if (target) {
    activeOrigin = target.origin
    if (mainWindow) void mainWindow.loadURL(target.url)
  }
  refreshTray()
}

/** Handlers wired into the tray host-picker (stable object; reads prefs live). */
const trayHandlers: TrayHandlers = {
  show: () => focusWindow(),
  quit: () => {
    isQuitting = true
    app.quit()
  },
  selectHost: (id) => switchToHost(id),
  addHost: () => {
    void addRemoteHostFlow()
  },
  removeHost: (id) => removeRemoteHostFlow(id),
}

/** Show a native error dialog (parented to the main window when one exists). */
function showError(title: string, message: string, detail: string): void {
  const options = { type: 'error' as const, title, message, detail }
  if (mainWindow) void dialog.showMessageBox(mainWindow, options)
  else void dialog.showMessageBox(options)
}

/**
 * Modal prompt for a remote-host name + URL. Resolves to the entered values, or
 * null if cancelled/closed. The window returns its result via location.hash (no
 * Node, no preload, no IPC surface); see PROMPT_HTML.
 */
function promptRemoteHost(): Promise<{ name: string; url: string } | null> {
  return new Promise((resolve) => {
    const win = new BrowserWindow({
      parent: mainWindow ?? undefined,
      modal: mainWindow !== null,
      width: PROMPT_WIDTH,
      height: PROMPT_HEIGHT,
      resizable: false,
      minimizable: false,
      maximizable: false,
      title: 'Add remote host',
      backgroundColor: '#0e0f13',
      webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
    })
    let settled = false
    const finish = (result: { name: string; url: string } | null): void => {
      if (settled) return
      settled = true
      resolve(result)
      if (!win.isDestroyed()) win.close()
    }
    win.webContents.on('did-navigate-in-page', (_event, navUrl) => {
      let hash: string
      try {
        hash = new URL(navUrl).hash
      } catch {
        return
      }
      if (hash === '#cancel') {
        finish(null)
        return
      }
      if (!hash.startsWith('#ok:')) return
      try {
        const parsed: unknown = JSON.parse(decodeURIComponent(hash.slice('#ok:'.length)))
        if (isRecord(parsed) && typeof parsed['name'] === 'string' && typeof parsed['url'] === 'string') {
          finish({ name: parsed['name'], url: parsed['url'] })
        } else {
          finish(null)
        }
      } catch {
        finish(null)
      }
    })
    win.on('closed', () => finish(null))
    void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PROMPT_HTML)}`)
  })
}

/**
 * D-2: choose the client certificate whose issuer is our device CA. The OS
 * keychain may hold unrelated certs, so match on issuer CN rather than picking
 * blindly. Returns null when nothing in the list was issued by DEVICE_CA_CN.
 */
function pickCertByIssuer(list: readonly Certificate[], issuerCn: string): Certificate | null {
  return (
    list.find(
      (cert) => cert.issuerName === issuerCn || cert.issuer.commonName === issuerCn,
    ) ?? null
  )
}

/** Tell the user their device cert isn't installed (D-2: never fail silently). */
function showDeviceCertMissingDialog(requestUrl: string): void {
  const host = originOf(requestUrl) ?? requestUrl
  showError(
    'Device certificate not installed',
    'No matching device certificate was found in the OS keychain.',
    `Connecting to ${host} requires your device client certificate (issued by ` +
      `"${DEVICE_CA_CN}"). Install the provided device.p12 into the login keychain ` +
      '(macOS: double-click the file) or the Personal certificate store (Windows), ' +
      'then reconnect. On macOS you will be prompted once to "Always Allow" access ' +
      'to the private key.',
  )
}

/** Find the first terminalapp:// argument in a process argv list. */
function findDeepLinkArg(argv: readonly string[]): string | null {
  const prefix = `${DEEP_LINK_PROTOCOL}://`
  return argv.find((arg) => arg.startsWith(prefix)) ?? null
}

/**
 * Apply a deep link: validate it, then navigate the window same-origin to
 * /?join=<id> (the mechanism the unchanged frontend actually understands). The
 * IPC send is a best-effort cooperative hook for any future in-page handler.
 */
function dispatchDeepLink(url: string): void {
  const link = parseDeepLink(url)
  if (!link) {
    logger.warn(`ignoring malformed deep link: ${url}`)
    return
  }
  if (!mainWindow || !server) {
    // Cold start: window/server not up yet — buffer for onReady to dispatch.
    pendingDeepLink = url
    return
  }

  const targetPath = deepLinkToPath(link)
  const base = `http://127.0.0.1:${server.port}`
  // A deep link joins a LOCAL session, so navigate the window back to the local
  // origin and move the lock with it (in case a remote host was active).
  activeOrigin = base
  focusWindow()
  mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
  void mainWindow.loadURL(`${base}${targetPath}`)
}

/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
function installWindowCloseGuard(win: BrowserWindow): void {
  win.on('close', (event) => {
    if (isQuitting) return
    event.preventDefault()
    win.hide()
  })
  win.on('closed', () => {
    mainWindow = null
  })
}

async function pollOnce(base: string): Promise<void> {
  // Read prefs live each tick so notify toggles take effect without a restart
  // (a settings UI wiring settings.set() lands in P2). NOTE: /live-sessions
  // carries status only, not approval state, so mapLiveSessions fixes
  // pendingApproval=false — the notifyOnApproval branch is driven by the hook
  // status bus (P2/D8), not this poll; status→'waiting' is the current proxy.
  const prefs = settings.get()
  try {
    const res = await fetch(`${base}/live-sessions`)
    if (!res.ok) {
      logger.warn(`live-sessions poll returned ${res.status}`)
      return
    }
    const json: unknown = await res.json()
    const snapshots = mapLiveSessions(json)
    const opts: NotifyOptions = {
      windowFocused: mainWindow?.isFocused() ?? false,
      notifyOnApproval: prefs.notifyOnApproval,
      notifyOnStatusChange: prefs.notifyOnStatusChange,
    }
    const { decisions, next } = computeNotifications(prevSnapshots, snapshots, opts)
    if (pollPrimed) {
      for (const decision of decisions) {
        new Notification({ title: decision.title, body: decision.body }).show()
      }
    } else {
      // Seed the baseline silently on the first successful poll (no burst).
      pollPrimed = true
    }
    prevSnapshots = next
  } catch (err: unknown) {
    // A poll failure (server warming up, transient fetch error) must never crash.
    logger.warn(`live-sessions poll failed: ${errorMessage(err)}`)
  }
}

function startPoller(base: string): void {
  pollTimer = setInterval(() => {
    void pollOnce(base)
  }, POLL_INTERVAL_MS)
}

async function onReady(): Promise<void> {
  const prefs = settings.get()
  server = await startEmbeddedServer({ prefs, logger })
  const base = `http://127.0.0.1:${server.port}`

  // Honor a previously-selected remote host across restarts; else land on local.
  const initial = resolveTarget(prefs) ?? { url: `${base}/`, origin: base }
  activeOrigin = initial.origin
  mainWindow = createMainWindow(
    initial.url,
    path.join(__dirname, PRELOAD_FILE),
    () => activeOrigin,
  )
  installWindowCloseGuard(mainWindow)

  Menu.setApplicationMenu(buildAppMenu())
  try {
    tray = createTray(
      path.join(__dirname, TRAY_ICON_SUBPATH),
      { remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
      trayHandlers,
    )
  } catch (err: unknown) {
    // A missing/invalid tray icon (cosmetic) must not take down a working app.
    logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
  }

  // Only register auto-launch when the user enabled it. Calling this on an
  // unsigned / non-standard-location build emits a native "Operation not permitted"
  // line, so skip it in the default (off) case to keep the log clean.
  if (prefs.openAtLogin) {
    try {
      app.setLoginItemSettings({ openAtLogin: true })
    } catch (err: unknown) {
      logger.warn(`could not set login item: ${errorMessage(err)}`)
    }
  }
  startPoller(base)

  // Cold-start deep link: macOS may have buffered an early 'open-url'; Windows/
  // Linux deliver it in argv. Dispatch now that the window + server exist.
  const coldLink = pendingDeepLink ?? findDeepLinkArg(process.argv)
  if (coldLink) {
    pendingDeepLink = null
    dispatchDeepLink(coldLink)
  }

  logger.info(`desktop ready on ${base} (bind ${server.bindHost})`)
}

/** Graceful teardown: stop the poller, close the server, then hard-exit. */
async function shutdown(): Promise<void> {
  if (pollTimer) {
    clearInterval(pollTimer)
    pollTimer = null
  }
  try {
    if (server) await server.close()
  } catch (err: unknown) {
    logger.error('error while closing embedded server', err)
  } finally {
    tray?.destroy()
    app.exit(0)
  }
}

if (!app.requestSingleInstanceLock()) {
  // Another instance owns the lock; it will receive our argv via 'second-instance'.
  app.quit()
} else {
  app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)

  // ── D-2: mTLS device-certificate selection (from the OS keychain) ──────────
  // When a remote tunnel host (nginx `ssl_verify_client on`) requests a client
  // cert, Chromium asks us which of the OS-store certs to present. preventDefault
  // is MANDATORY: without it Electron auto-picks list[0] (any cert, no control).
  // We deliberately choose the one issued by our device CA; if none is installed
  // we show a clear dialog (never a silent callback) and proceed cert-less so the
  // TLS handshake fails cleanly rather than hanging.
  app.on('select-client-certificate', (event, _webContents, url, list, callback) => {
    event.preventDefault()
    const cert = pickCertByIssuer(list, DEVICE_CA_CN)
    if (!cert) {
      showDeviceCertMissingDialog(url)
      callback() // no cert → nginx rejects the handshake (clean, non-hanging failure)
      return
    }
    callback(cert)
  })

  // ── D-3 (deferred — documented TODO; coordinate with Track V/S) ────────────
  // This box can also be its OWN tunnel source. A follow-up may optionally spawn
  // + supervise a local `frpc` from here (PLAN_NATIVE_TUNNEL §"C-Desktop / D-3")
  // and inject the embedded server's ALLOWED_ORIGINS=
  // https://<name>.terminal.yaojia.wang (additive via the backend config.ts). The
  // embedded server already defaults BIND_HOST=127.0.0.1 (server-config.ts), which
  // satisfies precondition P-A — EXCEPT when `lanSharing` is enabled: that binds
  // 0.0.0.0 and re-opens an unauthenticated shell on the LAN, bypassing mTLS. So a
  // tunnel-source host must keep lanSharing OFF. Not implemented here.

  app.on('second-instance', (_event, argv) => {
    focusWindow()
    const url = findDeepLinkArg(argv)
    if (url) dispatchDeepLink(url)
  })

  // macOS delivers deep links via 'open-url', not argv.
  app.on('open-url', (_event, url) => {
    dispatchDeepLink(url)
  })

  app.on('activate', () => {
    focusWindow()
  })

  // Tray keeps the app (and embedded server) alive after the last window closes,
  // so remote devices stay connected. Intentionally does not quit on any platform.
  app.on('window-all-closed', () => {})

  app.on('before-quit', (event) => {
    isQuitting = true
    if (isShuttingDown) return
    isShuttingDown = true
    // Take over quit so the async server.close() actually completes before exit.
    event.preventDefault()
    void shutdown()
  })

  app.whenReady().then(onReady).catch((err: unknown) => {
    logger.error('failed to start desktop app', err)
    app.quit()
  })
}
