/* test/billingAnnual.test.js
 *
 * Tests for annual pricing + per-seat Agency + Stripe Tax +
 * promo-code support. The Stripe SDK is mocked (same harness as
 * test/billing.test.js via test/_stripeMock.js) so these are
 * end-to-end through the route handler with the real model + DB
 * + plans config.
 *
 * Coverage:
 *   1.  GET /billing/plans returns annual prices + per_seat + seats range
 *   2.  GET /billing/plans: free plan has no annual price
 *   3.  POST /billing/checkout with interval=year uses annual price id
 *   4.  POST /billing/checkout with interval=month uses monthly price id
 *   5.  POST /billing/checkout defaults to monthly if interval omitted
 *   6.  POST /billing/checkout rejects bogus interval (e.g. 'week')
 *   7.  POST /billing/checkout rejects interval=year for the free plan
 *   8.  POST /billing/checkout for Agency honors quantity param
 *   9.  POST /billing/checkout for Agency rejects qty < min_seats
 *  10.  POST /billing/checkout for Agency caps qty > max_seats at max
 *  11.  POST /billing/checkout for non-Agency always uses qty=1
 *  12.  POST /billing/checkout session enables automatic_tax
 *  13.  POST /billing/checkout session enables tax_id_collection
 *  14.  POST /billing/checkout session enables allow_promotion_codes
 *  15.  POST /billing/checkout session metadata has interval + quantity
 *  16.  POST /billing/checkout subscription_data.metadata has interval + quantity
 *  17.  POST /billing/checkout honors an explicit promo discount
 *  18.  Webhook checkout.session.completed persists interval + seats
 *  19.  Webhook customer.subscription.updated with annual price id
 *       sets billing_interval='year'
 *  20.  Webhook customer.subscription.updated with quantity=5 sets seats=5
 *  21.  Webhook customer.subscription.updated with unknown price id
 *       defaults plan to 'free'
 *  22.  GET /billing/me returns billing_interval + seats
 *  23.  Agency default seats = 3 when none provided
 */
'use strict';

const assert = require('assert');
const { setupFreshDb, db } = require('./_setup');
const User = require('../models/user');
const stripeMock = require('./_stripeMock');

// Install the shared Stripe mock BEFORE routes/billing loads, so the
// route captures this exact mock instance. (billing.test.js also
// installs the same shared mock — they're the same object.)
stripeMock.installMock();

const billingRoutes = require('../routes/billing');
const webhookRoutes = require('../routes/webhook');
const { getStripePriceId, PLANS, validateQuantity, resolveLimits } = require('../config/plans');
const sign = stripeMock.sign;

// ---- Helpers --------------------------------------------------------
function makeReq({ user, body, method = 'POST', path = '/', rawBody = null, headers = {} }) {
  return { user, body, method, path, headers, app: { get: () => false } };
}
function makeRes() {
  const res = {
    statusCode: 200, body: null, headers: {},
    status(c) { this.statusCode = c; return this; },
    json(o) { this.body = o; return this; },
    send(o) { this.body = o; return this; },
    set(k, v) { this.headers[k] = v; return this; },
  };
  return res;
}
function findHandler(router, method, path) {
  for (const layer of router.stack) {
    if (!layer.route) continue;
    const methods = Object.keys(layer.route.methods).map(m => m.toUpperCase());
    if (methods.includes(method) && layer.route.path === path) {
      const stack = layer.route.stack;
      return stack[stack.length - 1].handle;
    }
  }
  throw new Error('No handler for ' + method + ' ' + path);
}
async function makeUser(email) {
  const bcrypt = require('bcryptjs');
  const hash = bcrypt.hashSync('Pass!1234', 4);
  return db.prepare(`INSERT INTO users (email, name, password_hash, role, plan, created_at, updated_at) VALUES (?, ?, ?, 'user', 'free', ?, ?) RETURNING *`)
    .get(email, 'Test', hash, Date.now(), Date.now());
}

// ---- Env ------------------------------------------------------------
process.env.STRIPE_WEBHOOK_SECRET         = 'whsec_test_annual';
process.env.STRIPE_PRICE_STANDARD         = 'price_test_standard_m';
process.env.STRIPE_PRICE_STANDARD_ANNUAL  = 'price_test_standard_y';
process.env.STRIPE_PRICE_PRO              = 'price_test_pro_m';
process.env.STRIPE_PRICE_PRO_ANNUAL       = 'price_test_pro_y';
process.env.STRIPE_PRICE_AGENCY           = 'price_test_agency_m';
process.env.STRIPE_PRICE_AGENCY_ANNUAL    = 'price_test_agency_y';
process.env.APP_URL                       = 'https://shield.test.local';

