/**
 * Tests for share-store (DC-053).
 * Coverage:
 *   - issuePublic returns raw token + signature + service-bound metadata
 *   - issuePublic enforces 1h/24h/7d whitelist (other ttls snap to default)
 *   - issueTailscale returns token; service-bound + email-bound
 *   - peek returns public-safe info; signature verification rejects tampering
 *   - peek returns null for unknown/used/expired (no enumeration)
 *   - recordPublicSubscribe increments; caps; rejects expired
 *   - recordTailscaleUse is single-use
 *   - revoke removes by id
 *   - list returns outstanding only (used/expired auto-pruned)
 *   - listForService filters
 *   - signing secret persists across reopens
 *   - dataDir resolver falls back to /tmp when given function/Proxy values
 */

'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { createShareStore } = require('../src/security/share-store');

function _tmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-sharetest-'));
}
function _cleanup(dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

describe('share-store: issuePublic', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('returns raw token + id + serviceId + expiresAt + urlPath', async () => {
    const r = await store.issuePublic({ serviceId: 'plex', ttlMs: 60 * 60 * 1000, createdBy: 'admin@x.com' });
    expect(r.ok).toBe(true);
    expect(r.id).toBeTruthy();
    expect(r.token.length).toBeGreaterThanOrEqual(40);
    expect(r.signature.length).toBeGreaterThan(20);
    expect(r.serviceId).toBe('plex');
    expect(r.kind).toBe('public');
    expect(r.urlPath).toBe(`/share/${r.token}`);
    expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now());
  });

  test('rejects missing serviceId', async () => {
    const r = await store.issuePublic({ serviceId: '' });
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('invalid_service');
  });

  test('snaps unsupported TTLs to default (24h)', async () => {
    const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 999999 });
    expect(r.ok).toBe(true);
    // default is 24h
    const diff = new Date(r.expiresAt).getTime() - Date.now();
    expect(diff).toBeGreaterThan(23 * 60 * 60 * 1000);
    expect(diff).toBeLessThan(25 * 60 * 60 * 1000);
  });

  test('allows exactly 1h, 24h, 7d', async () => {
    for (const ttl of [3_600_000, 86_400_000, 604_800_000]) {
      const r = await store.issuePublic({ serviceId: 'svc', ttlMs: ttl });
      expect(r.ttlMs).toBe(ttl);
    }
  });

  test('subscribeCap clamps to range', async () => {
    const r1 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 0 });
    expect(r1.ok).toBe(true);
    // 0 -> default
    const meta1 = await store.peek(r1.token);
    expect(meta1.subscribeCap).toBeGreaterThan(0);

    const r2 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 50 });
    expect((await store.peek(r2.token)).subscribeCap).toBe(50);

    const r3 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 999999 });
    expect((await store.peek(r3.token)).subscribeCap).toBe(10000); // clamped
  });
});

describe('share-store: issueTailscale', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('returns raw token + email + service-bound metadata', async () => {
    const r = await store.issueTailscale({
      serviceId: 'jellyfin',
      email: 'Friend@Example.COM',
      ttlMs: 24 * 60 * 60 * 1000,
    });
    expect(r.ok).toBe(true);
    expect(r.email).toBe('friend@example.com'); // normalized lowercase
    expect(r.serviceId).toBe('jellyfin');
    expect(r.kind).toBe('tailscale');
  });

  test('rejects missing email', async () => {
    const r = await store.issueTailscale({ serviceId: 'svc', email: 'nope' });
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('invalid_email');
  });

  test('rejects missing serviceId', async () => {
    const r = await store.issueTailscale({ serviceId: '', email: 'a@b.com' });
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('invalid_service');
  });

  test('clamps TTL to 24h max', async () => {
    const r = await store.issueTailscale({
      serviceId: 'svc',
      email: 'a@b.com',
      ttlMs: 30 * 24 * 60 * 60 * 1000, // 30d
    });
    expect(r.ok).toBe(true);
    const diff = new Date(r.expiresAt).getTime() - Date.now();
    expect(diff).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 100);
  });
});

describe('share-store: peek', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('returns public-safe metadata for a fresh public share', async () => {
    const issued = await store.issuePublic({ serviceId: 'plex' });
    const meta = await store.peek(issued.token);
    expect(meta).toMatchObject({
      kind: 'public',
      serviceId: 'plex',
      usedAt: null,
    });
    expect(meta.expiresAt).toBeTruthy();
  });

  test('returns null for unknown token (no enumeration)', async () => {
    expect(await store.peek('nope')).toBeNull();
    expect(await store.peek('')).toBeNull();
    expect(await store.peek(null)).toBeNull();
  });

  test('returns null for expired token', async () => {
    const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
    // tamper: backdate the expiresAt via direct file write
    const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
    const id = Object.keys(data.shares)[0];
    data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString();
    fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data));
    expect(await store.peek(issued.token)).toBeNull();
  });

  test('rejects tampered signature', async () => {
    const issued = await store.issuePublic({ serviceId: 'svc' });
    const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
    const id = Object.keys(data.shares)[0];
    data.shares[id].serviceId = 'attacker-controlled-svc'; // tamper the serviceId
    data.shares[id].signature = 'tampered' + 'x'.repeat(40);
    fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data));
    expect(await store.peek(issued.token)).toBeNull();
  });
});

