/**
 * Tests for invite-store (DC-048).
 * Coverage:
 *   - issue returns raw token + id; token is 256-bit entropy
 *   - peek returns public-safe info without consuming
 *   - accept consumes + marks used, second accept returns already_used
 *   - expired token returns expired on accept
 *   - revoke removes by id
 *   - listOutstanding hides used/expired
 *   - peek returns null for unknown/used/expired (no enumeration)
 *   - token hash never leaves the store (only SHA-256 on disk)
 */

'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const { createInviteStore, DEFAULT_TTL_MS } = require('../src/security/invite-store');

function _tmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-invitetest-'));
}

function _cleanup(dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}

describe('invite-store: issue', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('issue returns raw token + id + email + role + expiresAt', async () => {
    const r = await store.issue({ email: 'a@x.com', role: 'operator', ttlMs: 60_000 });
    expect(r.ok).toBe(true);
    expect(r.id).toBeTruthy();
    expect(typeof r.token).toBe('string');
    expect(r.token.length).toBeGreaterThanOrEqual(40);
    expect(r.email).toBe('a@x.com');
    expect(r.role).toBe('operator');
    expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now());
  });

  test('token is base64url and has 256 bits of entropy', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    expect(r.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
    // 32 bytes encoded → 43 chars (no padding)
    expect(r.token.length).toBeGreaterThanOrEqual(42);
    expect(r.token.length).toBeLessThanOrEqual(44);
  });

  test('on-disk JSON contains hash, not raw token', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    const raw = fs.readFileSync(path.join(dir, 'invites.json'), 'utf8');
    expect(raw).not.toContain(r.token); // raw token never touches disk
    // hash is 64 hex chars
    expect(raw).toMatch(/[a-f0-9]{64}/);
  });

  test('two issues produce different tokens', async () => {
    const r1 = await store.issue({ email: 'a@x.com' });
    const r2 = await store.issue({ email: 'b@x.com' });
    expect(r1.token).not.toEqual(r2.token);
  });

  test('invalid email rejected', async () => {
    const r = await store.issue({ email: 'not-an-email' });
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('invalid_email');
  });
});

describe('invite-store: peek + accept', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('peek returns public-safe info', async () => {
    const r = await store.issue({ email: 'a@x.com', role: 'operator' });
    const p = await store.peek(r.token);
    expect(p).toBeTruthy();
    expect(p.email).toBe('a@x.com');
    expect(p.role).toBe('operator');
    expect(p.expiresAt).toBe(r.expiresAt);
  });

  test('peek does NOT consume the token', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    await store.peek(r.token);
    await store.peek(r.token);
    const accept = await store.accept(r.token);
    expect(accept.ok).toBe(true);
  });

  test('peek returns null for unknown token', async () => {
    const p = await store.peek('not-a-real-token');
    expect(p).toBe(null);
  });

  test('peek returns null for used token (no enumeration)', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    await store.accept(r.token);
    const p = await store.peek(r.token);
    expect(p).toBe(null);
  });

  test('peek returns null for expired token (no enumeration)', async () => {
    const r = await store.issue({ email: 'a@x.com', ttlMs: 1 });
    await new Promise(res => setTimeout(res, 10));
    const p = await store.peek(r.token);
    expect(p).toBe(null);
  });

  test('accept marks used + records accept time', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    const a = await store.accept(r.token, { acceptedBy: 'first@x.com' });
    expect(a.ok).toBe(true);
    expect(a.invite.usedAt).toBeTruthy();
    expect(a.invite.email).toBe('a@x.com');
  });

  test('accept returns already_used on second call', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    await store.accept(r.token);
    const second = await store.accept(r.token);
    expect(second.ok).toBe(false);
    expect(second.reason).toBe('already_used');
  });

  test('accept returns expired for TTL-passed token', async () => {
    const r = await store.issue({ email: 'a@x.com', ttlMs: 1 });
    await new Promise(res => setTimeout(res, 10));
    const a = await store.accept(r.token);
    expect(a.ok).toBe(false);
    expect(a.reason).toBe('expired');
  });

  test('accept returns not_found for unknown token', async () => {
    const a = await store.accept('not-real');
    expect(a.ok).toBe(false);
    expect(a.reason).toBe('not_found');
  });
});

describe('invite-store: revoke + listOutstanding', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('revoke removes an invite', async () => {
    const r = await store.issue({ email: 'a@x.com' });
    const rev = await store.revoke(r.id);
    expect(rev.ok).toBe(true);
    const peek = await store.peek(r.token);
    expect(peek).toBe(null);
  });

  test('revoke returns not_found for unknown id', async () => {
    const r = await store.revoke('not-an-id');
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('not_found');
  });

  test('listOutstanding excludes used + expired', async () => {
    const r1 = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
    const r2 = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
    const r3 = await store.issue({ email: 'c@x.com', ttlMs: 1 });
    await store.accept(r1.token); // used
    await new Promise(res => setTimeout(res, 10)); // expire r3

    const list = await store.listOutstanding();
    expect(list).toHaveLength(1);
    expect(list[0].id).toBe(r2.id);
    expect(list[0].email).toBe('b@x.com');
  });

  test('listOutstanding sorted by expiresAt', async () => {
    const early = await store.issue({ email: 'a@x.com', ttlMs: 1000 });
    const late = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
    const list = await store.listOutstanding();
    expect(list[0].id).toBe(early.id);
    expect(list[1].id).toBe(late.id);
  });
});

describe('invite-store: DEFAULT_TTL_MS', () => {
  test('default is 24 hours', () => {
    expect(DEFAULT_TTL_MS).toBe(24 * 60 * 60 * 1000);
  });
});