const TESTS = [];
function test(name, fn) { TESTS.push({ name, fn }); }

// ---- Tests ----------------------------------------------------------

test('GET /billing/plans returns annual prices + per_seat + seats range', () => {
  setupFreshDb();
  const req = makeReq({ method: 'GET', path: '/plans', body: {} });
  const res = makeRes();
  findHandler(billingRoutes, 'GET', '/plans')(req, res);
  assert.strictEqual(res.statusCode, 200);
  const byKey = Object.fromEntries(res.body.plans.map(p => [p.key, p]));
  // Monthly + annual priced plans
  for (const k of ['standard', 'pro', 'agency']) {
    assert.ok(byKey[k].price_cents > 0, k + ' has monthly price');
    assert.ok(byKey[k].price_cents_annual > 0, k + ' has annual price');
    assert.ok(byKey[k].price_cents_annual < byKey[k].price_cents * 12, k + ' annual < 12x monthly');
  }
  // Free has no annual
  assert.strictEqual(byKey.free.price_cents, 0);
  assert.strictEqual(byKey.free.price_cents_annual, 0);
  // Agency is per_seat with range
  assert.strictEqual(byKey.agency.per_seat, true);
  assert.strictEqual(byKey.agency.min_seats, 3);
  assert.strictEqual(byKey.agency.max_seats, 50);
  assert.ok(byKey.agency.cases_limit_per_seat > 0);
});

test('getStripePriceId resolves both monthly + annual for paid plans', () => {
  assert.strictEqual(getStripePriceId('standard', 'month'), 'price_test_standard_m');
  assert.strictEqual(getStripePriceId('standard', 'year'),  'price_test_standard_y');
  assert.strictEqual(getStripePriceId('agency',   'month'), 'price_test_agency_m');
  assert.strictEqual(getStripePriceId('agency',   'year'),  'price_test_agency_y');
  assert.strictEqual(getStripePriceId('free',     'month'), null);
  assert.strictEqual(getStripePriceId('free',     'year'),  null);
});

test('POST /billing/checkout with interval=year uses annual price id', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('annual@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro', interval: 'year' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.ok(res.body.url);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].price, 'price_test_pro_y');
});

test('POST /billing/checkout with interval=month uses monthly price id', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('monthly@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro', interval: 'month' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].price, 'price_test_pro_m');
});

test('POST /billing/checkout defaults to monthly when interval omitted', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('default@test.local');
  const req = makeReq({ user: u, body: { plan: 'standard' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].price, 'price_test_standard_m');
});

test('POST /billing/checkout rejects bogus interval', async () => {
  setupFreshDb();
  const u = await makeUser('bogus@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro', interval: 'week' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 400);
  assert.strictEqual(res.body.error, 'INVALID_PLAN_OR_INTERVAL');
});

test('POST /billing/checkout rejects interval=year for the free plan', async () => {
  setupFreshDb();
  const u = await makeUser('freey@test.local');
  const req = makeReq({ user: u, body: { plan: 'free', interval: 'year' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 400);
});

test('POST /billing/checkout for Agency honors quantity param', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('agency5@test.local');
  const req = makeReq({ user: u, body: { plan: 'agency', interval: 'month', quantity: 5 } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].quantity, 5);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].price, 'price_test_agency_m');
});

test('POST /billing/checkout for Agency rejects qty below min_seats', async () => {
  setupFreshDb();
  const u = await makeUser('agency1@test.local');
  const req = makeReq({ user: u, body: { plan: 'agency', quantity: 1 } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 400);
  assert.strictEqual(res.body.error, 'INVALID_QUANTITY');
  assert.strictEqual(res.body.min, 3);
});

test('POST /billing/checkout for Agency rejects qty above max_seats', async () => {
  setupFreshDb();
  const u = await makeUser('agency99@test.local');
  const req = makeReq({ user: u, body: { plan: 'agency', quantity: 99 } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  // 99 > max 50 → clamp to 50
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].quantity, 50);
});

test('POST /billing/checkout for non-Agency ignores quantity', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('proq@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro', quantity: 99 } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].quantity, 1);
});

test('POST /billing/checkout session enables automatic_tax', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('tax@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro', interval: 'month' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.automatic_tax.enabled, true);
});

test('POST /billing/checkout session enables tax_id_collection', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('taxid@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.tax_id_collection.enabled, true);
});

test('POST /billing/checkout session enables allow_promotion_codes', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('promo@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.allow_promotion_codes, true);
});

test('POST /billing/checkout session metadata carries interval + quantity', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('meta@test.local');
  const req = makeReq({ user: u, body: { plan: 'agency', interval: 'year', quantity: 7 } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.metadata.interval, 'year');
  assert.strictEqual(stripeMock.lastSessionCreate.metadata.quantity, '7');
  assert.strictEqual(stripeMock.lastSessionCreate.metadata.plan, 'agency');
});