describe('share-store: recordPublicSubscribe', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('increments count up to cap, then rejects with cap_reached', async () => {
    const issued = await store.issuePublic({ serviceId: 'svc', subscribeCap: 3 });
    for (let i = 1; i <= 3; i++) {
      const r = await store.recordPublicSubscribe(issued.token);
      expect(r.ok).toBe(true);
      expect(r.count).toBe(i);
    }
    const blocked = await store.recordPublicSubscribe(issued.token);
    expect(blocked.ok).toBe(false);
    expect(blocked.reason).toBe('cap_reached');
  });

  test('rejects when token unknown', async () => {
    const r = await store.recordPublicSubscribe('unknown-token');
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('not_found');
  });

  test('rejects when wrong kind (Tailscale)', async () => {
    const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
    const r = await store.recordPublicSubscribe(issued.token);
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('wrong_kind');
  });

  test('rejects when expired', async () => {
    const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
    const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
    const id = Object.keys(data.shares)[0];
    data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString();
    fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data));
    const r = await store.recordPublicSubscribe(issued.token);
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('expired');
  });
});

describe('share-store: recordTailscaleUse', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('marks used on first redemption; second returns already_used', async () => {
    const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
    const r1 = await store.recordTailscaleUse(issued.token, { deviceId: 'device-xyz' });
    expect(r1.ok).toBe(true);
    expect(r1.share.usedAt).toBeTruthy();
    expect(r1.share.usedBy).toBe('device-xyz');

    const r2 = await store.recordTailscaleUse(issued.token, { deviceId: 'other' });
    expect(r2.ok).toBe(false);
    expect(r2.reason).toBe('already_used');
  });

  test('rejects wrong kind (public)', async () => {
    const issued = await store.issuePublic({ serviceId: 'svc' });
    const r = await store.recordTailscaleUse(issued.token, { deviceId: 'd' });
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('wrong_kind');
  });

  test('rejects unknown token', async () => {
    const r = await store.recordTailscaleUse('nope', { deviceId: 'd' });
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('not_found');
  });
});

describe('share-store: revoke + list + listForService', () => {
  let dir, store;
  beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('revoke removes by id', async () => {
    const a = await store.issuePublic({ serviceId: 'svc-a' });
    const b = await store.issuePublic({ serviceId: 'svc-b' });
    expect(await store.revoke(a.id)).toBe(true);
    expect(await store.peek(a.token)).toBeNull();
    expect(await store.peek(b.token)).not.toBeNull();
  });

  test('revoke returns false for unknown id', async () => {
    expect(await store.revoke('nope')).toBe(false);
  });

  test('list returns outstanding only', async () => {
    await store.issuePublic({ serviceId: 'svc' });
    const t = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
    await store.recordTailscaleUse(t.token, { deviceId: 'd' });
    const all = await store.list();
    // Tailscale record is terminal (used), pruned; 1 public remains
    expect(all).toHaveLength(1);
    expect(all[0].kind).toBe('public');
  });

  test('listForService filters', async () => {
    await store.issuePublic({ serviceId: 'svc-a' });
    await store.issuePublic({ serviceId: 'svc-b' });
    await store.issueTailscale({ serviceId: 'svc-a', email: 'a@b.com' });
    const aShares = await store.listForService('svc-a');
    expect(aShares).toHaveLength(2);
    expect(aShares.every(s => s.serviceId === 'svc-a')).toBe(true);
  });
});

describe('share-store: signing secret persistence + defensive dataDir', () => {
  test('signing secret persists across reopens', async () => {
    const dir = _tmpDir();
    try {
      const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' });
      const b = await createShareStore({ dataDir: dir }).peek(a.token);
      expect(b).not.toBeNull(); // same secret, signature still valid
    } finally { _cleanup(dir); }
  });

  test('falls back to os.tmpdir() when dataDir is missing/function/Proxy', () => {
    // function value (test-proxy scenario)
    const fn = () => '/should/not/throw';
    const proxy = new Proxy({ dataDir: '/x' }, { get: () => fn });
    const s = createShareStore({ dataDir: proxy });
    expect(typeof s.issuePublic).toBe('function');
    // Should not throw on construction
    expect(s._file).toContain('shares.json');
  });

  test('opts.signingSecret overrides persisted secret', async () => {
    const dir = _tmpDir();
    try {
      const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' });
      // Reopen with a DIFFERENT secret — peek should fail (signature mismatch).
      const reopen = createShareStore({ dataDir: dir, signingSecret: 'different-secret-' + 'x'.repeat(40) });
      const b = await reopen.peek(a.token);
      expect(b).toBeNull();
    } finally { _cleanup(dir); }
  });
});