/**
 * Contract Validation Tests for API
 * 
 * These tests validate that the API DTOs and OpenAPI spec are consistent
 * and that the generated types will be compatible with the website.
 */

import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

interface OpenAPISchema {
  type?: string;
  format?: string;
  $ref?: string;
  items?: OpenAPISchema;
  properties?: Record<string, OpenAPISchema>;
  required?: string[];
  enum?: string[];
  nullable?: boolean;
  description?: string;
  default?: unknown;
}

interface OpenAPISpec {
  openapi: string;
  info: {
    title: string;
    description: string;
    version: string;
  };
  paths: Record<string, any>;
  components: {
    schemas: Record<string, OpenAPISchema>;
  };
}

describe('API Contract Validation', () => {
  const apiRoot = path.join(__dirname, '../..'); // /Users/marcmintel/Projects/gridpilot
  const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
  const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
  const execFileAsync = promisify(execFile);

  describe('OpenAPI Spec Integrity', () => {
    it('should have a valid OpenAPI spec file', async () => {
      const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
      expect(specExists).toBe(true);
    });

    it('should have a valid JSON structure', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      expect(() => JSON.parse(content)).not.toThrow();
    });

    it('should have required OpenAPI fields', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);

      expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
      expect(spec.info).toBeDefined();
      expect(spec.info.title).toBeDefined();
      expect(spec.info.version).toBeDefined();
      expect(spec.components).toBeDefined();
      expect(spec.components.schemas).toBeDefined();
    });

    it('committed openapi.json should match generator output', async () => {
      const repoRoot = apiRoot; // Already at the repo root

      const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-'));
      const generatedOpenapiPath = path.join(tmpDir, 'openapi.json');

      await execFileAsync(
        'npx',
        ['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath],
        { cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
      );

      const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8'));
      const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8'));

      expect(generated).toEqual(committed);
    });

    it('should include real HTTP paths for known routes', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);

      const pathKeys = Object.keys(spec.paths ?? {});
      expect(pathKeys.length).toBeGreaterThan(0);

      // A couple of stable routes to detect "empty/stale" specs.
      expect(spec.paths['/drivers/leaderboard']).toBeDefined();
      expect(spec.paths['/dashboard/overview']).toBeDefined();

      // Sanity-check the operation objects exist (method keys are lowercase in OpenAPI).
      expect(spec.paths['/drivers/leaderboard'].get).toBeDefined();
      expect(spec.paths['/dashboard/overview'].get).toBeDefined();
    });

    it('should include league schedule publish/unpublish endpoints and published state', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);

      expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined();
      expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined();

      expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined();
      expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined();

      const scheduleSchema = spec.components.schemas['LeagueScheduleDTO'];
      if (!scheduleSchema) {
        throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec');
      }

      expect(scheduleSchema.properties?.published).toBeDefined();
      expect(scheduleSchema.properties?.published?.type).toBe('boolean');
      expect(scheduleSchema.required ?? []).toContain('published');
    });

    it('should include league roster admin read endpoints and schemas', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);

      expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined();
      expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined();

      expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined();
      expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined();

      expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined();
      expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined();

      expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined();
      expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined();

      const memberSchema = spec.components.schemas['LeagueRosterMemberDTO'];
      if (!memberSchema) {
        throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec');
      }

      expect(memberSchema.properties?.driverId).toBeDefined();
      expect(memberSchema.properties?.role).toBeDefined();
      expect(memberSchema.properties?.joinedAt).toBeDefined();
      expect(memberSchema.required ?? []).toContain('driverId');
      expect(memberSchema.required ?? []).toContain('role');
      expect(memberSchema.required ?? []).toContain('joinedAt');
      expect(memberSchema.required ?? []).toContain('driver');

      const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO'];
      if (!joinRequestSchema) {
        throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec');
      }

      expect(joinRequestSchema.properties?.id).toBeDefined();
      expect(joinRequestSchema.properties?.leagueId).toBeDefined();
      expect(joinRequestSchema.properties?.driverId).toBeDefined();
      expect(joinRequestSchema.properties?.requestedAt).toBeDefined();
      expect(joinRequestSchema.required ?? []).toContain('id');
      expect(joinRequestSchema.required ?? []).toContain('leagueId');
      expect(joinRequestSchema.required ?? []).toContain('driverId');
      expect(joinRequestSchema.required ?? []).toContain('requestedAt');
      expect(joinRequestSchema.required ?? []).toContain('driver');
    });

    it('should have no circular references in schemas', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);
      const schemas = spec.components.schemas;

      const visited = new Set<string>();
      const visiting = new Set<string>();

      function detectCircular(schemaName: string): boolean {
        if (visiting.has(schemaName)) return true;
        if (visited.has(schemaName)) return false;

        visiting.add(schemaName);
        const schema = schemas[schemaName];
        
        if (!schema) {
          visiting.delete(schemaName);
          visited.add(schemaName);
          return false;
        }

        // Check properties for references
        if (schema.properties) {
          for (const prop of Object.values(schema.properties)) {
            if (prop.$ref) {
              const refName = prop.$ref.split('/').pop();
              if (refName && detectCircular(refName)) {
                return true;
              }
            }
            if (prop.items?.$ref) {
              const refName = prop.items.$ref.split('/').pop();
              if (refName && detectCircular(refName)) {
                return true;
              }
            }
          }
        }

        visiting.delete(schemaName);
        visited.add(schemaName);
        return false;
      }

      for (const schemaName of Object.keys(schemas)) {
        expect(detectCircular(schemaName)).toBe(false);
      }
    });
  });

  describe('DTO Consistency', () => {
    it('should have generated DTO files for critical schemas', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);

      const generatedFiles = await fs.readdir(generatedTypesDir);
      const generatedDTOs = generatedFiles
        .filter(f => f.endsWith('.ts'))
        .map(f => f.replace('.ts', ''));

      // We intentionally do NOT require a 1:1 mapping for *all* schemas here.
      // OpenAPI generation and type generation can be run as separate steps,
      // and new schemas should not break API contract validation by themselves.
      const criticalDTOs = [
        'RequestAvatarGenerationInputDTO',
        'RequestAvatarGenerationOutputDTO',
        'UploadMediaInputDTO',
        'UploadMediaOutputDTO',
        'RaceDTO',
        'DriverDTO',
      ];

      for (const dtoName of criticalDTOs) {
        expect(spec.components.schemas[dtoName]).toBeDefined();
        expect(generatedDTOs).toContain(dtoName);
      }
    });

    it('should have consistent property types between DTOs and schemas', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);
      const schemas = spec.components.schemas;

      for (const [schemaName, schema] of Object.entries(schemas)) {
        const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
        const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
        
        if (!dtoExists) continue;

        const dtoContent = await fs.readFile(dtoPath, 'utf-8');
        
        // Check that all required properties are present
        if (schema.required) {
          for (const requiredProp of schema.required) {
            expect(dtoContent).toContain(requiredProp);
          }
        }

        // Check that all properties are present
        if (schema.properties) {
          for (const propName of Object.keys(schema.properties)) {
            expect(dtoContent).toContain(propName);
          }
        }
      }
    });
  });

  describe('Type Generation Integrity', () => {
    it('should have valid TypeScript syntax in generated files', async () => {
      const files = await fs.readdir(generatedTypesDir);
      const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));

      for (const file of dtos) {
        const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');

        // `index.ts` is a generated barrel file (no interfaces).
        if (file === 'index.ts') {
          expect(content).toContain('export type {');
          expect(content).toContain("from './");
          continue;
        }

        // Basic TypeScript syntax checks (DTO interfaces)
        expect(content).toContain('export interface');
        expect(content).toContain('{');
        expect(content).toContain('}');

        // Should not have syntax errors (basic check)
        expect(content).not.toContain('undefined;');
        expect(content).not.toContain('any;');
      }
    });

    it('should have proper imports for dependencies', async () => {
      const files = await fs.readdir(generatedTypesDir);
      const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));

      for (const file of dtos) {
        const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
        const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
        
        for (const importLine of importMatches) {
          const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
          if (match) {
            const [, importedType, fromFile] = match;
            expect(importedType).toBe(fromFile);
            
            // Check that the imported file exists
            const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
            const exists = await fs.access(importedPath).then(() => true).catch(() => false);
            expect(exists).toBe(true);
          }
        }
      }
    });
  });

  describe('Contract Compatibility', () => {
    it('should maintain backward compatibility for existing DTOs', async () => {
      // This test ensures that when regenerating types, existing properties aren't removed
      // unless explicitly intended
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);
      
      // Check critical DTOs that are likely used in production
      const criticalDTOs = [
        'RequestAvatarGenerationInputDTO',
        'RequestAvatarGenerationOutputDTO',
        'UploadMediaInputDTO',
        'UploadMediaOutputDTO',
        'RaceDTO',
        'DriverDTO'
      ];

      for (const dtoName of criticalDTOs) {
        if (spec.components.schemas[dtoName]) {
          const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
          const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
          expect(exists).toBe(true);
        }
      }
    });

    it('should handle nullable fields correctly', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);
      const schemas = spec.components.schemas;

      for (const [, schema] of Object.entries(schemas)) {
        const required = new Set(schema.required ?? []);
        if (!schema.properties) continue;

        for (const [propName, propSchema] of Object.entries(schema.properties)) {
          if (!propSchema.nullable) continue;

          // In OpenAPI 3.0, a `nullable: true` property should not be listed as required,
          // otherwise downstream generators can't represent it safely.
          expect(required.has(propName)).toBe(false);
        }
      }
    });

    it('should have no empty string defaults for avatar/logo URLs', async () => {
      const content = await fs.readFile(openapiPath, 'utf-8');
      const spec: OpenAPISpec = JSON.parse(content);
      const schemas = spec.components.schemas;

      // Check DTOs that should use URL|null pattern
      const mediaRelatedDTOs = [
        'GetAvatarOutputDTO',
        'UpdateAvatarInputDTO',
        'DashboardDriverSummaryDTO',
        'DriverProfileDriverSummaryDTO',
        'DriverLeaderboardItemDTO',
        'TeamListItemDTO',
        'LeagueSummaryDTO',
        'SponsorDTO',
      ];

      for (const dtoName of mediaRelatedDTOs) {
        const schema = schemas[dtoName];
        if (!schema || !schema.properties) continue;

        // Check for avatarUrl, logoUrl properties
        for (const [propName, propSchema] of Object.entries(schema.properties)) {
          if (propName === 'avatarUrl' || propName === 'logoUrl') {
            // Should be string type, nullable (no empty string defaults)
            expect(propSchema.type).toBe('string');
            expect(propSchema.nullable).toBe(true);
            // Should not have default value of empty string
            if (propSchema.default !== undefined) {
              expect(propSchema.default).not.toBe('');
            }
          }
        }
      }
    });
  });
});