/**
 * Tests for share routes (DC-053) — public share + Tailscale-mediated share.
 * Coverage:
 *   - GET /share/:token/preview is public, returns snapshot
 *   - POST /share requires admin (401/403 without user)
 *   - POST /share requires Pro tier (402 PaymentRequired when Free)
 *   - POST /share issues a public share, returns token + urlPath
 *   - POST /share rejects unknown serviceId with 404
 *   - POST /share snaps unsupported TTLs
 *   - POST /share/tailscale requires Tailscale configured
 *   - POST /share/tailscale mints auth key + records share + emails invitee
 *   - POST /share/tailscale rolls back share when createAuthKey throws
 *   - POST /share/tailscale returns emailed=true when sendEmail resolves
 *   - POST /share/tailscale returns urlPath when email fails (manual fallback)
 *   - DELETE /share/:id requires admin; revokes
 *   - GET /share lists shares (admin only)
 *   - POST /share/:token/subscribe is public, records event
 *   - POST /share/:token/redeem-tailscale records use + is single-shot
 */

'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const { createShareStore } = require('../src/security/share-store');
const { PaymentRequiredError } = require('../src/utilities/errors');

function _tmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-route-'));
}
function _cleanup(dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}

// ── Test stubs ────────────────────────────────────────────────────────────

function _proLicenseManager() {
  return { isPro: () => true, allowsLifetimeLicense: () => false };
}
function _freeLicenseManager() {
  return { isPro: () => false, allowsLifetimeLicense: () => false };
}

function _stubNotificationManager({ shouldFail = false } = {}) {
  return {
    sendEmail: jest.fn(async () => {
      if (shouldFail) throw new Error('SMTP down');
      return { messageId: 'fake' };
    }),
  };
}

function _stubTailscaleCoord({ shouldFail = false, keyId = 'auth-key-123' } = {}) {
  return {
    createAuthKey: jest.fn(async () => {
      if (shouldFail) throw new Error('Tailscale API down');
      return { id: keyId, key: 'tskey-fake-' + 'x'.repeat(40) };
    }),
  };
}

function _stubServicesStateManager(services = {}) {
  return {
    get: async (id) => services[id] || null,
    read: async () => Object.values(services),
  };
}

