// Tests for the Supabase-first license fetch in licensingMiddleware.js.
//
// Architecture: SuperAdmin publishes the signed license to Supabase
// cloud `tenant_licenses`. The tenant server fetches it from there
// with the anon key, falls back to its local SQLite only on Supabase
// failure. These tests stub the SupabaseLicenseService methods on the
// real singleton (which the middleware imports once) and then restore
// the originals in afterEach. We avoid `require.cache` patching because
// Windows path-casing makes the cache key brittle; mutating the exported
// singleton's methods is the same approach vitest userland code uses.

const { pointAtDevDb } = require('./setup');
pointAtDevDb();
process.env.ENABLE_LICENSING_IN_TESTS = 'true';

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const LicenseCache = require('../src/services/LicenseCacheService');
const SupabaseLicenseService = require('../src/services/SupabaseLicenseService');

const ED25519_PUB = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAWg05BQdh7zUjCHpvS7w7RKv7lSgLT1ZtULbwJGYq0vw=
-----END PUBLIC KEY-----`;
const ED25519_PRIV = `-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIHU4ibnw5vxTf4FTstDk++KyLY4hDehbYuKxkVYpP3um
-----END PRIVATE KEY-----`;

const TENANT_ID = '5b39bcb5-506a-456d-b7cb-91730b397cc1';

function signLicense(claims) {
  const header = { alg: 'EdDSA', typ: 'JWT' };
  const enc = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
  const data = `${enc(header)}.${enc(claims)}`;
  const sig = crypto.sign(null, Buffer.from(data), ED25519_PRIV);
  return `${data}.${sig.toString('base64url')}`;
}

function futureExp() {
  return Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30; // +30d
}

function buildSupabaseRow(overrides = {}) {
  const exp = futureExp();
  const token = signLicense({
    tenant_id: TENANT_ID,
    nbf: Math.floor(Date.now() / 1000) - 60,
    exp,
    ...overrides.claims,
  });
  return {
    id: overrides.id || 'lic-test-001',
    tenant_id: TENANT_ID,
    license_key: 'LIC-TEST-001',
    signed_license_token: token,
    algorithm: 'EdDSA',
    term_name: 'Term 1',
    academic_year: '2026',
    start_date: new Date().toISOString().slice(0, 10),
    end_date: new Date(exp * 1000).toISOString().slice(0, 10),
    grace_period_days: 7,
    max_students: 1000,
    max_staff: 200,
    status: 'active',
    override_reason: null,
    issued_by: 'SuperAdmin Cloud',
    ...overrides.row,
  };
}

function makeSaStub(overrides = {}) {
  const calls = [];
  const stub = {
    supabaseUrl: 'https://example.supabase.co',
    supabaseKey: overrides.key || 'anon-key',
    _configured: true,
    isConfigured: () => (overrides.isConfigured === false ? false : true),
    fetchActiveLicense: async (tenantId) => {
      calls.push(tenantId);
      if (overrides.fetchError) throw overrides.fetchError;
      if (overrides.fetchResult === null) return null;
      return overrides.fetchResult || buildSupabaseRow();
    },
    fetchActiveLicenseCalls: calls,
    upsertLicenseCache: () => (overrides.upsertResult !== false),
  };
  return stub;
}

function installSaStub(overrides = {}) {
  // Replace methods on the live singleton so the middleware (which has
  // already captured the same reference at require time) sees the
  // override. Save the originals so afterEach can restore them.
  const stub = makeSaStub(overrides);
  return {
    stub,
    restore: () => {
      SupabaseLicenseService.isConfigured = originalIsConfigured;
      SupabaseLicenseService.fetchActiveLicense = originalFetch;
      SupabaseLicenseService.upsertLicenseCache = originalUpsert;
    },
  };
}

const originalIsConfigured = SupabaseLicenseService.isConfigured.bind(SupabaseLicenseService);
const originalFetch = SupabaseLicenseService.fetchActiveLicense.bind(SupabaseLicenseService);
const originalUpsert = SupabaseLicenseService.upsertLicenseCache.bind(SupabaseLicenseService);

let activeRestore = null;
function installSaStubWrap(overrides) {
  if (activeRestore) activeRestore();
  const { stub, restore } = installSaStub(overrides);
  SupabaseLicenseService.isConfigured = stub.isConfigured;
  SupabaseLicenseService.fetchActiveLicense = stub.fetchActiveLicense;
  SupabaseLicenseService.upsertLicenseCache = stub.upsertLicenseCache;
  SupabaseLicenseService.__stub = stub;
  activeRestore = restore;
}

function makeReq(overrides = {}) {
  return {
    method: overrides.method || 'POST',
    path: overrides.path || '/api/grades',
    headers: {
      'x-tenant-id': TENANT_ID,
      ...(overrides.headers || {}),
    },
    tenantId: TENANT_ID,
    user: undefined,
    ...overrides,
  };
}

function makeRes() {
  const r = {
    statusCode: 200,
    body: null,
    headers: {},
  };
  r.status = (code) => {
    r.statusCode = code;
    return r;
  };
  r.json = (body) => {
    r.body = body;
    return r;
  };
  r.setHeader = (k, v) => {
    r.headers[k] = v;
  };
  return r;
}

let savedEnv;
beforeAll(() => {
  savedEnv = {
    SUPERADMIN_PUBLIC_KEY: process.env.SUPERADMIN_PUBLIC_KEY,
    NODE_ENV: process.env.NODE_ENV,
  };
  process.env.SUPERADMIN_PUBLIC_KEY = ED25519_PUB;
  process.env.NODE_ENV = 'development';
});

afterAll(() => {
  if (savedEnv.SUPERADMIN_PUBLIC_KEY !== undefined) {
    process.env.SUPERADMIN_PUBLIC_KEY = savedEnv.SUPERADMIN_PUBLIC_KEY;
  } else {
    delete process.env.SUPERADMIN_PUBLIC_KEY;
  }
  if (savedEnv.NODE_ENV !== undefined) {
    process.env.NODE_ENV = savedEnv.NODE_ENV;
  }
});

// Skip the entire suite if the dev DB doesn't have tenant_licenses or
// the public key. The middleware falls back to "Dev-Bypass" when
// SUPERADMIN_PUBLIC_KEY is empty, but we override it above so the
// verification path runs.
const DB_PATH = process.env.DB_PATH;
const hasLocalLicenseTable = (() => {
  try {
    const Database = require('better-sqlite3');
    const db = new Database(DB_PATH);
    const row = db
      .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tenant_licenses'")
      .get();
    db.close();
    return !!row;
  } catch (_) {
    return false;
  }
})();

const itDb = hasLocalLicenseTable ? it : it.skip;

describe('Tenant licensing middleware — Supabase-first fetch', () => {
  let middleware;

  beforeEach(async () => {
    // Clear the in-process license cache so each test starts from a
    // cold Supabase-first path. Without this, the second test would
    // hit the LRU entry from the first test and skip the Supabase fetch.
    try {
      await LicenseCache.del(TENANT_ID);
    } catch (_) {}
    // Wipe the bundled default local-row so tests that expect
    // "no license" hit a real cold path. Real dev DBs in this repo
    // ship with one default row, which is great for manual dev but
    // would make these tests no-ops.
    try {
      const Database = require('better-sqlite3');
      const db = new Database(DB_PATH);
      db.prepare('DELETE FROM tenant_licenses WHERE tenant_id = ?').run(TENANT_ID);
      db.close();
    } catch (_) {}
    // Always start from a fresh require of the middleware so the test
    // can rebuild the cache/Supabase contract deterministically.
    delete require.cache[path.resolve(__dirname, '..', 'src', 'middleware', 'licensingMiddleware.js')];
    middleware = require('../src/middleware/licensingMiddleware');
  });

  afterEach(() => {
    if (activeRestore) {
      activeRestore();
      activeRestore = null;
    }
  });

  it('blocks mutations when Supabase has no license and local DB has no row', async () => {
    installSaStubWrap({ fetchResult: null });
    const req = makeReq({ method: 'POST', path: '/api/grades' });
    const res = makeRes();
    let nextCalled = false;
    await middleware(req, res, () => {
      nextCalled = true;
    });
    expect(nextCalled).toBe(false);
    expect(res.statusCode).toBe(402);
    expect(res.body.code).toBe('NO_LICENSE');
  });

  it('allows mutations when Supabase returns a valid signed license', async () => {
    installSaStubWrap();
    const req = makeReq({ method: 'POST', path: '/api/grades' });
    const res = makeRes();
    let nextCalled = false;
    await middleware(req, res, () => {
      nextCalled = true;
    });
    expect(nextCalled).toBe(true);
    expect(res.headers['X-License-Status']).toBe('ACTIVE');
    expect(res.headers['X-License-Source']).toBe('supabase');
    expect(req.license).toBeTruthy();
    expect(req.license.source).toBe('supabase');
  });

  it('allows read-only requests when Supabase has no license (graceful read)', async () => {
    installSaStubWrap({ fetchResult: null });
    const req = makeReq({ method: 'GET', path: '/api/grades' });
    const res = makeRes();
    let nextCalled = false;
    await middleware(req, res, () => {
      nextCalled = true;
    });
    expect(nextCalled).toBe(true);
    expect(res.headers['X-License-Status']).toBe('No-License');
    expect(res.headers['X-License-Source']).toBe('none');
  });

  it('falls back to local SQLite when Supabase fetch throws', async () => {
    installSaStubWrap({ fetchError: new Error('Supabase 503') });
    const req = makeReq({ method: 'POST', path: '/api/grades' });
    const res = makeRes();
    let nextCalled = false;
    await middleware(req, res, () => {
      nextCalled = true;
    });
    // If the local DB has the bundled default license, we expect ACTIVE
    // from local. If not, we expect 402 NO_LICENSE. Either is acceptable
    // here — the contract is "fall back, don't crash".
    if (res.statusCode === 402) {
      expect(res.body.code).toBe('NO_LICENSE');
    } else {
      expect(nextCalled).toBe(true);
      expect(['local', 'supabase']).toContain(res.headers['X-License-Source']);
    }
  });

  it('rejects with 402 INVALID_SIGNATURE when Supabase returns a token signed by an unknown key', async () => {
    // Sign a token with an unrelated key, so verification fails.
    const badKey = crypto.generateKeyPairSync('ed25519');
    const header = { alg: 'EdDSA', typ: 'JWT' };
    const enc = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
    const claims = {
      tenant_id: TENANT_ID,
      nbf: Math.floor(Date.now() / 1000) - 60,
      exp: futureExp(),
    };
    const data = `${enc(header)}.${enc(claims)}`;
    const sig = crypto.sign(null, Buffer.from(data), badKey.privateKey);
    const badToken = `${data}.${sig.toString('base64url')}`;

    installSaStubWrap({
      fetchResult: buildSupabaseRow({ row: { signed_license_token: badToken } }),
    });
    const req = makeReq({ method: 'POST', path: '/api/grades' });
    const res = makeRes();
    let nextCalled = false;
    await middleware(req, res, () => {
      nextCalled = true;
    });
    expect(nextCalled).toBe(false);
    expect(res.statusCode).toBe(402);
    expect(res.body.code).toBe('INVALID_SIGNATURE');
  });

  it('bypasses licensing for systems_admin role', async () => {
    installSaStubWrap({ fetchResult: null });
    const req = makeReq({ method: 'POST', path: '/api/users', user: { role: 'systems_admin' } });
    const res = makeRes();
    let nextCalled = false;
    await middleware(req, res, () => {
      nextCalled = true;
    });
    expect(nextCalled).toBe(true);
  });

  it('treats tenant_id mismatch (debug supabase row with different tenant_id) as a SIG-only failure', async () => {
    // We cannot easily inject a two-tenant race here without refactoring
    // the middleware to accept a row provider. The middleware filters by
    // tenant_id at the Supabase query level, so a forged row with a
    // different tenant_id never reaches the middleware. This test asserts
    // that contract indirectly: the SA stub's fetchActiveLicense is
    // called with the tenant_id from the request.
    installSaStubWrap({ fetchResult: null });
    const req = makeReq({ method: 'POST', path: '/api/grades' });
    const res = makeRes();
    await middleware(req, res, () => {});
    expect(SupabaseLicenseService.__stub.fetchActiveLicenseCalls).toContain(TENANT_ID);
  });
});

describe('SupabaseLicenseService — unit-level', () => {
  // We can't exercise the real client without a network round-trip.
  // Instead we test the upsert path against the local SQLite. This is
  // the resilience write-back the user asked for.
  beforeEach(() => {
    // Restore the real methods so the unit test exercises the real
    // service, not the stub from the previous describe block.
    if (activeRestore) {
      activeRestore();
      activeRestore = null;
    }
  });
  it('upsertLicenseCache writes a row that the middleware can read back', () => {
    const row = buildSupabaseRow();
    const ok = SupabaseLicenseService.upsertLicenseCache(row);
    expect(ok).toBe(true);

    const Database = require('better-sqlite3');
    const db = new Database(DB_PATH);
    const got = db
      .prepare('SELECT id, license_key, signed_license_token, algorithm FROM tenant_licenses WHERE id = ?')
      .get(row.id);
    db.close();
    expect(got).toBeTruthy();
    expect(got.id).toBe(row.id);
    expect(got.license_key).toBe(row.license_key);
    expect(got.signed_license_token).toBe(row.signed_license_token);
    expect(got.algorithm).toBe('EdDSA');
  });
});

