/**
 * Unit tests for cookie-import-browser.ts
 *
 * Uses a fixture SQLite database with cookies encrypted using a known test key.
 * Mocks Keychain access to return the test password.
 *
 * Test key derivation (matches real Chromium pipeline):
 *   password = "test-keychain-password"
 *   key = PBKDF2(password, "saltysalt", 1003, 16, sha1)
 *
 * Encryption: AES-128-CBC with IV = 16 × 0x20, prefix "v10"
 * First 32 bytes of plaintext = HMAC-SHA256 tag (random for tests)
 * Remaining bytes = actual cookie value
 */

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Database } from 'bun:sqlite';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

// ─── Test Constants ─────────────────────────────────────────────

const TEST_PASSWORD = 'test-keychain-password';
const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1');
const LINUX_V10_PASSWORD = 'peanuts';
const LINUX_V10_KEY = crypto.pbkdf2Sync(LINUX_V10_PASSWORD, 'saltysalt', 1, 16, 'sha1');
const LINUX_V11_PASSWORD = 'test-linux-secret';
const LINUX_V11_KEY = crypto.pbkdf2Sync(LINUX_V11_PASSWORD, 'saltysalt', 1, 16, 'sha1');
const IV = Buffer.alloc(16, 0x20);
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;

// Fixture DB path
const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures');
const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db');
const LINUX_FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies-linux.db');

// ─── Encryption Helper ──────────────────────────────────────────

function encryptCookieValue(
  value: string,
  options?: { key?: Buffer; prefix?: 'v10' | 'v11' },
): Buffer {
  const key = options?.key ?? TEST_KEY;
  const prefix = options?.prefix ?? 'v10';
  // 32-byte HMAC tag (random for test) + actual value
  const hmacTag = crypto.randomBytes(32);
  const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]);

  // PKCS7 pad to AES block size (16 bytes)
  const blockSize = 16;
  const padLen = blockSize - (plaintext.length % blockSize);
  const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]);

  const cipher = crypto.createCipheriv('aes-128-cbc', key, IV);
  cipher.setAutoPadding(false); // We padded manually
  const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);

  return Buffer.concat([Buffer.from(prefix), encrypted]);
}

function chromiumEpoch(unixSeconds: number): bigint {
  return BigInt(unixSeconds) * 1000000n + CHROMIUM_EPOCH_OFFSET;
}

// ─── Create Fixture Database ────────────────────────────────────

function createFixtureDb(dbPath: string): Database {
  fs.mkdirSync(FIXTURE_DIR, { recursive: true });
  if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);

  const db = new Database(dbPath);
  db.run(`CREATE TABLE cookies (
    host_key TEXT NOT NULL,
    name TEXT NOT NULL,
    value TEXT NOT NULL DEFAULT '',
    encrypted_value BLOB NOT NULL DEFAULT x'',
    path TEXT NOT NULL DEFAULT '/',
    expires_utc INTEGER NOT NULL DEFAULT 0,
    is_secure INTEGER NOT NULL DEFAULT 0,
    is_httponly INTEGER NOT NULL DEFAULT 0,
    has_expires INTEGER NOT NULL DEFAULT 0,
    samesite INTEGER NOT NULL DEFAULT 1
  )`);
  return db;
}

