import { app, BrowserWindow, protocol, net, shell, ipcMain } from 'electron';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { existsSync, promises as fsp } from 'node:fs';

// Dev-mode loads from Vite dev-server (http://localhost:8080) so HMR works.
// Prod-mode loads from in-process custom scheme `vojo://app/index.html`.
// `VOJO_ELECTRON_PROD=1` forces prod-mode in an un-packaged binary so the
// scheme + service-worker path can be validated without re-running
// electron-builder. The packaged app sets `app.isPackaged === true`.
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
const DEV_URL = 'http://localhost:8080';
const APP_SCHEME = 'vojo';
const APP_HOST = 'app';

// Extensions that look like real web assets; for these, a missing file is a
// genuine 404. Anything else (including Matrix-flavoured `roomId/userId`
// segments like `!foo:vojo.chat` whose `path.extname` returns `.chat`) is
// treated as a SPA route and falls back to `index.html`. The allowlist is
// intentionally narrow — extend only when adding a new bundled asset kind.
const WEB_ASSET_EXTENSIONS = new Set([
  '.js',
  '.mjs',
  '.cjs',
  '.css',
  '.html',
  '.htm',
  '.map',
  '.json',
  '.txt',
  '.xml',
  '.svg',
  '.ico',
  '.png',
  '.jpg',
  '.jpeg',
  '.gif',
  '.webp',
  '.avif',
  '.woff',
  '.woff2',
  '.ttf',
  '.otf',
  '.wasm',
]);

protocol.registerSchemesAsPrivileged([
  {
    scheme: APP_SCHEME,
    privileges: {
      standard: true,
      secure: true,
      supportFetchAPI: true,
      allowServiceWorkers: true,
      corsEnabled: true,
      stream: true,
      codeCache: true,
    },
  },
]);

const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);

const isSafeExternal = (raw: unknown): raw is string => {
  if (typeof raw !== 'string' || raw.length > 8 * 1024) return false;
  try {
    return ALLOWED_EXTERNAL_SCHEMES.has(new URL(raw).protocol);
  } catch {
    return false;
  }
};

// The canonical in-app origin (prod custom scheme, or the dev server in dev).
// Used by both the navigation guard and the permission handlers.
const isInternalUrl = (raw: unknown): raw is string => {
  if (typeof raw !== 'string') return false;
  try {
    const target = new URL(raw);
    return (
      (target.protocol === `${APP_SCHEME}:` && target.hostname === APP_HOST) ||
      (isDev && target.origin === DEV_URL)
    );
  } catch {
    return false;
  }
};

const distDir = path.resolve(__dirname, '..', '..', 'dist');

// React Router defaults to BrowserRouter against `window.location.pathname`,
// which for `vojo://app/...` would treat URL segments as routes (e.g.
// `vojo://app/index.html` resolved as a space alias `index.html`). Vojo
// already supports HashRouter via `clientConfig.hashRouter.enabled` in
// `config.json` — we override that to `true` for the Electron renderer so
// every route lives in `window.location.hash`, leaving the pathname stable
// for the protocol handler. The web/Android bundles see the unmodified
// config (hash router off).
const patchConfigForElectron = (raw: string): string => {
  try {
    const config: Record<string, unknown> = JSON.parse(raw);
    const existing = (config.hashRouter as { basename?: string } | undefined) ?? {};
    config.hashRouter = {
      enabled: true,
      basename: typeof existing.basename === 'string' ? existing.basename : '/',
    };
    return JSON.stringify(config);
  } catch {
    return raw;
  }
};

const registerAppProtocol = () => {
  protocol.handle(APP_SCHEME, async (request) => {
    let url: URL;
    try {
      url = new URL(request.url);
    } catch {
      return new Response(null, { status: 400 });
    }
    // Reject foreign origins under the same scheme. `protocol.handle` does
    // not validate the URL host, so without this check a renderer-side
    // `window.location = 'vojo://evil/...'` would resolve into a separate
    // (cookie/SW/IndexedDB-isolated) copy of the bundle — same content but
    // detached storage. Only `vojo://app` is accepted.
    if (url.hostname !== APP_HOST) {
      return new Response(null, { status: 403 });
    }
    const rel = (url.pathname || '/').replace(/^\/+/, '') || 'index.html';
    let filePath = path.normalize(path.join(distDir, rel));

    // Path-traversal guard. `filePath.startsWith(distDir)` is unsafe — a
    // sibling directory `/a/dist_evil` passes the prefix check against
    // `/a/dist`. `path.relative` yields `..`-leading or absolute output
    // for paths escaping `distDir`, which is the canonical Node check.
    let relFromDist = path.relative(distDir, filePath);
    if (relFromDist.startsWith('..') || path.isAbsolute(relFromDist)) {
      return new Response(null, { status: 403 });
    }

    // SPA fallback for paths without a real file on disk. Under HashRouter
    // (the Electron default — see `patchConfigForElectron`) reloads arrive
    // with pathname `/`, so this rarely fires; kept as a safety net for
    // manual URL edits and if someone ever reverts the HashRouter patch.
    // We MUST NOT gate via `path.extname() !== ''` — Matrix room/user IDs
    // like `!foo:vojo.chat` parse as having extension `.chat` and would
    // wrongly 404. Use a narrow allowlist of real web-asset extensions.
    if (!existsSync(filePath)) {
      const ext = path.extname(rel).toLowerCase();
      if (ext !== '' && WEB_ASSET_EXTENSIONS.has(ext)) {
        return new Response('Not Found', { status: 404 });
      }
      filePath = path.join(distDir, 'index.html');
      relFromDist = 'index.html';
    }

    // Override top-level `config.json` to force HashRouter on in Electron.
    // Exact-match via `path.relative` is Windows-safe (case + separator
    // normalization), unlike `path.dirname(filePath) === distDir`.
    if (relFromDist === 'config.json') {
      const raw = await fsp.readFile(filePath, 'utf-8');
      return new Response(patchConfigForElectron(raw), {
        headers: { 'Content-Type': 'application/json; charset=utf-8' },
      });
    }

    return net.fetch(pathToFileURL(filePath).toString());
  });
};

