/**
 * SQLite session store TTL behaviour (the "sessions disappear after a fixed
 * time" fix).
 *
 * Regression guards:
 *  - set() writes an `expired` ≈ now + cookie.maxAge (ms), NOT now + maxAge*1000.
 *    The old code re-scaled the already-millisecond maxAge by 1000.
 *  - with no cookie.maxAge, set()/touch() fall back to DEFAULT_SESSION_MAX_AGE_MS
 *    (30d), not a 1-day hard expiry.
 *  - touch() slides the expiry forward (rolling sessions).
 *  - get() deletes and returns null for an already-expired row.
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, existsSync, statSync, writeFileSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../db/repository.js';
import {
  createSqliteSessionStore,
  DEFAULT_SESSION_MAX_AGE_MS,
  setupAuth,
  loadOrCreateSessionSecret,
} from './auth.js';
import type { AuthConfig } from '../config.js';
import type { SessionData } from 'express-session';

function makeStore() {
  const repo = new Repository(':memory:');
  return { repo, store: createSqliteSessionStore(repo.getDb()) };
}

function sess(maxAge?: number): SessionData {
  return { cookie: { originalMaxAge: maxAge ?? null, maxAge } } as unknown as SessionData;
}

function readExpired(repo: Repository, sid: string): number {
  const row = repo.getDb().prepare('SELECT expired FROM sessions WHERE sid = ?').get(sid) as
    | { expired: string }
    | undefined;
  return row ? new Date(row.expired).getTime() : NaN;
}

describe('SQLite session store TTL', () => {
  let repo: Repository;
  let store: ReturnType<typeof createSqliteSessionStore>;
  beforeEach(() => {
    ({ repo, store } = makeStore());
  });

  it('set() expiry tracks cookie.maxAge in milliseconds (no ×1000 re-scale)', async () => {
    const oneHour = 60 * 60 * 1000;
    const before = Date.now();
    await new Promise<void>((r) => store.set('s1', sess(oneHour), () => r()));
    const expired = readExpired(repo, 's1');
    // Should be ~1h out, definitely under a day. The old bug put it 1000h out.
    expect(expired - before).toBeGreaterThan(oneHour - 5_000);
    expect(expired - before).toBeLessThan(oneHour + 60_000);
  });

  it('set() with no maxAge falls back to the 30-day default, not 1 day', async () => {
    const before = Date.now();
    await new Promise<void>((r) => store.set('s2', sess(undefined), () => r()));
    const expired = readExpired(repo, 's2');
    expect(expired - before).toBeGreaterThan(DEFAULT_SESSION_MAX_AGE_MS - 60_000);
    expect(expired - before).toBeLessThan(DEFAULT_SESSION_MAX_AGE_MS + 60_000);
  });

  it('touch() slides the expiry forward', async () => {
    // Seed a row that expires soon.
    repo.getDb()
      .prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)')
      .run('s3', JSON.stringify(sess(1000)), new Date(Date.now() + 1000).toISOString());
    const before = readExpired(repo, 's3');
    const oneHour = 60 * 60 * 1000;
    await new Promise<void>((r) => store.touch('s3', sess(oneHour), () => r()));
    const after = readExpired(repo, 's3');
    expect(after).toBeGreaterThan(before);
    expect(after - Date.now()).toBeGreaterThan(oneHour - 5_000);
  });

  it('get() deletes and returns null for an expired row', async () => {
    repo.getDb()
      .prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)')
      .run('s4', JSON.stringify(sess(1000)), new Date(Date.now() - 1000).toISOString());
    const got = await new Promise((r) => store.get('s4', (_e, s) => r(s)));
    expect(got).toBeNull();
    expect(repo.getDb().prepare('SELECT 1 FROM sessions WHERE sid = ?').get('s4')).toBeUndefined();
  });

  it('get() returns the stored session for a live row', async () => {
    await new Promise<void>((r) => store.set('s5', sess(60 * 60 * 1000), () => r()));
    const got = (await new Promise((r) => store.get('s5', (_e, s) => r(s)))) as SessionData | null;
    expect(got).not.toBeNull();
    expect(got?.cookie).toBeTruthy();
  });
});

// ── Middleware wiring: rolling + maxAge fallback ──────────────────────────────

/** Seconds until the session cookie expires, from Max-Age or Expires (express-
 *  session serializes the lifetime as an `Expires=<date>`, not Max-Age). */
function cookieTtlSec(setCookie: string | string[] | undefined): number | null {
  const header = Array.isArray(setCookie) ? setCookie.join('\n') : (setCookie ?? '');
  const ma = /Max-Age=(\d+)/i.exec(header);
  if (ma) return Number(ma[1]);
  const ex = /Expires=([^;]+)/i.exec(header);
  if (ex) {
    const t = Date.parse(ex[1]);
    if (!Number.isNaN(t)) return Math.round((t - Date.now()) / 1000);
  }
  return null;
}

function appFor(cfg: Partial<AuthConfig>) {
  const repo = new Repository(':memory:');
  const full: AuthConfig = {
    sessionSecret: 'test-secret', secureCookie: false, adminEmails: [], providers: {},
    ...cfg,
  } as AuthConfig;
  const { sessionMiddleware } = setupAuth(repo, full);
  const app = express();
  app.use(sessionMiddleware);
  // /bump modifies the session (forces a save); /ping leaves it untouched.
  app.get('/bump', (req, res) => {
    (req.session as unknown as { n: number }).n = ((req.session as unknown as { n?: number }).n ?? 0) + 1;
    res.json({ ok: true });
  });
  app.get('/ping', (_req, res) => res.json({ ok: true }));
  return app;
}