function createMacFixtureDb() {
  const db = createFixtureDb(FIXTURE_DB);
  const insert = db.prepare(`INSERT INTO cookies
    (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);

  const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365));
  const pastExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) - 86400));

  // Domain 1: .github.com — 3 encrypted cookies
  insert.run('.github.com', 'session_id', '', encryptCookieValue('abc123'), '/', futureExpiry, 1, 1, 1, 1);
  insert.run('.github.com', 'user_token', '', encryptCookieValue('token-xyz'), '/', futureExpiry, 1, 0, 1, 0);
  insert.run('.github.com', 'theme', '', encryptCookieValue('dark'), '/', futureExpiry, 0, 0, 1, 2);

  // Domain 2: .google.com — 2 cookies
  insert.run('.google.com', 'NID', '', encryptCookieValue('google-nid-value'), '/', futureExpiry, 1, 1, 1, 0);
  insert.run('.google.com', 'SID', '', encryptCookieValue('google-sid-value'), '/', futureExpiry, 1, 1, 1, 1);

  // Domain 3: .example.com — 1 unencrypted cookie (value field set, no encrypted_value)
  insert.run('.example.com', 'plain_cookie', 'hello-world', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1);

  // Domain 4: .expired.com — 1 expired cookie (should be filtered out)
  insert.run('.expired.com', 'old', '', encryptCookieValue('expired-value'), '/', pastExpiry, 0, 0, 1, 1);

  // Domain 5: .session.com — session cookie (has_expires=0)
  insert.run('.session.com', 'sess', '', encryptCookieValue('session-value'), '/', 0, 1, 1, 0, 1);

  // Domain 6: .corrupt.com — cookie with garbage encrypted_value
  insert.run('.corrupt.com', 'bad', '', Buffer.from('v10' + 'not-valid-ciphertext-at-all'), '/', futureExpiry, 0, 0, 1, 1);

  // Domain 7: .mixed.com — one good, one corrupt
  insert.run('.mixed.com', 'good', '', encryptCookieValue('mixed-good'), '/', futureExpiry, 0, 0, 1, 1);
  insert.run('.mixed.com', 'bad', '', Buffer.from('v10' + 'garbage-data-here!!!'), '/', futureExpiry, 0, 0, 1, 1);

  db.close();
}

function createLinuxFixtureDb() {
  const db = createFixtureDb(LINUX_FIXTURE_DB);
  const insert = db.prepare(`INSERT INTO cookies
    (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);

  const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365));

  insert.run('.linux-v10.com', 'sid', '', encryptCookieValue('linux-v10-value', { key: LINUX_V10_KEY, prefix: 'v10' }), '/', futureExpiry, 1, 1, 1, 1);
  insert.run('.linux-v11.com', 'auth', '', encryptCookieValue('linux-v11-value', { key: LINUX_V11_KEY, prefix: 'v11' }), '/', futureExpiry, 1, 1, 1, 1);
  insert.run('.linux-plain.com', 'plain', 'plain-linux', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1);

  db.close();
}

// ─── Mock Setup ─────────────────────────────────────────────────
// We need to mock:
// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD
// 2. The cookie DB path resolution to use our fixture DB

// We'll import the module after setting up the mocks
let findInstalledBrowsers: any;
let listDomains: any;
let importCookies: any;
let CookieImportError: any;
let originalSpawn: typeof Bun.spawn;

beforeAll(async () => {
  createMacFixtureDb();
  createLinuxFixtureDb();

  // Mock Bun.spawn to return test password for keychain access
  originalSpawn = Bun.spawn;
  // @ts-ignore - monkey-patching for test
  Bun.spawn = function(cmd: any, opts: any) {
    // Intercept security find-generic-password calls
    if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') {
      // Return test password for any known test service
      return {
        stdout: new ReadableStream({
          start(controller) {
            controller.enqueue(new TextEncoder().encode(TEST_PASSWORD + '\n'));
            controller.close();
          }
        }),
        stderr: new ReadableStream({
          start(controller) { controller.close(); }
        }),
        exited: Promise.resolve(0),
        kill: () => {},
      };
    }
    if (Array.isArray(cmd) && cmd[0] === 'secret-tool' && cmd[1] === 'lookup') {
      return {
        stdout: new ReadableStream({
          start(controller) {
            controller.enqueue(new TextEncoder().encode(LINUX_V11_PASSWORD + '\n'));
            controller.close();
          }
        }),
        stderr: new ReadableStream({
          start(controller) { controller.close(); }
        }),
        exited: Promise.resolve(0),
        kill: () => {},
      };
    }
    // Pass through other spawn calls
    return originalSpawn(cmd, opts);
  };

  // Import the module (uses our mocked Bun.spawn)
  const mod = await import('../src/cookie-import-browser');
  findInstalledBrowsers = mod.findInstalledBrowsers;
  listDomains = mod.listDomains;
  importCookies = mod.importCookies;
  CookieImportError = mod.CookieImportError;
});