test('POST /billing/checkout subscription_data.metadata carries interval + quantity', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('submeta@test.local');
  const req = makeReq({ user: u, body: { plan: 'agency', interval: 'year', quantity: 4 } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.subscription_data.metadata.interval, 'year');
  assert.strictEqual(stripeMock.lastSessionCreate.subscription_data.metadata.quantity, '4');
});

test('POST /billing/checkout applies an explicit promo discount', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('coupon@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro', promo: 'LAUNCH50' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.deepStrictEqual(stripeMock.lastSessionCreate.subscription_data.discounts, [{ coupon: 'LAUNCH50' }]);
});

test('POST /billing/checkout without promo has no discounts key', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('nopromo@test.local');
  const req = makeReq({ user: u, body: { plan: 'pro' } });
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.subscription_data.discounts, undefined);
});

// ---- Webhook tests -------------------------------------------------

test('webhook checkout.session.completed persists interval + seats', async () => {
  setupFreshDb();
  const u = await makeUser('cs-interval@test.local');
  const event = {
    id: 'evt_cs_int_' + Date.now(),
    type: 'checkout.session.completed',
    created: Math.floor(Date.now() / 1000),
    data: { object: {
      id: 'cs_int', customer: 'cus_int', subscription: 'sub_int',
      metadata: { user_id: String(u.id), plan: 'agency', interval: 'year', quantity: '4' },
    } },
  };
  const payload = JSON.stringify(event);
  const { header } = sign(payload, process.env.STRIPE_WEBHOOK_SECRET);
  const req = makeReq({ method: 'POST', path: '/', rawBody: payload, body: payload, headers: { 'stripe-signature': header } });
  const res = makeRes();
  await findHandler(webhookRoutes, 'POST', '/')(req, res);
  assert.strictEqual(res.statusCode, 200);
  const updated = User.getById(u.id);
  assert.strictEqual(updated.plan, 'agency');
  assert.strictEqual(updated.billing_interval, 'year');
  assert.strictEqual(updated.seats, 4);
});

test('webhook checkout.session.completed with missing interval defaults to month', async () => {
  setupFreshDb();
  const u = await makeUser('cs-default@test.local');
  const event = {
    id: 'evt_cs_def_' + Date.now(),
    type: 'checkout.session.completed',
    created: Math.floor(Date.now() / 1000),
    data: { object: {
      id: 'cs_def', customer: 'cus_def', subscription: 'sub_def',
      metadata: { user_id: String(u.id), plan: 'standard' },  // no interval/quantity
    } },
  };
  const payload = JSON.stringify(event);
  const { header } = sign(payload, process.env.STRIPE_WEBHOOK_SECRET);
  const req = makeReq({ method: 'POST', path: '/', rawBody: payload, body: payload, headers: { 'stripe-signature': header } });
  const res = makeRes();
  await findHandler(webhookRoutes, 'POST', '/')(req, res);
  assert.strictEqual(res.statusCode, 200);
  const updated = User.getById(u.id);
  assert.strictEqual(updated.billing_interval, 'month');
  assert.strictEqual(updated.seats, 1);
});

test('webhook customer.subscription.updated with annual price id sets year', async () => {
  setupFreshDb();
  const u = await makeUser('sub-year@test.local');
  User.setStripeCustomerId(u.id, 'cus_year');
  const annualPriceId = getStripePriceId('pro', 'year');
  const event = {
    id: 'evt_sub_y_' + Date.now(),
    type: 'customer.subscription.updated',
    created: Math.floor(Date.now() / 1000),
    data: { object: {
      id: 'sub_y', customer: 'cus_year', status: 'active',
      items: { data: [{ price: { id: annualPriceId }, quantity: 1 }] },
    } },
  };
  const payload = JSON.stringify(event);
  const { header } = sign(payload, process.env.STRIPE_WEBHOOK_SECRET);
  const req = makeReq({ method: 'POST', path: '/', rawBody: payload, body: payload, headers: { 'stripe-signature': header } });
  const res = makeRes();
  await findHandler(webhookRoutes, 'POST', '/')(req, res);
  assert.strictEqual(res.statusCode, 200);
  const updated = User.getById(u.id);
  assert.strictEqual(updated.plan, 'pro');
  assert.strictEqual(updated.billing_interval, 'year');
});