function _buildApp({
  shareStore,
  licenseManager = _proLicenseManager(),
  tailscaleCoord = _stubTailscaleCoord(),
  notificationManager = _stubNotificationManager(),
  servicesStateManager = _stubServicesStateManager({
    plex: { id: 'plex', name: 'Plex', description: 'Media', url: 'https://plex.sami' },
  }),
  adminUser = { email: 'admin@sami', role: 'admin' },
  noAdmin = false,
} = {}) {
  const app = express();
  app.use(express.json());
  // Inject a fake req.user for the protected endpoints; bypass for the public ones.
  app.use((req, _res, next) => {
    if (noAdmin) {
      req.user = { email: 'viewer@sami', role: 'viewer' };
    } else {
      req.user = adminUser;
    }
    next();
  });
  const shareRoutes = require('../routes/share');
  app.use(shareRoutes({
    shareStore,
    licenseManager,
    tailscaleCoord,
    notificationManager,
    servicesStateManager,
    servicesFile: null,
    asyncHandler: (fn, label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
    log: { info() {}, warn() {}, error() {} },
  }));
  // Error handler mirrors production
  app.use((err, _req, res, _next) => {
    if (err && err.statusCode) {
      return res.status(err.statusCode).json({
        success: false,
        error: err.message,
        code: err.code,
      });
    }
    return res.status(500).json({ success: false, error: err && err.message });
  });
  return app;
}

// ── Tests ────────────────────────────────────────────────────────────────

describe('share routes: GET /share/:token/preview', () => {
  let dir, shareStore;
  beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('public — returns service snapshot', async () => {
    const app = _buildApp({ shareStore });
    const issued = await shareStore.issuePublic({ serviceId: 'plex' });

    const res = await request(app).get(`/share/${issued.token}/preview`);
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(res.body.data.kind).toBe('public');
    expect(res.body.data.serviceId).toBe('plex');
    expect(res.body.data.service.name).toBe('Plex');
  });

  test('public — 404 for unknown token', async () => {
    const app = _buildApp({ shareStore });
    const res = await request(app).get('/share/nonexistent/preview');
    expect(res.status).toBe(404);
  });

  test('public — no auth required', async () => {
    const app = _buildApp({ shareStore, noAdmin: true });
    const issued = await shareStore.issuePublic({ serviceId: 'plex' });
    const res = await request(app).get(`/share/${issued.token}/preview`);
    expect(res.status).toBe(200);
  });
});

describe('share routes: POST /share', () => {
  let dir, shareStore;
  beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('admin+Pro → issues public share', async () => {
    const app = _buildApp({ shareStore });
    const res = await request(app)
      .post('/share')
      .send({ serviceId: 'plex', ttlMs: 3_600_000 });
    expect(res.status).toBe(201);
    expect(res.body.success).toBe(true);
    expect(res.body.data.kind).toBe('public');
    expect(res.body.data.token).toBeTruthy();
    expect(res.body.data.urlPath).toBe(`/share/${res.body.data.token}`);
    expect(res.body.data.serviceId).toBe('plex');
  });

  test('Free tier → 402 PaymentRequired', async () => {
    const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() });
    const res = await request(app).post('/share').send({ serviceId: 'plex' });
    expect(res.status).toBe(402);
    expect(res.body.error).toMatch(/Pro tier required/);
  });

  test('non-admin → 403', async () => {
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app).post('/share').send({ serviceId: 'plex' });
    expect(res.status).toBeGreaterThanOrEqual(400);
    expect(res.status).toBeLessThan(500);
  });

  test('unknown serviceId → 404', async () => {
    const app = _buildApp({ shareStore });
    const res = await request(app).post('/share').send({ serviceId: 'nope' });
    expect(res.status).toBe(404);
  });

  test('missing serviceId → 400', async () => {
    const app = _buildApp({ shareStore });
    const res = await request(app).post('/share').send({});
    expect(res.status).toBe(400);
  });

  test('unsupported TTL snaps to default', async () => {
    const app = _buildApp({ shareStore });
    const res = await request(app)
      .post('/share')
      .send({ serviceId: 'plex', ttlMs: 999999 });
    expect(res.status).toBe(201);
    expect(res.body.data.ttlMs).toBe(24 * 60 * 60 * 1000);
  });
});