afterAll(() => {
  // Restore Bun.spawn
  // @ts-ignore - monkey-patching for test
  Bun.spawn = originalSpawn;
  // Clean up fixture DB
  try { fs.unlinkSync(FIXTURE_DB); } catch {}
  try { fs.unlinkSync(LINUX_FIXTURE_DB); } catch {}
  try { fs.rmdirSync(FIXTURE_DIR); } catch {}
});

// ─── Helper: Override DB path for tests ─────────────────────────
// The real code resolves paths via ~/Library/Application Support/<browser>/Default/Cookies
// We need to test against our fixture DB directly. We'll test the pure decryption functions
// by calling importCookies with a browser that points to our fixture.
// Since the module uses a hardcoded registry, we test the decryption logic via a different approach:
// We'll directly call the internal decryption by setting up the DB in the expected location.

// For the unit tests below, we test the decryption pipeline by:
// 1. Creating encrypted cookies with known values
// 2. Decrypting them with the module's decryption logic
// The actual DB path resolution is tested separately.

async function withInstalledProfile<T>(
  relativeBrowserDir: string,
  sourceDb: string,
  run: () => Promise<T>,
  profile = 'Default',
): Promise<T> {
  const homeDir = os.homedir();
  const profileDir = path.join(homeDir, relativeBrowserDir, profile);
  const cookiesPath = path.join(profileDir, 'Cookies');
  const backupPath = path.join(profileDir, `Cookies.backup-${crypto.randomUUID()}`);
  const hadOriginal = fs.existsSync(cookiesPath);

  fs.mkdirSync(profileDir, { recursive: true });
  if (hadOriginal) fs.copyFileSync(cookiesPath, backupPath);
  fs.copyFileSync(sourceDb, cookiesPath);

  try {
    return await run();
  } finally {
    if (hadOriginal) {
      fs.copyFileSync(backupPath, cookiesPath);
      fs.unlinkSync(backupPath);
    } else {
      try { fs.unlinkSync(cookiesPath); } catch {}
      try { fs.rmdirSync(profileDir); } catch {}
    }
  }
}

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