const createWindow = async () => {
  const win = new BrowserWindow({
    width: 1280,
    height: 800,
    minWidth: 640,
    minHeight: 480,
    backgroundColor: '#0d0e11',
    autoHideMenuBar: true,
    // Native-looking chrome: Windows draws real min/max/close buttons via
    // Window Controls Overlay, the rest of the bar matches our Dawn palette.
    // Same pattern as Discord/Slack/VS Code/Element Desktop. On macOS
    // `hiddenInset` keeps traffic lights but inset over a clean dark area.
    // Linux keeps the system frame to avoid breaking GTK-decoration UX.
    ...(process.platform === 'win32' && {
      titleBarStyle: 'hidden' as const,
      titleBarOverlay: {
        color: '#0d0e11',
        symbolColor: '#e0e0e8',
        height: 32,
      },
    }),
    ...(process.platform === 'darwin' && {
      titleBarStyle: 'hiddenInset' as const,
    }),
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      // sandbox: true is safe here — preload uses only `contextBridge` and
      // `ipcRenderer`, both of which are exposed inside the Electron sandbox
      // per https://www.electronjs.org/docs/latest/tutorial/sandbox.
      sandbox: true,
    },
  });

  // With `titleBarStyle: 'hidden'` Windows draws the min/max/close buttons
  // but the rest of the bar is the renderer's pixel area — and Electron does
  // NOT mark it draggable automatically. Inject a CSS drag-region overlay
  // sized by the Window Controls Overlay API env() variables (the canonical
  // way Microsoft / Chromium expose the safe drag area excluding the OS
  // button strip). Body gets matching padding-top so content shifts down
  // by 32px instead of sitting under the drag region.
  if (process.platform === 'win32') {
    win.webContents.on('dom-ready', () => {
      win.webContents.insertCSS(`
        body { padding-top: env(titlebar-area-height, 32px) !important; }
        body::before {
          content: "";
          position: fixed;
          top: env(titlebar-area-y, 0);
          left: env(titlebar-area-x, 0);
          width: env(titlebar-area-width, calc(100vw - 138px));
          height: env(titlebar-area-height, 32px);
          -webkit-app-region: drag;
          z-index: 2147483647;
        }
      `);
    });
  }

  win.webContents.setWindowOpenHandler(({ url }) => {
    if (isSafeExternal(url)) shell.openExternal(url);
    return { action: 'deny' };
  });

  win.webContents.on('will-navigate', (event, url) => {
    // Strict match for the in-app origin. `target.protocol === 'vojo:'` alone
    // would treat `vojo://evil/...` as internal even though the protocol
    // handler now rejects it; preventing navigation upstream avoids the
    // round-trip and keeps the renderer pinned to the canonical origin.
    if (isInternalUrl(url)) return;
    event.preventDefault();
    if (isSafeExternal(url)) shell.openExternal(url);
  });

  // Electron auto-approves EVERY permission request unless a handler is set.
  // Deny by default and allow only what the app legitimately needs (media for
  // calls, notifications) and only from the in-app origin — otherwise a DOM XSS
  // in the renderer could silently call getUserMedia/geolocation with no consent
  // prompt (unlike the browser build).
  const ALLOWED_PERMISSIONS = new Set(['media', 'notifications']);
  const { session } = win.webContents;
  session.setPermissionRequestHandler((_wc, permission, callback, details) => {
    callback(isInternalUrl(details.requestingUrl) && ALLOWED_PERMISSIONS.has(permission));
  });
  session.setPermissionCheckHandler(
    (_wc, permission, requestingOrigin) =>
      isInternalUrl(requestingOrigin) && ALLOWED_PERMISSIONS.has(permission)
  );

  if (isDev) {
    await win.loadURL(DEV_URL);
    win.webContents.openDevTools({ mode: 'detach' });
  } else {
    await win.loadURL(`${APP_SCHEME}://${APP_HOST}/`);
  }
};

app.whenReady().then(() => {
  if (!isDev) registerAppProtocol();

  ipcMain.handle('vojo:open-external', async (_event, url: unknown) => {
    if (isSafeExternal(url)) await shell.openExternal(url);
  });

  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
