import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseMigrationsService } from './database-migrations.service';
import { getDataSourceToken } from '@nestjs/typeorm';

interface MockColumn {
  name: string;
}

interface MockTable {
  name: string;
  columns: MockColumn[];
}
interface MockRunner {
  release: jest.Mock;
  connect: jest.Mock;
  query: jest.Mock;
  getTables: jest.Mock;
  getTable: jest.Mock;
}

function mockRunner(overrides: {
  getTables?: MockTable[];
  getTable?: MockTable;
  queryError?: Error;
} = {}) {
  const release = jest.fn();
  const connect = jest.fn();
  const query = jest.fn().mockResolvedValue([]);
  const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
  const getTable = jest.fn().mockResolvedValue(
    overrides.getTable ?? { name: 'ai_config', columns: [] },
  );

  if (overrides.queryError) {
    query.mockRejectedValue(overrides.queryError);
  }

  return { release, connect, query, getTables, getTable } satisfies MockRunner;
}

function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') {
  return {
    options: { type: dbType },
    createQueryRunner: jest.fn().mockReturnValue(runner),
    transaction: jest.fn(),
  };
}

// Type to reach private migration methods for testing
interface MigrationsPrivate {
  ensureAiConfigTable(): Promise<void>;
  ensureSyncStateLeaseColumns(): Promise<void>;
  backfillOrganizations(): Promise<void>;
  normalizeClassDates(): Promise<void>;
  ensureCourseAttendanceSchema(): Promise<void>;
  protectAttendanceHistory(): Promise<void>;
  removeUnusedClassroomColumns(): Promise<void>;
}

describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
  let service: MigrationsPrivate & DatabaseMigrationsService;

  async function bootstrap(runner: MockRunner) {
    const dataSource = createDataSource(runner);
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DatabaseMigrationsService,
        { provide: getDataSourceToken(), useValue: dataSource },
      ],
    }).compile();
    service = module.get(
      DatabaseMigrationsService,
    );
  }

  it('creates table + index when ai_config does not exist', async () => {
    const runner = mockRunner({ getTables: [] });
    await bootstrap(runner);
    await service.ensureAiConfigTable();

    expect(runner.connect).toHaveBeenCalled();
    expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config'));
    expect(runner.query).toHaveBeenCalledWith(
      expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'),
    );
    expect(runner.release).toHaveBeenCalled();
  });

  it('skips ALTER when table exists with all columns', async () => {
    const allColumns: MockColumn[] = [
      { name: 'id' },
      { name: 'singleton_key' },
      { name: 'provider' },
      { name: 'base_url' },
      { name: 'encrypted_api_key' },
      { name: 'api_key_iv' },
      { name: 'api_key_auth_tag' },
      { name: 'key_last4' },
      { name: 'default_model' },
      { name: 'enabled' },
      { name: 'timeout_ms' },
      { name: 'verified' },
      { name: 'last_tested_at' },
      { name: 'last_test_latency_ms' },
      { name: 'created_at' },
      { name: 'updated_at' },
    ];

    const runner = mockRunner({
      getTables: [{ name: 'ai_config', columns: allColumns }],
      getTable: { name: 'ai_config', columns: allColumns },
    });
    await bootstrap(runner);
    await service.ensureAiConfigTable();

    expect(runner.connect).toHaveBeenCalled();
    // Should NOT issue any ALTER TABLE
    const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
      (c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'),
    );
    expect(alterCalls).toHaveLength(0);
    expect(runner.release).toHaveBeenCalled();
  });

  it('adds missing column via ALTER TABLE', async () => {
    // Table has most columns but is missing last_test_latency_ms
    const missingOne: MockColumn[] = [
      { name: 'id' },
      { name: 'singleton_key' },
      { name: 'provider' },
      { name: 'base_url' },
      { name: 'encrypted_api_key' },
      { name: 'api_key_iv' },
      { name: 'api_key_auth_tag' },
      { name: 'key_last4' },
      { name: 'default_model' },
      { name: 'enabled' },
      { name: 'timeout_ms' },
      { name: 'verified' },
      { name: 'last_tested_at' },
      // last_test_latency_ms missing
      { name: 'created_at' },
      { name: 'updated_at' },
    ];

    const runner = mockRunner({
      getTables: [{ name: 'ai_config', columns: missingOne }],
      getTable: { name: 'ai_config', columns: missingOne },
    });
    await bootstrap(runner);
    await service.ensureAiConfigTable();

    expect(runner.connect).toHaveBeenCalled();
    expect(runner.query).toHaveBeenCalledWith(
      expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'),
    );
    expect(runner.release).toHaveBeenCalled();
  });

  it('releases runner even when query throws', async () => {
    const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') });
    await bootstrap(runner);

    await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM');
    expect(runner.release).toHaveBeenCalled();
  });
});

