'use strict';

const fsp = require('node:fs/promises');
const path = require('node:path');
const zlib = require('node:zlib');
const { URL } = require('node:url');

const { send, json, readJson, safeName, mimeByExt } = require('./http');
const auth = require('./auth');
const covers = require('./covers');
const sync = require('./sync');

const ROOT = path.resolve(__dirname, '..');
const APP_DIR = path.join(ROOT, 'app');
const APP_HTML = path.join(APP_DIR, 'BangumiVault.html');
const JS_DIR = path.join(APP_DIR, 'js');
const CSS_DIR = path.join(APP_DIR, 'css');
const FONTS_DIR = path.join(APP_DIR, 'fonts');

function createHandler(store) {
  let publicModeCache = null;
  // Version-keyed cache of the client state (plain + gzip), so repeated refreshes without any
  // data change serve a 304 / cached bytes instead of re-stripping+re-gzipping the big JSON.
  // It's also pre-warmed in the background (startup + after writes) so the build cost stays off
  // the request path and even the first load after a change is fast.
  let stateCache = null; // { version, plain, gzip, etag }
  let warming = null;
  async function buildStateCache(version) {
    const state = await store.getClientState();
    if (!state) { stateCache = null; return null; }
    const plain = Buffer.from(JSON.stringify(state), 'utf8');
    stateCache = { version, plain, gzip: zlib.gzipSync(plain), etag: `W/"st-${version}"` };
    return stateCache;
  }
  function ensureWarm() {
    const v = store.getStateVersion();
    if ((stateCache && stateCache.version === v) || warming) return;
    warming = buildStateCache(v).catch(() => {}).finally(() => { warming = null; });
  }
  const warmTimer = setInterval(ensureWarm, 2000);
  if (warmTimer.unref) warmTimer.unref();
  ensureWarm(); // pre-warm at startup
  async function getPublicMode() {
    if (publicModeCache === null) {
      publicModeCache = (await store.getSetting('public_mode')) === 'true';
    }
    return publicModeCache;
  }

  // Serve a file with conditional caching (ETag from size+mtime). Returns 304 when unchanged.
  async function serveFileCached(req, res, target, cacheControl) {
    try {
      const st = await fsp.stat(target);
      const etag = `W/"${st.size}-${Math.floor(st.mtimeMs)}"`;
      if (req.headers['if-none-match'] === etag) {
        res.writeHead(304, { 'ETag': etag, 'Cache-Control': cacheControl });
        res.end();
        return;
      }
      const buf = await fsp.readFile(target);
      send(res, 200, buf, mimeByExt(target), {
        'ETag': etag,
        'Last-Modified': st.mtime.toUTCString(),
        'Cache-Control': cacheControl
      });
    } catch {
      send(res, 404, 'Not found');
    }
  }

  async function serveStatic(req, res, dir, name) {
    const file = safeName(path.basename(name), '');
    if (!file) { send(res, 404, 'Not found'); return; }
    await serveFileCached(req, res, path.join(dir, file), 'no-cache');
  }

  return async function handleRequest(req, res) {
    res._acceptGzip = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
    const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
    const pathname = decodeURIComponent(requestUrl.pathname);

    // --- public assets (no auth) ---
    if (req.method === 'GET' && (pathname === '/' || pathname === '/BangumiVault.html')) {
      await serveFileCached(req, res, APP_HTML, 'no-cache');
      return;
    }
    if (req.method === 'GET' && pathname.startsWith('/js/')) {
      await serveStatic(req, res, JS_DIR, pathname.slice('/js/'.length));
      return;
    }
    if (req.method === 'GET' && pathname.startsWith('/css/')) {
      await serveStatic(req, res, CSS_DIR, pathname.slice('/css/'.length));
      return;
    }
    // Self-hosted web fonts (public, immutable). Allows the /fonts/<dir>/files/<name> subpaths.
    if (req.method === 'GET' && pathname.startsWith('/fonts/')) {
      const target = path.normalize(path.join(FONTS_DIR, pathname.slice('/fonts/'.length)));
      if (target !== FONTS_DIR && !target.startsWith(FONTS_DIR + path.sep)) { send(res, 404, 'Not found'); return; }
      await serveFileCached(req, res, target, 'public, max-age=31536000, immutable');
      return;
    }

    if (req.method === 'GET' && pathname === '/api/session') {
      const pm = await getPublicMode();
      const psn = (await store.getSetting('public_show_nsfw')) === 'true';
      json(res, 200, { authenticated: auth.isAuthed(req), publicMode: pm, publicShowNsfw: psn });
      return;
    }

    if (req.method === 'POST' && pathname === '/api/login') {
      const body = await readJson(req);
      if (!auth.verifyPassword(body.password || '', await store.getSetting('admin_password_hash'))) {
        json(res, 401, { ok: false, error: 'invalid password' });
        return;
      }
      json(res, 200, { ok: true }, {
        'Set-Cookie': `bv_session=${encodeURIComponent(auth.createSessionCookie())}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(auth.SESSION_TTL_MS / 1000)}`
      });
      return;
    }

    if (req.method === 'POST' && pathname === '/api/logout') {
      json(res, 200, { ok: true }, { 'Set-Cookie': 'bv_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0' });
      return;
    }

    if (req.method === 'GET' && pathname === '/api/ping') {
      json(res, 200, { ok: true, docker: true, database: store.kind, filesDir: covers.FILES_DIR });
      return;
    }

    // --- auth gate (public mode allows read of state + images) ---
    if ((pathname.startsWith('/api/') || pathname.startsWith('/images/')) && !auth.isAuthed(req)) {
      const pm = await getPublicMode();
      const readOk = pm && (
        (req.method === 'GET' && pathname === '/api/state') ||
        (req.method === 'GET' && pathname === '/api/history' && requestUrl.searchParams.get('subject_id')) ||
        (req.method === 'GET' && pathname === '/api/subject-meta' && requestUrl.searchParams.get('subject_id')) ||
        (req.method === 'GET' && pathname === '/api/infoboxes') ||
        (req.method === 'GET' && pathname.startsWith('/images/'))
      );
      if (!readOk) {
        json(res, 401, { ok: false, error: 'unauthorized' });
        return;
      }
    }

    if (req.method === 'POST' && pathname === '/api/admin/password') {
      const body = await readJson(req);
      const currentPassword = String(body.currentPassword || '');
      const newPassword = String(body.newPassword || '');
      if (!auth.verifyPassword(currentPassword, await store.getSetting('admin_password_hash'))) {
        json(res, 400, { ok: false, error: 'current password is incorrect' });
        return;
      }
      if (newPassword.length < 10) {
        json(res, 400, { ok: false, error: 'new password must be at least 10 characters' });
        return;
      }
      await store.setSetting('admin_password_hash', auth.hashPassword(newPassword));
      json(res, 200, { ok: true });
      return;
    }

    if (req.method === 'POST' && pathname === '/api/admin/public-mode') {
      const body = await readJson(req);
      const enabled = !!body.enabled;
      const showNsfw = !!body.showNsfw;
      await store.setSetting('public_mode', String(enabled));
      await store.setSetting('public_show_nsfw', String(showNsfw));
      publicModeCache = enabled;
      json(res, 200, { ok: true, publicMode: enabled, publicShowNsfw: showNsfw });
      return;
    }

    if (req.method === 'GET' && pathname === '/api/state') {
      // History is excluded here (lazy-loaded per subject) to keep this payload small.
      const version = store.getStateVersion();
      if (!stateCache || stateCache.version !== version) {
        if (warming) { await warming; }
        if (!stateCache || stateCache.version !== version) { await buildStateCache(version); }
      }
      if (!stateCache) { send(res, 204, '', 'application/json; charset=utf-8'); return; }
      const c = stateCache;
      if (req.headers['if-none-match'] === c.etag) {
        res.writeHead(304, { 'ETag': c.etag, 'Cache-Control': 'no-cache', 'Vary': 'Accept-Encoding' });
        res.end();
        return;
      }
      const useGz = res._acceptGzip;
      const body = useGz ? c.gzip : c.plain;
      const headers = {
        'Content-Type': 'application/json; charset=utf-8',
        'Content-Length': body.length,
        'Cache-Control': 'no-cache',
        'ETag': c.etag,
        'Vary': 'Accept-Encoding'
      };
      if (useGz) headers['Content-Encoding'] = 'gzip';
      res.writeHead(200, headers);
      res.end(body);
      return;
    }

    if (req.method === 'GET' && pathname === '/api/history') {
      const subjectId = requestUrl.searchParams.get('subject_id');
      const history = subjectId ? await store.getSubjectHistory(subjectId) : await store.getAllHistory();
      send(res, 200, JSON.stringify({ history }), 'application/json; charset=utf-8');
      return;
    }

    // Subject infobox (lazy-loaded by the detail drawer; stripped from /api/state for payload size).
    if (req.method === 'GET' && pathname === '/api/subject-meta') {
      const subjectId = requestUrl.searchParams.get('subject_id');
      const infobox = subjectId ? await store.getSubjectInfobox(subjectId) : [];
      send(res, 200, JSON.stringify({ infobox }), 'application/json; charset=utf-8');
      return;
    }

    // All infoboxes as a map (background-prefetched by the client so drawers show 条目资料 instantly).
    if (req.method === 'GET' && pathname === '/api/infoboxes') {
      send(res, 200, JSON.stringify({ infoboxes: await store.getAllInfoboxes() }), 'application/json; charset=utf-8');
      return;
    }

    // Full replace — backup import only.
    if (req.method === 'POST' && pathname === '/api/state') {
      await store.persistState(await readJson(req));
      json(res, 200, { ok: true });
      return;
    }

    // Merge partial settings without touching collections.
    if (req.method === 'POST' && pathname === '/api/settings') {
      const body = await readJson(req);
      await store.setStateSettings(body.settings || {});
      json(res, 200, { ok: true });
      return;
    }

    // Update a single collection's status fields (safety box).
    if (req.method === 'POST' && pathname === '/api/collections/status') {
      const body = await readJson(req);
      if (!body.subject_id) { json(res, 400, { ok: false, error: 'missing subject_id' }); return; }
      const patch = {};
      if (body.status !== undefined) patch.status = body.status;
      if (body.missing_count !== undefined) patch.missing_count = body.missing_count;
      const merged = await store.patchCollection(body.subject_id, patch);
      json(res, merged ? 200 : 404, merged ? { ok: true, collection: merged } : { ok: false, error: 'not found' });
      return;
    }

    if (req.method === 'POST' && pathname === '/api/sync') {
      const body = await readJson(req);
      const mode = body.mode === 'full' ? 'full' : 'recent';
      const opts = {};
      if (body.recentMonths !== undefined) opts.recentMonths = Math.max(1, Math.min(120, Number(body.recentMonths) || 3));
      if (body.cacheCovers !== undefined) opts.cacheCovers = !!body.cacheCovers;
      if (body.refreshSubjects !== undefined) opts.refreshSubjects = !!body.refreshSubjects;
      const started = sync.startSync(store, mode, 'manual', opts);
      if (started) json(res, 200, { ok: true, started: true });
      else json(res, 409, { ok: false, started: false, error: 'sync already running' });
      return;
    }

    if (req.method === 'GET' && pathname === '/api/sync/status') {
      json(res, 200, sync.getStatus());
      return;
    }

    if (req.method === 'POST' && pathname === '/api/cache-cover') {
      const body = await readJson(req);
      const remoteUrl = String(body.url || '').trim();
      if (!body.subject_id || !remoteUrl) {
        json(res, 400, { ok: false, error: 'missing subject_id or url' });
        return;
      }
      const r = await covers.cacheCover(body.subject_id, remoteUrl);
      json(res, 200, { ok: true, url: r.url, file: r.file });
      return;
    }

    if (req.method === 'GET' && pathname.startsWith('/images/')) {
      const file = safeName(path.basename(pathname.slice('/images/'.length)), 'image.jpg');
      // Covers are effectively immutable per subject; cache for a day so refreshes don't re-download.
      await serveFileCached(req, res, path.join(covers.COVERS_DIR, file), 'public, max-age=86400');
      return;
    }

    send(res, 404, 'Not found');
  };
}

module.exports = { createHandler };