describe('session middleware wiring', () => {
  it('issues a Max-Age ≈ 30 days when session_max_age is unset', async () => {
    const app = appFor({ sessionMaxAge: undefined });
    const res = await request(app).get('/bump');
    const sec = cookieTtlSec(res.headers['set-cookie']);
    expect(sec).not.toBeNull();
    // 30d = 2_592_000s. express-session rounds; allow a minute of slack.
    expect(Math.abs((sec as number) - DEFAULT_SESSION_MAX_AGE_MS / 1000)).toBeLessThan(60);
  });

  it('re-issues the cookie on an unmodified request (rolling: true)', async () => {
    const app = appFor({ sessionMaxAge: 60 * 60 * 1000 }); // 1h
    const agent = request.agent(app);
    const first = await agent.get('/bump'); // establishes the session
    expect(first.headers['set-cookie']).toBeTruthy();
    // A request that does NOT touch the session must still get a fresh cookie
    // because rolling re-issues it every response (sliding expiry).
    const second = await agent.get('/ping');
    expect(second.headers['set-cookie']).toBeTruthy();
    expect(cookieTtlSec(second.headers['set-cookie'])).toBeGreaterThan(0);
  });
});

// ── Persistent session secret (survives restarts) ────────────────────────────

describe('loadOrCreateSessionSecret', () => {
  let dir: string;
  beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sess-secret-')); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('generates, persists (0600), and reuses the same secret', () => {
    const p = join(dir, 'nested', 'session-secret.key');
    const first = loadOrCreateSessionSecret(p);
    expect(first).toMatch(/^[0-9a-f]{64}$/); // 32 bytes hex
    expect(existsSync(p)).toBe(true);
    expect(statSync(p).mode & 0o777).toBe(0o600);
    // A second call (≈ a restart) must return the SAME secret, not a new one.
    expect(loadOrCreateSessionSecret(p)).toBe(first);
  });

  it('honours an existing secret file verbatim', () => {
    const p = join(dir, 'session-secret.key');
    writeFileSync(p, 'pre-existing-secret\n');
    expect(loadOrCreateSessionSecret(p)).toBe('pre-existing-secret'); // trimmed
    expect(readFileSync(p, 'utf8')).toBe('pre-existing-secret\n'); // unchanged
  });

  it('regenerates over an empty/blank file', () => {
    const p = join(dir, 'session-secret.key');
    writeFileSync(p, '   \n'); // blank
    const secret = loadOrCreateSessionSecret(p);
    expect(secret).toMatch(/^[0-9a-f]{64}$/);
    expect(readFileSync(p, 'utf8').trim()).toBe(secret); // overwritten on disk
  });

  it('falls back to a random secret (no throw) when the path is unwritable', () => {
    const blocker = join(dir, 'blocker'); // a regular file …
    writeFileSync(blocker, 'x');
    const p = join(blocker, 'session-secret.key'); // … used as a parent dir → ENOTDIR
    const secret = loadOrCreateSessionSecret(p);
    expect(secret).toMatch(/^[0-9a-f]{64}$/); // random in-memory fallback
    expect(existsSync(p)).toBe(false); // nothing persisted
  });
});

describe('setupAuth persistent secret wiring', () => {
  let dir: string;
  beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sess-auth-')); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('a session signed before a "restart" is still valid after, when session_secret is unset', async () => {
    const dbPath = join(dir, 'db.sqlite');
    const secretPath = join(dir, 'session-secret.key');
    const cfg = { sessionSecret: '', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig;

    function bootApp() {
      const repo = new Repository(dbPath); // same on-disk store across "restarts"
      const { sessionMiddleware } = setupAuth(repo, cfg, undefined, secretPath);
      const app = express();
      app.use(sessionMiddleware);
      app.get('/bump', (req, res) => {
        (req.session as unknown as { n: number }).n = 7;
        res.json({ ok: true });
      });
      app.get('/read', (req, res) => res.json({ n: (req.session as unknown as { n?: number }).n ?? null }));
      return app;
    }

    const before = bootApp();
    const res1 = await request(before).get('/bump'); // signs a session cookie
    const cookie = res1.headers['set-cookie'];
    expect(cookie).toBeTruthy();

    // Simulate a process restart: a fresh setupAuth re-reads the persisted
    // secret. With the old per-process random secret the cookie signature would
    // no longer verify and the session would be lost.
    const after = bootApp();
    const replay = await request(after).get('/read').set('Cookie', cookie);
    expect(replay.body.n).toBe(7);
  });

  it('does NOT touch the secret file when auth.session_secret is configured', () => {
    const secretPath = join(dir, 'session-secret.key');
    const repo = new Repository(':memory:');
    const cfg = { sessionSecret: 'a-configured-secret', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig;
    setupAuth(repo, cfg, undefined, secretPath);
    expect(existsSync(secretPath)).toBe(false); // configured secret wins; no persistence
  });

  it('treats a whitespace-only session_secret as unset (persists a real one)', () => {
    const secretPath = join(dir, 'session-secret.key');
    const repo = new Repository(':memory:');
    const cfg = { sessionSecret: '   ', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig;
    setupAuth(repo, cfg, undefined, secretPath);
    expect(existsSync(secretPath)).toBe(true); // blank → fell back to a persisted secret
  });
});