describe('DatabaseMigrationsService — bootstrap failure handling', () => {
  it('fails application bootstrap when the required ai_config migration fails', async () => {
    const runner = mockRunner();
    const dataSource = createDataSource(runner);
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DatabaseMigrationsService,
        { provide: getDataSourceToken(), useValue: dataSource },
      ],
    }).compile();
    const service = module.get(DatabaseMigrationsService);

    jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed'));
    const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue();
    const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue();

    await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed');
    expect(backfill).not.toHaveBeenCalled();
    expect(normalize).not.toHaveBeenCalled();
  });
});

describe('DatabaseMigrationsService — course attendance schema', () => {
  it('adds schedule linkage columns to an existing attendance_records table', async () => {
    const runner = mockRunner({
      getTables: [
        { name: 'attendance_records', columns: [{ name: 'id' }] },
        { name: 'attendance_sessions', columns: [{ name: 'id' }] },
      ],
      getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
    });
    const service = await bootstrapCourseAttendance(runner);

    await service.ensureCourseAttendanceSchema();

    expect(runner.query).toHaveBeenCalledWith(
      expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'),
    );
    expect(runner.query).toHaveBeenCalledWith(
      expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'),
    );
    expect(runner.release).toHaveBeenCalled();
  });

  it('adds the configurable attendance window to existing schedules', async () => {
    const runner = mockRunner({
      getTables: [
        { name: 'class_schedule', columns: [{ name: 'id' }] },
        { name: 'attendance_records', columns: [{ name: 'id' }] },
        { name: 'attendance_sessions', columns: [{ name: 'id' }] },
      ],
    });
    runner.getTable.mockImplementation(async (name: string) =>
      name === 'class_schedule'
        ? { name, columns: [{ name: 'id' }] }
        : { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
    );
    const service = await bootstrapCourseAttendance(runner);

    await service.ensureCourseAttendanceSchema();

    expect(runner.query).toHaveBeenCalledWith(
      expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'),
    );
  });

  it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
      getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
    });
    const service = await bootstrapCourseAttendance(runner);
    await service.ensureCourseAttendanceSchema();

    const createSql: string = (runner.query as jest.Mock).mock.calls
      .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
      .find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? '';
    expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT');
    expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT');
  });
});

describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
  let service: MigrationsPrivate & DatabaseMigrationsService;

  async function bootstrap(runner: MockRunner, dbType: string = 'better-sqlite3') {
    const dataSource = createDataSource(runner, dbType);
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DatabaseMigrationsService,
        { provide: getDataSourceToken(), useValue: dataSource },
      ],
    }).compile();
    service = module.get(DatabaseMigrationsService);
  }

  it('skips when attendance_sessions table is absent', async () => {
    const runner = mockRunner({ getTables: [] });
    await bootstrap(runner);
    await service.protectAttendanceHistory();
    expect(runner.query).not.toHaveBeenCalled();
    expect(runner.release).toHaveBeenCalled();
  });

  it('SQLite: exits early when FKs already exist', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
    });
    runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows
    await bootstrap(runner);
    await service.protectAttendanceHistory();

    // Should not run any TABLE creation (rebuild)
    const queries: string[] = (runner.query as jest.Mock).mock.calls
      .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
    expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
    expect(runner.release).toHaveBeenCalled();
  });

  it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
    });
    // PRAGMA foreign_key_list for attendance_sessions → empty
    runner.query.mockResolvedValueOnce([]);
    // PRAGMA foreign_key_list for attendance_records → also empty (no FK yet)
    runner.query.mockResolvedValueOnce([]);
    await bootstrap(runner);
    await service.protectAttendanceHistory();

    const queries: string[] = (runner.query as jest.Mock).mock.calls
      .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));

    // PRAGMA foreign_keys = OFF outside the transaction
    expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true);
    expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true);
    expect(queries.some((q: string) =>
      q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT')
    )).toBe(true);
    expect(queries.some((q: string) =>
      q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT')
    )).toBe(true);
    expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true);
    expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true);
    expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true);
    expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true);
    // attendance_records rebuilt with FK
    expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true);
    expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true);
    expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true);
    expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true);
    // PRAGMA foreign_keys restored to ON and foreign_key_check runs
    expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
    expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true);
    expect(runner.release).toHaveBeenCalled();
  });

  it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
    });
    // Use mockImplementation to match by SQL content, not call position
    runner.query.mockImplementation((sql: string) => {
      if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) {
        return Promise.resolve([]); // FKs absent → trigger rebuild
      }
      if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) {
        return Promise.resolve([
          { table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 },
        ]);
      }
      return Promise.resolve([]);
    });
    await bootstrap(runner);

    await expect(service.protectAttendanceHistory()).rejects.toThrow(
      /外键一致性检查失败/,
    );

    const queries: string[] = (runner.query as jest.Mock).mock.calls
      .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));

    // The transaction should have been rolled back (ROLLBACK called)
    expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
    // COMMIT should NOT have been called
    expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false);
    // PRAGMA foreign_keys should still be restored
    expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
    expect(runner.release).toHaveBeenCalled();
  });
  it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
    });
    // Mock: override SELECT CONSTRAINT_NAME and REFERENTIAL_CONSTRAINTS queries
    runner.query.mockImplementation((sql: string, params?: string[]) => {
      if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
        if (params?.[0] === 'schedule_id') {
          return Promise.resolve([{ CONSTRAINT_NAME: 'fk_schedule_cascade' }]);
        }
        if (params?.[0] === 'class_id') {
          return Promise.resolve([{ CONSTRAINT_NAME: 'fk_class_cascade' }]);
        }
      }
      // REFERENTIAL_CONSTRAINTS check — constraint does not yet exist
      if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
        return Promise.resolve([]);
      }
      return Promise.resolve([]);
    });
    await bootstrap(runner, 'mysql');
    await service.protectAttendanceHistory();

    const queries: string[] = (runner.query as jest.Mock).mock.calls
      .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));

    // Drops old FKs
    expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true);
    expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true);
    // Checks REFERENTIAL_CONSTRAINTS before ADD
    expect(queries.some((q: string) =>
      q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')
    )).toBe(true);
    // Creates new RESTRICT FKs
    expect(queries.some((q: string) =>
      q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT')
    )).toBe(true);
    expect(queries.some((q: string) =>
      q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT')
    )).toBe(true);
    expect(runner.release).toHaveBeenCalled();
  });

  it('MySQL: throws when ADD CONSTRAINT RESTRICT fails', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
    });
    const addError = new Error('Cannot add foreign key constraint');
    runner.query.mockImplementation((sql: string, params?: string[]) => {
      if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
        return Promise.resolve([]);
      }
      if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
        return Promise.resolve([]);
      }
      if (typeof sql === 'string' && sql.includes('ADD CONSTRAINT')) {
        return Promise.reject(addError);
      }
      return Promise.resolve([]);
    });
    await bootstrap(runner, 'mysql');
    await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint');
    expect(runner.release).toHaveBeenCalled();
  });

  it('MySQL: skips ADD when RESTRICT constraint already confirmed via information_schema', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
    });
    runner.query.mockImplementation((sql: string, params?: string[]) => {
      if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
        return Promise.resolve([]);
      }
      // REFERENTIAL_CONSTRAINTS confirms RESTRICT already present
      if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
        return Promise.resolve([{ DELETE_RULE: 'RESTRICT' }]);
      }
      return Promise.resolve([]);
    });
    await bootstrap(runner, 'mysql');
    await service.protectAttendanceHistory();

    const queries: string[] = (runner.query as jest.Mock).mock.calls
      .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));

    // No ADD CONSTRAINT calls
    expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0);
    expect(runner.release).toHaveBeenCalled();
  });
});

describe('DatabaseMigrationsService — classroom cleanup', () => {
  let cleanupService: MigrationsPrivate & DatabaseMigrationsService;

  async function bootstrapClassroomCleanup(runner: MockRunner) {
    const dataSource = createDataSource(runner);
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DatabaseMigrationsService,
        { provide: getDataSourceToken(), useValue: dataSource },
      ],
    }).compile();
    cleanupService = module.get(DatabaseMigrationsService);
  }

  it('drops legacy classroom fields when present', async () => {
    const runner = mockRunner({
      getTables: [{ name: 'classrooms', columns: [] }],
      getTable: {
        name: 'classrooms',
        columns: [{ name: 'id' }, { name: 'course_type' }, { name: 'supervisor' }],
      },
    });
    await bootstrapClassroomCleanup(runner);

    await cleanupService.removeUnusedClassroomColumns();

    expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN course_type');
    expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN supervisor');
    expect(runner.release).toHaveBeenCalled();
  });

  it('does nothing when the classrooms table is absent', async () => {
    const runner = mockRunner({ getTables: [] });
    await bootstrapClassroomCleanup(runner);

    await cleanupService.removeUnusedClassroomColumns();

    expect(runner.query).not.toHaveBeenCalled();
    expect(runner.release).toHaveBeenCalled();
  });
});

async function bootstrapCourseAttendance(runner: MockRunner) {
  const dataSource = createDataSource(runner);
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      DatabaseMigrationsService,
      { provide: getDataSourceToken(), useValue: dataSource },
    ],
  }).compile();
  return module.get<MigrationsPrivate & DatabaseMigrationsService>(DatabaseMigrationsService);
}
