/**
 * Unit tests for Cursor authentication module
 */

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type { CursorCredentials } from '../../../src/cursor/types';
import {
  validateToken,
  extractUserInfo,
  saveCredentials,
  loadCredentials,
  checkAuthStatus,
  deleteCredentials,
  autoDetectTokens,
  getTokenStorageCandidates,
} from '../../../src/cursor/cursor-auth';

// Test isolation
let originalCcsHome: string | undefined;
let tempDir: string;

beforeEach(() => {
  // Save original CCS_HOME
  originalCcsHome = process.env.CCS_HOME;

  // Create temp directory for test isolation
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-test-'));
  process.env.CCS_HOME = tempDir;
});

afterEach(() => {
  // Restore original CCS_HOME
  if (originalCcsHome !== undefined) {
    process.env.CCS_HOME = originalCcsHome;
  } else {
    delete process.env.CCS_HOME;
  }

  // Clean up temp directory
  if (fs.existsSync(tempDir)) {
    fs.rmSync(tempDir, { recursive: true, force: true });
  }
});

describe('validateToken', () => {
  it('should accept valid token and machineId', () => {
    const token = 'a'.repeat(50); // 50 chars minimum
    const machineId = 'a'.repeat(32); // 32 hex chars
    expect(validateToken(token, machineId)).toBe(true);
  });

  it('should reject 49-char token (too short)', () => {
    const token = 'a'.repeat(49); // Just under minimum
    const machineId = 'a'.repeat(32);
    expect(validateToken(token, machineId)).toBe(false);
  });

  it('should reject 31-char hex UUID (too short)', () => {
    const token = 'a'.repeat(50);
    const machineId = 'a'.repeat(31); // Just under 32
    expect(validateToken(token, machineId)).toBe(false);
  });

  it('should accept UUID with hyphens (strips them)', () => {
    const token = 'a'.repeat(50);
    const machineId = '12345678-1234-1234-1234-123456789abc'; // 36 chars with hyphens
    expect(validateToken(token, machineId)).toBe(true);
  });

  it('should reject empty token', () => {
    const machineId = 'a'.repeat(32);
    expect(validateToken('', machineId)).toBe(false);
  });

  it('should reject empty machineId', () => {
    const token = 'a'.repeat(50);
    expect(validateToken(token, '')).toBe(false);
  });

  it('should reject non-hex characters in machineId', () => {
    const token = 'a'.repeat(50);
    const machineId = 'g'.repeat(32); // 'g' is not valid hex
    expect(validateToken(token, machineId)).toBe(false);
  });
});

describe('extractUserInfo', () => {
  it('should extract email and sub from valid JWT', () => {
    // JWT: {"email":"user@example.com","sub":"12345","exp":1234567890}
    const payload = Buffer.from(
      JSON.stringify({ email: 'user@example.com', sub: '12345', exp: 1234567890 })
    ).toString('base64');
    const token = `header.${payload}.signature`;

    const result = extractUserInfo(token);
    expect(result).toEqual({
      email: 'user@example.com',
      userId: '12345',
      exp: 1234567890,
    });
  });

  it('should return undefined email when only sub claim exists', () => {
    // JWT: {"sub":"uuid-12345","exp":1234567890}
    const payload = Buffer.from(JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 })).toString(
      'base64'
    );
    const token = `header.${payload}.signature`;

    const result = extractUserInfo(token);
    expect(result).toEqual({
      email: undefined,
      userId: 'uuid-12345',
      exp: 1234567890,
    });
  });

  it('should return null for non-JWT token', () => {
    const token = 'a'.repeat(50); // Plain token
    const result = extractUserInfo(token);
    expect(result).toBe(null);
  });

  it('should return null for malformed base64', () => {
    const token = 'header.!!!invalid-base64!!!.signature';
    const result = extractUserInfo(token);
    expect(result).toBe(null);
  });

  it('should handle JWT with user_id instead of sub', () => {
    // JWT: {"email":"user@example.com","user_id":"67890"}
    const payload = Buffer.from(
      JSON.stringify({ email: 'user@example.com', user_id: '67890' })
    ).toString('base64');
    const token = `header.${payload}.signature`;

    const result = extractUserInfo(token);
    expect(result).toEqual({
      email: 'user@example.com',
      userId: '67890',
      exp: undefined,
    });
  });

  it('should return null for JWT with no meaningful claims', () => {
    // JWT: {"iat":1234567890} (only issued-at, no email/sub/exp)
    const payload = Buffer.from(JSON.stringify({ iat: 1234567890 })).toString('base64');
    const token = `header.${payload}.signature`;

    const result = extractUserInfo(token);
    expect(result).toBe(null);
  });
});