test('webhook customer.subscription.updated with quantity=5 sets seats=5', async () => {
  setupFreshDb();
  const u = await makeUser('sub-seats@test.local');
  User.setStripeCustomerId(u.id, 'cus_seats');
  const agencyPriceId = getStripePriceId('agency', 'month');
  const event = {
    id: 'evt_sub_seats_' + Date.now(),
    type: 'customer.subscription.updated',
    created: Math.floor(Date.now() / 1000),
    data: { object: {
      id: 'sub_seats', customer: 'cus_seats', status: 'active',
      items: { data: [{ price: { id: agencyPriceId }, quantity: 5 }] },
    } },
  };
  const payload = JSON.stringify(event);
  const { header } = sign(payload, process.env.STRIPE_WEBHOOK_SECRET);
  const req = makeReq({ method: 'POST', path: '/', rawBody: payload, body: payload, headers: { 'stripe-signature': header } });
  const res = makeRes();
  await findHandler(webhookRoutes, 'POST', '/')(req, res);
  assert.strictEqual(res.statusCode, 200);
  const updated = User.getById(u.id);
  assert.strictEqual(updated.plan, 'agency');
  assert.strictEqual(updated.seats, 5);
  assert.strictEqual(updated.billing_interval, 'month');
});

test('webhook customer.subscription.updated with unknown price id defaults to free', async () => {
  setupFreshDb();
  const u = await makeUser('sub-unknown@test.local');
  User.setStripeCustomerId(u.id, 'cus_unk');
  User.setStripeSubscriptionId(u.id, 'sub_unk', 'pro', 'month', 1);
  const event = {
    id: 'evt_sub_unk_' + Date.now(),
    type: 'customer.subscription.updated',
    created: Math.floor(Date.now() / 1000),
    data: { object: {
      id: 'sub_unk', customer: 'cus_unk', status: 'active',
      items: { data: [{ price: { id: 'price_unknown_xyz' }, quantity: 1 }] },
    } },
  };
  const payload = JSON.stringify(event);
  const { header } = sign(payload, process.env.STRIPE_WEBHOOK_SECRET);
  const req = makeReq({ method: 'POST', path: '/', rawBody: payload, body: payload, headers: { 'stripe-signature': header } });
  const res = makeRes();
  await findHandler(webhookRoutes, 'POST', '/')(req, res);
  assert.strictEqual(res.statusCode, 200);
  const updated = User.getById(u.id);
  assert.strictEqual(updated.plan, 'free');
});

test('GET /billing/me returns billing_interval + seats', async () => {
  setupFreshDb();
  const u = await makeUser('me@test.local');
  User.setStripeSubscriptionId(u.id, 'sub_me', 'agency', 'year', 7);
  const refreshed = User.getById(u.id);
  const req = makeReq({ user: refreshed, method: 'GET', path: '/me', body: {} });
  const res = makeRes();
  findHandler(billingRoutes, 'GET', '/me')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(res.body.billing_interval, 'year');
  assert.strictEqual(res.body.seats, 7);
});

test('Agency default seats = 3 when none provided', async () => {
  setupFreshDb();
  stripeMock.reset();
  const u = await makeUser('agency-default@test.local');
  const req = makeReq({ user: u, body: { plan: 'agency' } }); // no quantity
  const res = makeRes();
  await findHandler(billingRoutes, 'POST', '/checkout')(req, res);
  assert.strictEqual(res.statusCode, 200);
  assert.strictEqual(stripeMock.lastSessionCreate.line_items[0].quantity, 3);
});

test('validateQuantity clamps above max, rejects below min', () => {
  assert.strictEqual(validateQuantity('agency', 2),   null,   'below min');
  assert.strictEqual(validateQuantity('agency', 3),   3,      'at min');
  assert.strictEqual(validateQuantity('agency', 99),  50,     'above max → cap');
  assert.strictEqual(validateQuantity('agency', 'abc'), null, 'NaN rejected');
  // non-per-seat always returns 1
  assert.strictEqual(validateQuantity('pro', 99), 1);
});

test('resolveLimits scales Agency per-seat but leaves flat plans alone', () => {
  const flat = resolveLimits('pro', 99);
  assert.strictEqual(flat.cases_limit, PLANS.pro.cases_limit);
  assert.strictEqual(flat.scans_limit, PLANS.pro.scans_limit);
  // Agency with 5 seats
  const agency = resolveLimits('agency', 5);
  assert.strictEqual(agency.seats, 5);
  assert.strictEqual(agency.cases_limit, 5 * PLANS.agency.cases_limit_per_seat);
  assert.strictEqual(agency.scans_limit, 5 * PLANS.agency.scans_limit_per_seat);
  assert.strictEqual(agency.takedowns_limit, 5 * PLANS.agency.takedowns_limit_per_seat);
  assert.strictEqual(agency.users_limit, 5);
});

module.exports = TESTS;