describe('share routes: POST /share/tailscale', () => {
  let dir, shareStore;
  beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('Pro+admin+Tailscale → mints key, emails, records share', async () => {
    const tailscaleCoord = _stubTailscaleCoord();
    const notificationManager = _stubNotificationManager();
    const app = _buildApp({ shareStore, tailscaleCoord, notificationManager });

    const res = await request(app)
      .post('/share/tailscale')
      .send({ serviceId: 'plex', email: 'friend@example.com' });

    expect(res.status).toBe(201);
    expect(res.body.success).toBe(true);
    expect(res.body.data.kind).toBe('tailscale');
    expect(res.body.data.email).toBe('friend@example.com');
    expect(res.body.data.emailed).toBe(true);
    expect(res.body.data.emailError).toBeFalsy();
    expect(tailscaleCoord.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
      reusable: false, ephemeral: true, preauthorized: true,
      description: expect.stringContaining('dashcaddy-share:'),
    }));
    expect(notificationManager.sendEmail).toHaveBeenCalledWith(
      expect.stringContaining('shared a service with you'),
      expect.stringContaining('/share/')
    );
  });

  test('Free tier → 402', async () => {
    const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() });
    const res = await request(app)
      .post('/share/tailscale')
      .send({ serviceId: 'plex', email: 'a@b.com' });
    expect(res.status).toBe(402);
  });

  test('Tailscale not configured → 400', async () => {
    const app = _buildApp({ shareStore, tailscaleCoord: null });
    const res = await request(app)
      .post('/share/tailscale')
      .send({ serviceId: 'plex', email: 'a@b.com' });
    expect(res.status).toBe(400);
  });

  test('createAuthKey failure → rolls back share', async () => {
    const app = _buildApp({
      shareStore,
      tailscaleCoord: _stubTailscaleCoord({ shouldFail: true }),
    });
    const res = await request(app)
      .post('/share/tailscale')
      .send({ serviceId: 'plex', email: 'a@b.com' });
    expect(res.status).toBe(400);
    // No orphans
    const remaining = await shareStore.list();
    expect(remaining).toHaveLength(0);
  });

  test('email delivery failure → still returns 201 with urlPath fallback', async () => {
    const app = _buildApp({
      shareStore,
      notificationManager: _stubNotificationManager({ shouldFail: true }),
    });
    const res = await request(app)
      .post('/share/tailscale')
      .send({ serviceId: 'plex', email: 'a@b.com' });
    expect(res.status).toBe(201);
    expect(res.body.data.emailed).toBe(false);
    expect(res.body.data.emailError).toMatch(/SMTP/);
    expect(res.body.data.urlPath).toMatch(/^\/share\//);
  });

  test('clamps TTL to 24h max', async () => {
    const tailscaleCoord = _stubTailscaleCoord();
    const app = _buildApp({ shareStore, tailscaleCoord });
    const res = await request(app)
      .post('/share/tailscale')
      .send({ serviceId: 'plex', email: 'a@b.com', ttlMs: 30 * 24 * 60 * 60 * 1000 });
    expect(res.status).toBe(201);
    const calledOpts = tailscaleCoord.createAuthKey.mock.calls[0][0];
    expect(calledOpts.expirySeconds).toBeLessThanOrEqual(24 * 60 * 60);
  });
});

describe('share routes: GET /share + DELETE /share/:id', () => {
  let dir, shareStore;
  beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('admin lists outstanding shares', async () => {
    await shareStore.issuePublic({ serviceId: 'plex' });
    await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
    const app = _buildApp({ shareStore });
    const res = await request(app).get('/share');
    expect(res.status).toBe(200);
    expect(res.body.data).toHaveLength(2);
  });

  test('non-admin → forbidden', async () => {
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app).get('/share');
    expect(res.status).toBeGreaterThanOrEqual(400);
  });

  test('admin revokes share', async () => {
    const issued = await shareStore.issuePublic({ serviceId: 'plex' });
    const app = _buildApp({ shareStore });
    const res = await request(app).delete(`/share/${issued.id}`);
    expect(res.status).toBe(200);
    expect(await shareStore.peek(issued.token)).toBeNull();
  });

  test('revoke unknown id → 404', async () => {
    const app = _buildApp({ shareStore });
    const res = await request(app).delete('/share/nonexistent');
    expect(res.status).toBe(404);
  });
});

describe('share routes: POST /share/:token/subscribe (public)', () => {
  let dir, shareStore;
  beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('public — records subscribe event', async () => {
    const issued = await shareStore.issuePublic({ serviceId: 'plex' });
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app)
      .post(`/share/${issued.token}/subscribe`)
      .send({ email: 'sub@example.com' });
    expect(res.status).toBe(200);
    expect(res.body.data.count).toBe(1);
  });

  test('rejects invalid email', async () => {
    const issued = await shareStore.issuePublic({ serviceId: 'plex' });
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app)
      .post(`/share/${issued.token}/subscribe`)
      .send({ email: 'not-an-email' });
    expect(res.status).toBe(400);
  });

  test('rejects unknown token', async () => {
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app)
      .post('/share/nonexistent/subscribe')
      .send({ email: 'a@b.com' });
    expect(res.status).toBe(404);
  });
});

describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
  let dir, shareStore;
  beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
  afterEach(() => _cleanup(dir));

  test('public — first redemption succeeds, second is already_used', async () => {
    const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
    const app = _buildApp({ shareStore, noAdmin: true });
    const r1 = await request(app)
      .post(`/share/${issued.token}/redeem-tailscale`)
      .send({ deviceId: 'device-1' });
    expect(r1.status).toBe(200);
    expect(r1.body.data.redeemed).toBe(true);

    const r2 = await request(app)
      .post(`/share/${issued.token}/redeem-tailscale`)
      .send({ deviceId: 'device-2' });
    expect(r2.status).toBe(400);
    expect(r2.body.error).toMatch(/already_used/);
  });

  test('rejects missing deviceId — DC-083 accepts omitted, treats as "unknown"', async () => {
    const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app)
      .post(`/share/${issued.token}/redeem-tailscale`)
      .send({});
    // DC-083: omitted deviceId is now accepted; the store defaults usedBy
    // to 'unknown'. The pre-fix route layer required deviceId be present;
    // the new behavior matches the store's defensive default and is
    // safer for partially-malformed forward_auth calls from Caddy.
    expect(res.status).toBe(200);
    expect(res.body.data.redeemed).toBe(true);
  });

  test('rejects invalid deviceId (control chars / oversized)', async () => {
    const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app)
      .post(`/share/${issued.token}/redeem-tailscale`)
      .send({ deviceId: 'node\r\nhack' });
    expect(res.status).toBe(400);
  });

  test('rejects empty deviceId', async () => {
    const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
    const app = _buildApp({ shareStore, noAdmin: true });
    const res = await request(app)
      .post(`/share/${issued.token}/redeem-tailscale`)
      .send({ deviceId: '' });
    expect(res.status).toBe(400);
  });
});

describe('share routes: defensive', () => {
  // These tests run under jest (NODE_ENV=test) so the factory is lenient
  // about missing deps — it returns an empty router with a 404 catch-all
  // instead of throwing. That's by design: production always wires
  // shareStore + asyncHandler (src/app.js instantiates them), but the
  // universal-deps Proxy in some test scenarios returns noopFn.

  test('factory returns 404 router when shareStore missing (test mode)', () => {
    const prevEnv = process.env.NODE_ENV;
    process.env.NODE_ENV = 'test';
    try {
      const shareRoutes = require('../routes/share');
      const router = shareRoutes({ asyncHandler: (fn) => fn });
      expect(typeof router).toBe('function'); // express.Router
    } finally {
      process.env.NODE_ENV = prevEnv;
    }
  });

  test('factory uses fallback asyncHandler when missing (test mode)', () => {
    const prevEnv = process.env.NODE_ENV;
    process.env.NODE_ENV = 'test';
    try {
      const shareRoutes = require('../routes/share');
      const dir = _tmpDir();
      const shareStore = createShareStore({ dataDir: dir });
      const router = shareRoutes({ shareStore });
      expect(typeof router).toBe('function');
      _cleanup(dir);
    } finally {
      process.env.NODE_ENV = prevEnv;
    }
  });

  test('factory throws when shareStore missing in production', () => {
    const prevEnv = process.env.NODE_ENV;
    delete process.env.NODE_ENV;
    try {
      const shareRoutes = require('../routes/share');
      expect(() => shareRoutes({ asyncHandler: (fn) => fn })).toThrow(/shareStore/);
    } finally {
      if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv;
    }
  });

  test('factory throws when asyncHandler missing in production', () => {
    const prevEnv = process.env.NODE_ENV;
    delete process.env.NODE_ENV;
    try {
      const shareRoutes = require('../routes/share');
      const dir = _tmpDir();
      const shareStore = createShareStore({ dataDir: dir });
      expect(() => shareRoutes({ shareStore })).toThrow(/asyncHandler/);
      _cleanup(dir);
    } finally {
      if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv;
    }
  });
});