describe('saveCredentials and loadCredentials', () => {
  it('should save and load credentials successfully', () => {
    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'auto-detect',
      importedAt: new Date().toISOString(),
    };

    saveCredentials(credentials);
    const loaded = loadCredentials();

    expect(loaded).toEqual(credentials);
  });

  it('should return null when no credentials file exists', () => {
    const loaded = loadCredentials();
    expect(loaded).toBe(null);
  });

  it('should create directory with restrictive permissions', () => {
    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'manual',
      importedAt: new Date().toISOString(),
    };

    saveCredentials(credentials);

    // CCS_HOME is set to tempDir, but getCcsDir() appends '.ccs' to it
    const credDir = path.join(tempDir, '.ccs', 'cursor');
    expect(fs.existsSync(credDir)).toBe(true);

    // Check directory permissions (skip on Windows)
    if (process.platform !== 'win32') {
      const stats = fs.statSync(credDir);
      const mode = stats.mode & 0o777;
      expect(mode).toBe(0o700);
    }
  });

  it('should return null for invalid JSON in credentials file', () => {
    // CCS_HOME is set to tempDir, getCcsDir() returns path.join(tempDir, '.ccs')
    const credDir = path.join(tempDir, '.ccs', 'cursor');
    const credPath = path.join(credDir, 'credentials.json');
    fs.mkdirSync(credDir, { recursive: true });
    fs.writeFileSync(credPath, 'invalid json{{{');

    const loaded = loadCredentials();
    expect(loaded).toBe(null);
  });

  it('should return null for credentials missing required fields', () => {
    const credDir = path.join(tempDir, '.ccs', 'cursor');
    const credPath = path.join(credDir, 'credentials.json');
    fs.mkdirSync(credDir, { recursive: true });
    fs.writeFileSync(
      credPath,
      JSON.stringify({
        accessToken: 'token',
        // Missing machineId, authMethod, importedAt
      })
    );

    const loaded = loadCredentials();
    expect(loaded).toBe(null);
  });

  it('should return null for credentials with wrong types', () => {
    const credDir = path.join(tempDir, '.ccs', 'cursor');
    const credPath = path.join(credDir, 'credentials.json');
    fs.mkdirSync(credDir, { recursive: true });
    fs.writeFileSync(
      credPath,
      JSON.stringify({
        accessToken: 123, // Wrong type (number instead of string)
        machineId: 'abc',
        authMethod: 'auto-detect',
        importedAt: new Date().toISOString(),
      })
    );

    const loaded = loadCredentials();
    expect(loaded).toBe(null);
  });

  it('should return null for invalid authMethod', () => {
    const credDir = path.join(tempDir, '.ccs', 'cursor');
    const credPath = path.join(credDir, 'credentials.json');
    fs.mkdirSync(credDir, { recursive: true });
    fs.writeFileSync(
      credPath,
      JSON.stringify({
        accessToken: 'token',
        machineId: 'abc',
        authMethod: 'invalid-method', // Invalid authMethod
        importedAt: new Date().toISOString(),
      })
    );

    const loaded = loadCredentials();
    expect(loaded).toBe(null);
  });
});

describe('checkAuthStatus', () => {
  it('should return not authenticated when no credentials exist', () => {
    const status = checkAuthStatus();
    expect(status.authenticated).toBe(false);
    expect(status.credentials).toBeUndefined();
  });

  it('should return authenticated for valid credentials', () => {
    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'auto-detect',
      importedAt: new Date().toISOString(),
    };

    saveCredentials(credentials);
    const status = checkAuthStatus();

    expect(status.authenticated).toBe(true);
    expect(status.credentials).toEqual(credentials);
    expect(status.expired).toBe(false);
    expect(status.tokenAge).toBeDefined();
    expect(status.tokenAge).toBeLessThan(1); // Just imported
  });

  it('should detect expired credentials (importedAt > 24h ago)', () => {
    // Create credentials from 25 hours ago
    const past = new Date();
    past.setHours(past.getHours() - 25);

    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'manual',
      importedAt: past.toISOString(),
    };

    saveCredentials(credentials);
    const status = checkAuthStatus();

    expect(status.authenticated).toBe(true);
    expect(status.expired).toBe(true);
    expect(status.tokenAge).toBeGreaterThanOrEqual(24);
  });

  it('should return not authenticated for invalid token format', () => {
    const credentials: CursorCredentials = {
      accessToken: 'short', // Invalid (too short)
      machineId: 'b'.repeat(32),
      authMethod: 'manual',
      importedAt: new Date().toISOString(),
    };

    saveCredentials(credentials);
    const status = checkAuthStatus();

    expect(status.authenticated).toBe(false);
  });

  it('should use JWT exp claim when available', () => {
    // Create JWT token that expired 1 hour ago
    const expiredTime = Math.floor(Date.now() / 1000) - 3600;
    const payload = Buffer.from(
      JSON.stringify({ email: 'test@example.com', sub: '123', exp: expiredTime })
    ).toString('base64');
    const jwtToken = `header.${payload}.signature`;

    const credentials: CursorCredentials = {
      accessToken: jwtToken,
      machineId: 'b'.repeat(32),
      authMethod: 'auto-detect',
      importedAt: new Date().toISOString(), // Recent import
    };

    saveCredentials(credentials);
    const status = checkAuthStatus();

    expect(status.authenticated).toBe(true);
    expect(status.expired).toBe(true); // Should detect expiry from JWT exp
  });

  it('should handle invalid importedAt date gracefully', () => {
    // Create credentials with valid format but garbage date value
    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'manual',
      importedAt: 'invalid-date-garbage-2026-99-99T99:99:99Z',
    };

    saveCredentials(credentials);
    const status = checkAuthStatus();

    // Should still authenticate if token format is valid
    expect(status.authenticated).toBe(true);
    // tokenAge should be undefined due to invalid date (NaN from getTime())
    expect(status.tokenAge).toBeUndefined();
    // expired should be false (defaults to false when date parsing fails)
    expect(status.expired).toBe(false);
  });
});