describe('Cookie Import Browser', () => {

  describe('Decryption Pipeline', () => {
    test('encrypts and decrypts round-trip correctly', () => {
      // Verify our test helper produces valid ciphertext
      const encrypted = encryptCookieValue('hello-world');
      expect(encrypted.slice(0, 3).toString()).toBe('v10');

      // Decrypt manually to verify
      const ciphertext = encrypted.slice(3);
      const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
      const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
      // Skip 32-byte HMAC tag
      const value = plaintext.slice(32).toString('utf-8');
      expect(value).toBe('hello-world');
    });

    test('handles empty encrypted_value', () => {
      const encrypted = encryptCookieValue('');
      const ciphertext = encrypted.slice(3);
      const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
      const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
      // 32-byte tag + empty value → slice(32) = empty
      expect(plaintext.length).toBe(32); // just the HMAC tag, padded to block boundary? Actually 32 + 0 padded = 48
      // With PKCS7 padding: 32 bytes + 16 bytes of padding = 48 bytes padded → decrypts to 32 bytes + padding removed = 32 bytes
    });

    test('handles special characters in cookie values', () => {
      const specialValue = 'a=b&c=d; path=/; expires=Thu, 01 Jan 2099';
      const encrypted = encryptCookieValue(specialValue);
      const ciphertext = encrypted.slice(3);
      const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
      const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
      expect(plaintext.slice(32).toString('utf-8')).toBe(specialValue);
    });
  });

  describe('Fixture DB Structure', () => {
    test('fixture DB has correct domain counts', () => {
      const db = new Database(FIXTURE_DB, { readonly: true });
      const rows = db.query(
        `SELECT host_key, COUNT(*) as count FROM cookies GROUP BY host_key ORDER BY count DESC`
      ).all() as any[];
      db.close();

      const counts = Object.fromEntries(rows.map((r: any) => [r.host_key, r.count]));
      expect(counts['.github.com']).toBe(3);
      expect(counts['.google.com']).toBe(2);
      expect(counts['.example.com']).toBe(1);
      expect(counts['.expired.com']).toBe(1);
      expect(counts['.session.com']).toBe(1);
      expect(counts['.corrupt.com']).toBe(1);
      expect(counts['.mixed.com']).toBe(2);
    });

    test('encrypted cookies in fixture have v10 prefix', () => {
      const db = new Database(FIXTURE_DB, { readonly: true });
      const rows = db.query(
        `SELECT name, encrypted_value FROM cookies WHERE host_key = '.github.com'`
      ).all() as any[];
      db.close();

      for (const row of rows) {
        const ev = Buffer.from(row.encrypted_value);
        expect(ev.slice(0, 3).toString()).toBe('v10');
      }
    });

    test('decrypts all github.com cookies from fixture DB', () => {
      const db = new Database(FIXTURE_DB, { readonly: true });
      const rows = db.query(
        `SELECT name, value, encrypted_value FROM cookies WHERE host_key = '.github.com'`
      ).all() as any[];
      db.close();

      const expected: Record<string, string> = {
        'session_id': 'abc123',
        'user_token': 'token-xyz',
        'theme': 'dark',
      };

      for (const row of rows) {
        const ev = Buffer.from(row.encrypted_value);
        if (ev.length === 0) continue;
        const ciphertext = ev.slice(3);
        const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
        const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
        const value = plaintext.slice(32).toString('utf-8');
        expect(value).toBe(expected[row.name]);
      }
    });

    test('unencrypted cookie uses value field directly', () => {
      const db = new Database(FIXTURE_DB, { readonly: true });
      const row = db.query(
        `SELECT value, encrypted_value FROM cookies WHERE host_key = '.example.com'`
      ).get() as any;
      db.close();

      expect(row.value).toBe('hello-world');
      expect(Buffer.from(row.encrypted_value).length).toBe(0);
    });
  });

  describe('sameSite Mapping', () => {
    test('maps sameSite values correctly', () => {
      // Read from fixture DB and verify mapping
      const db = new Database(FIXTURE_DB, { readonly: true });

      // samesite=0 → None
      const none = db.query(`SELECT samesite FROM cookies WHERE name = 'user_token'`).get() as any;
      expect(none.samesite).toBe(0);

      // samesite=1 → Lax
      const lax = db.query(`SELECT samesite FROM cookies WHERE name = 'session_id'`).get() as any;
      expect(lax.samesite).toBe(1);

      // samesite=2 → Strict
      const strict = db.query(`SELECT samesite FROM cookies WHERE name = 'theme'`).get() as any;
      expect(strict.samesite).toBe(2);

      db.close();
    });
  });

  describe('Chromium Epoch Conversion', () => {
    test('converts Chromium epoch to Unix timestamp correctly', () => {
      // Round-trip: pick a known Unix timestamp, convert to Chromium, convert back
      const knownUnix = 1704067200; // 2024-01-01T00:00:00Z
      const chromiumTs = BigInt(knownUnix) * 1000000n + CHROMIUM_EPOCH_OFFSET;
      const unixTs = Number((chromiumTs - CHROMIUM_EPOCH_OFFSET) / 1000000n);
      expect(unixTs).toBe(knownUnix);
    });

    test('session cookies (has_expires=0) get expires=-1', () => {
      const db = new Database(FIXTURE_DB, { readonly: true });
      const row = db.query(
        `SELECT has_expires, expires_utc FROM cookies WHERE host_key = '.session.com'`
      ).get() as any;
      db.close();
      expect(row.has_expires).toBe(0);
      // When has_expires=0, the module should return expires=-1
    });
  });

  describe('Error Handling', () => {
    test('CookieImportError has correct properties', () => {
      const err = new CookieImportError('test message', 'test_code', 'retry');
      expect(err.message).toBe('test message');
      expect(err.code).toBe('test_code');
      expect(err.action).toBe('retry');
      expect(err.name).toBe('CookieImportError');
      expect(err instanceof Error).toBe(true);
    });

    test('CookieImportError without action', () => {
      const err = new CookieImportError('no action', 'some_code');
      expect(err.action).toBeUndefined();
    });
  });

  describe('Browser Registry', () => {
    test('findInstalledBrowsers returns array', () => {
      const browsers = findInstalledBrowsers();
      expect(Array.isArray(browsers)).toBe(true);
      // Each entry should have the right shape
      for (const b of browsers) {
        expect(b).toHaveProperty('name');
        expect(b).toHaveProperty('dataDir');
        expect(b).toHaveProperty('keychainService');
        expect(b).toHaveProperty('aliases');
      }
    });

    test('detects linux-style Chromium profiles under ~/.config', async () => {
      await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
        const browsers = findInstalledBrowsers();
        const names = browsers.map((browser: any) => browser.name);

        expect(names).toContain('Chromium');
      });
    });
  });

  describe('Real Profile Imports', () => {
    test('imports Linux v10 cookies from ~/.config/chromium', async () => {
      await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
        const result = await importCookies('chromium', ['.linux-v10.com'], 'GstackLinuxV10');

        expect(result.count).toBe(1);
        expect(result.failed).toBe(0);
        expect(result.cookies[0].name).toBe('sid');
        expect(result.cookies[0].value).toBe('linux-v10-value');
      }, 'GstackLinuxV10');
    });

    test('imports Linux v11 cookies when secret-tool returns a key', async () => {
      await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
        const result = await importCookies('chromium', ['.linux-v11.com'], 'GstackLinuxV11');

        expect(result.count).toBe(1);
        expect(result.failed).toBe(0);
        expect(result.cookies[0].name).toBe('auth');
        expect(result.cookies[0].value).toBe('linux-v11-value');
      }, 'GstackLinuxV11');
    });

    test('lists domains from Linux Chromium profiles', async () => {
      await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
        const result = listDomains('chromium', 'GstackLinuxDomains');
        const domains = result.domains.map((entry: any) => entry.domain);

        expect(result.browser).toBe('Chromium');
        expect(domains).toContain('.linux-v10.com');
        expect(domains).toContain('.linux-v11.com');
        expect(domains).toContain('.linux-plain.com');
      }, 'GstackLinuxDomains');
    });
  });

  describe('Corrupt Data Handling', () => {
    test('garbage ciphertext produces decryption error', () => {
      const garbage = Buffer.from('v10' + 'this-is-not-valid-ciphertext!!');
      const ciphertext = garbage.slice(3);
      expect(() => {
        const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV);
        Buffer.concat([decipher.update(ciphertext), decipher.final()]);
      }).toThrow();
    });
  });

  describe('Profile Validation', () => {
    test('rejects path traversal in profile names', () => {
      // The validateProfile function should reject profiles with / or ..
      // We can't call it directly (internal), but we can test via listDomains
      // which calls validateProfile
      expect(() => listDomains('chrome', '../etc')).toThrow(/Invalid profile/);
      expect(() => listDomains('chrome', 'Default/../../etc')).toThrow(/Invalid profile/);
    });

    test('rejects control characters in profile names', () => {
      expect(() => listDomains('chrome', 'Default\x00evil')).toThrow(/Invalid profile/);
    });
  });

  describe('Unknown Browser', () => {
    test('throws for unknown browser name', () => {
      expect(() => listDomains('firefox')).toThrow(/Unknown browser.*firefox/i);
    });

    test('error includes list of supported browsers', () => {
      try {
        listDomains('firefox');
        throw new Error('Should have thrown');
      } catch (err: any) {
        expect(err.code).toBe('unknown_browser');
        expect(err.message).toContain('comet');
        expect(err.message).toContain('chrome');
      }
    });
  });
});