describe('deleteCredentials', () => {
  it('should delete existing credentials file and return true', () => {
    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'auto-detect',
      importedAt: new Date().toISOString(),
    };

    saveCredentials(credentials);
    expect(loadCredentials()).not.toBe(null);

    const result = deleteCredentials();
    expect(result).toBe(true);
    expect(loadCredentials()).toBe(null);
  });

  it('should return false when credentials file does not exist', () => {
    const result = deleteCredentials();
    expect(result).toBe(false);
  });

  it('should handle multiple delete calls gracefully', () => {
    const credentials: CursorCredentials = {
      accessToken: 'a'.repeat(50),
      machineId: 'b'.repeat(32),
      authMethod: 'manual',
      importedAt: new Date().toISOString(),
    };

    saveCredentials(credentials);

    // First delete should succeed
    expect(deleteCredentials()).toBe(true);

    // Second delete should return false (already deleted)
    expect(deleteCredentials()).toBe(false);
  });
});

describe('autoDetectTokens', () => {
  it('should report checked paths when no Windows database exists', () => {
    const originalPlatform = process.platform;
    const originalUserProfile = process.env.USERPROFILE;
    const originalAppData = process.env.APPDATA;
    const originalLocalAppData = process.env.LOCALAPPDATA;
    const fakeHome = path.join(tempDir, 'win-home');
    process.env.USERPROFILE = fakeHome;
    delete process.env.APPDATA;
    delete process.env.LOCALAPPDATA;

    Object.defineProperty(process, 'platform', {
      value: 'win32',
      configurable: true,
    });

    try {
      const result = autoDetectTokens();

      expect(result.found).toBe(false);
      expect(result.reason).toBe('db_not_found');
      expect(result.checkedPaths?.length).toBeGreaterThan(0);
      expect(result.error).toContain('Checked:');
    } finally {
      Object.defineProperty(process, 'platform', {
        value: originalPlatform,
        configurable: true,
      });
      if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile;
      else delete process.env.USERPROFILE;
      if (originalAppData !== undefined) process.env.APPDATA = originalAppData;
      else delete process.env.APPDATA;
      if (originalLocalAppData !== undefined) process.env.LOCALAPPDATA = originalLocalAppData;
      else delete process.env.LOCALAPPDATA;
    }
  });

  it('should return not found when database file does not exist', () => {
    // Skip on Windows (already covered by previous test)
    if (process.platform === 'win32') {
      return;
    }

    const originalHome = process.env.HOME;
    const isolatedHome = path.join(tempDir, 'no-cursor-home');
    process.env.HOME = isolatedHome;

    try {
      const result = autoDetectTokens();

      expect(result.found).toBe(false);
      expect(result.reason).toBe('db_not_found');
      expect(result.checkedPaths?.length).toBeGreaterThan(0);
      expect(result.error).toContain('Checked:');
    } finally {
      if (originalHome !== undefined) {
        process.env.HOME = originalHome;
      } else {
        delete process.env.HOME;
      }
    }
  });

  it('should have found property in return type', () => {
    const result = autoDetectTokens();

    // Verify return type structure
    expect(result).toHaveProperty('found');
    expect(typeof result.found).toBe('boolean');
  });

  it('should report sqlite_unavailable when database exists but sqlite3 is missing', () => {
    const originalHome = process.env.HOME;
    const originalPath = process.env.PATH;
    const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN;
    const fakeHome = path.join(tempDir, 'sqlite-missing-home');
    process.env.HOME = fakeHome;
    process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3';

    try {
      const dbPath = getTokenStorageCandidates()[0];
      fs.mkdirSync(path.dirname(dbPath), { recursive: true });
      fs.writeFileSync(dbPath, '');

      const result = autoDetectTokens();
      expect(result.found).toBe(false);
      expect(result.reason).toBe('sqlite_unavailable');
      expect(result.dbPath).toBe(dbPath);
    } finally {
      if (originalHome !== undefined) process.env.HOME = originalHome;
      else delete process.env.HOME;
      if (originalPath !== undefined) process.env.PATH = originalPath;
      else delete process.env.PATH;
      if (originalSqliteBin !== undefined) process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin;
      else delete process.env.CCS_CURSOR_SQLITE_BIN;
    }
  });

  it('should use fallback keys and normalize JSON-encoded sqlite values', () => {
    if (process.platform === 'win32') return;

    try {
      execFileSync('sqlite3', ['--version'], { stdio: 'ignore' });
    } catch {
      return;
    }

    const originalHome = process.env.HOME;
    const fakeHome = path.join(tempDir, 'sqlite-fallback-home');
    process.env.HOME = fakeHome;

    try {
      const dbPath = getTokenStorageCandidates()[0];
      fs.mkdirSync(path.dirname(dbPath), { recursive: true });
      execFileSync('sqlite3', [
        dbPath,
        'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);',
      ]);
      execFileSync('sqlite3', [
        dbPath,
        `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/token', '"${'a'.repeat(60)}"');`,
      ]);
      execFileSync('sqlite3', [
        dbPath,
        `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.machineId', '"1234567890abcdef1234567890abcdef"');`,
      ]);

      const result = autoDetectTokens();
      expect(result.found).toBe(true);
      expect(result.accessToken).toBe('a'.repeat(60));
      expect(result.machineId).toBe('1234567890abcdef1234567890abcdef');
    } finally {
      if (originalHome !== undefined) process.env.HOME = originalHome;
      else delete process.env.HOME;
    }
  });

  it('should continue scanning later database candidates after one invalid credential pair', () => {
    if (process.platform !== 'darwin') {
      return;
    }

    try {
      execFileSync('sqlite3', ['--version'], { stdio: 'ignore' });
    } catch {
      return;
    }

    const originalHome = process.env.HOME;
    const fakeHome = path.join(tempDir, 'candidate-scan-home');
    process.env.HOME = fakeHome;

    try {
      const [stablePath, insidersPath] = getTokenStorageCandidates();
      fs.mkdirSync(path.dirname(stablePath), { recursive: true });
      fs.mkdirSync(path.dirname(insidersPath), { recursive: true });

      execFileSync('sqlite3', [
        stablePath,
        'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);',
      ]);
      execFileSync('sqlite3', [
        stablePath,
        `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', 'short');`,
      ]);
      execFileSync('sqlite3', [
        stablePath,
        `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', 'bad');`,
      ]);

      execFileSync('sqlite3', [
        insidersPath,
        'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);',
      ]);
      execFileSync('sqlite3', [
        insidersPath,
        `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', '${'a'.repeat(60)}');`,
      ]);
      execFileSync('sqlite3', [
        insidersPath,
        `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', '1234567890abcdef1234567890abcdef');`,
      ]);

      const result = autoDetectTokens();
      expect(result.found).toBe(true);
      expect(result.dbPath).toBe(insidersPath);
    } finally {
      if (originalHome !== undefined) process.env.HOME = originalHome;
      else delete process.env.HOME;
    }
  });

  it('should report db_query_failed when the database exists but sqlite cannot query it', () => {
    if (process.platform === 'win32') return;

    try {
      execFileSync('sqlite3', ['--version'], { stdio: 'ignore' });
    } catch {
      return;
    }

    const originalHome = process.env.HOME;
    const fakeHome = path.join(tempDir, 'query-failed-home');
    process.env.HOME = fakeHome;

    try {
      const dbPath = getTokenStorageCandidates()[0];
      fs.mkdirSync(path.dirname(dbPath), { recursive: true });
      fs.writeFileSync(dbPath, 'not-a-sqlite-database');

      const result = autoDetectTokens();
      expect(result.found).toBe(false);
      expect(result.reason).toBe('db_query_failed');
    } finally {
      if (originalHome !== undefined) process.env.HOME = originalHome;
      else delete process.env.HOME;
    }
  });
});
