#!/usr/bin/env tsx
/**
 * Generate OpenAPI spec from NestJS DTO classes
 * 
 * This script scans all DTO files and extracts OpenAPI schema definitions
 * using the @nestjs/swagger metadata.
 */

import 'reflect-metadata';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';

// OpenAPI schema types
interface OpenAPISchema {
  type?: string;
  format?: string;
  $ref?: string;
  items?: OpenAPISchema;
  properties?: Record<string, OpenAPISchema>;
  additionalProperties?: OpenAPISchema;
  required?: string[];
  enum?: string[];
  nullable?: boolean;
  description?: string;
}

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

function getCliArgValue(flag: string): string | undefined {
  const index = process.argv.indexOf(flag);
  if (index === -1) return undefined;
  return process.argv[index + 1];
}

function resolveOutputPath(): string {
  const cliOutput = getCliArgValue('--output') ?? getCliArgValue('-o');
  const envOutput = process.env.OPENAPI_OUTPUT_PATH;
  const configured = cliOutput ?? envOutput;

  if (!configured) {
    return path.join(process.cwd(), 'apps/api/openapi.json');
  }

  return path.resolve(process.cwd(), configured);
}

async function generateSpec() {
  console.log('🔄 Generating OpenAPI spec from DTO files...');

  const schemas: Record<string, OpenAPISchema> = {};

  // Find all DTO files (sorted for deterministic output)
  const dtoFiles = await glob('apps/api/src/domain/*/dtos/**/*.ts', {
    cwd: process.cwd()
  });
  dtoFiles.sort((a, b) => a.localeCompare(b));

  console.log(`📁 Found ${dtoFiles.length} DTO files to process`);

  for (const dtoFile of dtoFiles) {
    try {
      await processDTOFile(path.join(process.cwd(), dtoFile), schemas);
    } catch (e: any) {
      console.warn(`⚠️ Could not process ${dtoFile}: ${e.message}`);
    }
  }

  const paths = await extractPathsFromControllers();

  const spec: OpenAPISpec = {
    openapi: '3.0.0',
    info: {
      title: 'GridPilot API',
      description: 'GridPilot API documentation',
      version: '1.0.0'
    },
    paths,
    components: {
      schemas: sortRecordKeys(schemas)
    }
  };

  const outputPath = resolveOutputPath();
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
  await fs.writeFile(outputPath, JSON.stringify(spec, null, 2));
  console.log(`✅ OpenAPI spec generated with ${Object.keys(schemas).length} schemas at: ${outputPath}`);
}

function sortRecordKeys<T>(record: Record<string, T>): Record<string, T> {
  return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
}

function joinRouteParts(controllerPath: string, methodPath: string): string {
  const base = controllerPath.replace(/^\/+|\/+$/g, '');
  const sub = methodPath.replace(/^\/+|\/+$/g, '');
  const joined = [base, sub].filter(Boolean).join('/');
  return `/${joined}`;
}

function toOpenApiPath(route: string): string {
  // Convert Nest-style ":param" to OpenAPI "{param}"
  return route.replace(/(^|\/):([^/]+)/g, '$1{$2}');
}

async function extractPathsFromControllers(): Promise<Record<string, any>> {
  const controllerFiles = await glob('apps/api/src/domain/**/*Controller.ts', {
    cwd: process.cwd(),
  });
  controllerFiles.sort((a, b) => a.localeCompare(b));

  const paths: Record<string, any> = {};

  for (const controllerFile of controllerFiles) {
    const filePath = path.join(process.cwd(), controllerFile);
    const content = await fs.readFile(filePath, 'utf-8');

    const controllerMatch = content.match(/@Controller\(\s*['"]([^'"]+)['"]\s*\)/);
    if (!controllerMatch) continue;

    const controllerPath = controllerMatch[1] ?? '';
    const methodRegex = /@(Get|Post|Put|Patch|Delete)\(\s*(?:['"]([^'"]*)['"])?\s*\)/g;

    let match: RegExpExecArray | null;
    while ((match = methodRegex.exec(content)) !== null) {
      const httpMethod = match[1]?.toLowerCase();
      if (!httpMethod) continue;

      const methodPath = match[2] ?? '';
      const route = joinRouteParts(controllerPath, methodPath);
      const openapiPath = toOpenApiPath(route);

      paths[openapiPath] ??= {};
      paths[openapiPath][httpMethod] ??= {
        responses: {
          '200': { description: 'OK' },
        },
      };
    }
  }

  return sortRecordKeys(paths);
}

async function processDTOFile(filePath: string, schemas: Record<string, OpenAPISchema>) {
  const content = await fs.readFile(filePath, 'utf-8');

  // Find exported class definitions with DTO suffix.
  // NOTE: We cannot use a naive regex to capture the full class body because
  // decorators often contain object literals with braces (e.g. @ApiProperty({ ... })).
  // Instead, we locate the opening brace and then parse using a simple brace counter.
  const classRegex = /export\s+class\s+(\w+)\b/g;
  let classMatch: RegExpExecArray | null;

  while ((classMatch = classRegex.exec(content)) !== null) {
    const className = classMatch[1];
    if (!className.endsWith('DTO') && !className.endsWith('Dto')) {
      continue;
    }

    const declStartIndex = classMatch.index;
    const braceOpenIndex = content.indexOf('{', classRegex.lastIndex);
    if (braceOpenIndex === -1) {
      continue;
    }

    // Walk forward to find the matching closing brace.
    // This is intentionally simple: it counts braces and does not attempt to fully
    // understand strings/comments. It's sufficient for our DTO files where braces
    // are primarily used for TS blocks and decorator object literals.
    let depth = 0;
    let i = braceOpenIndex;
    let braceCloseIndex = -1;

    for (; i < content.length; i++) {
      const ch = content[i];
      if (ch === '{') depth++;
      if (ch === '}') depth--;

      if (depth === 0) {
        braceCloseIndex = i;
        break;
      }
    }

    if (braceCloseIndex === -1) {
      console.warn(`  ⚠️  Could not find closing brace for ${className} in ${filePath}`);
      continue;
    }

    const classBody = content.slice(braceOpenIndex + 1, braceCloseIndex);

    // Normalize class name to always use DTO suffix (not Dto)
    const normalizedName = className.endsWith('Dto') ?
      className.slice(0, -3) + 'DTO' : className;

    console.log(`  📝 Processing ${className} -> ${normalizedName}`);

    // Check for conflicts
    if (schemas[normalizedName]) {
      console.warn(`  ⚠️  Conflict: ${normalizedName} already exists. Skipping duplicate from ${filePath}`);
      continue;
    }

    const schema = extractSchemaFromClassBody(classBody, content);
    if (schema && Object.keys(schema.properties || {}).length > 0) {
      schemas[normalizedName] = schema;
      console.log(`  ✅ Added ${normalizedName} with ${Object.keys(schema.properties || {}).length} properties`);
    }
  }
}

function extractSchemaFromClassBody(classBody: string, fullContent: string): OpenAPISchema {
  const properties: Record<string, OpenAPISchema> = {};
  const required: string[] = [];

  // Split by lines and process each property
  const lines = classBody.split('\n');
  let currentDecorators: string[] = [];

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();

    // Skip empty lines and comments
    if (!line || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) {
      continue;
    }

    // Collect decorators (support multi-line decorator calls like @ApiProperty({ ... }))
    if (line.startsWith('@')) {
      let decorator = line;

      while (!decorator.includes(')') && i + 1 < lines.length) {
        const nextLine = lines[i + 1]?.trim() ?? '';
        decorator = `${decorator} ${nextLine}`.trim();
        i++;
      }

      currentDecorators.push(decorator);
      continue;
    }

    // Check if this is a property declaration
    const propertyMatch = line.match(/^(\w+)(\?)?[!]?\s*:\s*(.+?)\s*;?\s*$/);
    if (propertyMatch) {
      const [, propName, optional, propType] = propertyMatch;

      // Strip any default initializer from the "type" capture.
      // Example: "sponsor: SponsorDTO = new SponsorDTO();" -> "SponsorDTO"
      // Without this, later mapping may fall back to "string" and corrupt the schema.
      const cleanedType = propType.split('=')[0].trim();

      // Skip if propName is a TypeScript/decorator keyword
      if (['constructor', 'private', 'public', 'protected', 'static', 'readonly'].includes(propName)) {
        currentDecorators = [];
        continue;
      }

      // Determine if required
      const isOptional =
        !!optional || currentDecorators.some(d => d.includes('required: false') || d.includes('@IsOptional'));
      const isNullableFromDecorator = currentDecorators.some(d => d.includes('nullable: true'));
      const isNullableFromType = cleanedType.includes('| null') || cleanedType.includes('null |');
      const isNullable = isNullableFromDecorator || isNullableFromType;

      if (!isOptional && !isNullable) {
        required.push(propName);
      }

      // Extract type from @ApiProperty decorator if present
      let schema = extractTypeFromDecorators(currentDecorators, cleanedType);

      properties[propName] = schema;
      currentDecorators = [];
    }
  }

  const result: OpenAPISchema = {
    type: 'object',
    properties
  };

  if (required.length > 0) {
    result.required = required;
  }

  return result;
}

function extractTypeFromDecorators(decorators: string[], tsType: string): OpenAPISchema {
  // Join all decorators to search across them
  const decoratorStr = decorators.join(' ');

  // Check for @ApiProperty type specification
  // NOTE: Avoid the RegExp dotAll (/s) flag to keep compatibility with older TS targets.
  const apiPropertyMatch = decoratorStr.match(/@ApiProperty\s*\(\s*\{([\s\S]*?)\}\s*\)/);
  
  if (apiPropertyMatch) {
    const apiPropertyContent = apiPropertyMatch[1];

    // Check for array type: type: [SomeDTO]
    const arrayTypeMatch = apiPropertyContent.match(/type:\s*\[\s*(\w+)\s*\]/);
    if (arrayTypeMatch) {
      const itemType = arrayTypeMatch[1];
      return {
        type: 'array',
        items: mapTypeToSchema(itemType)
      };
    }

    // Check for function type: type: () => SomeDTO
    const funcTypeMatch = apiPropertyContent.match(/type:\s*\(\)\s*=>\s*(\w+)/);
    if (funcTypeMatch) {
      return mapTypeToSchema(funcTypeMatch[1]);
    }

    // Check for direct type reference: type: SomeDTO
    const directTypeMatch = apiPropertyContent.match(/type:\s*(\w+)(?:\s*[,}])/);
    if (directTypeMatch && !['String', 'Number', 'Boolean', 'string', 'number', 'boolean'].includes(directTypeMatch[1])) {
      return mapTypeToSchema(directTypeMatch[1]);
    }

    // Check for enum
    const enumMatch = apiPropertyContent.match(/enum:\s*(\w+)/);
    if (enumMatch) {
      return { type: 'string' }; // Simplify enum to string
    }

    // Check for nullable
    if (apiPropertyContent.includes('nullable: true')) {
      const baseSchema = mapTypeToSchema(tsType);
      baseSchema.nullable = true;
      return baseSchema;
    }
  }

  // Fall back to TypeScript type
  return mapTypeToSchema(tsType);
}

function mapTypeToSchema(type: string): OpenAPISchema {
  // Clean up the type
  type = type.replace(/[;!]/g, '').trim();

  const normalizeDtoName = (name: string) => (name.endsWith('Dto') ? name.slice(0, -3) + 'DTO' : name);

  // Handle Record<string, T>
  // eslint-disable-next-line no-useless-escape
  const recordMatch = type.match(/^Record<\s*string\s*,\s*(.+)\s*>$/);
  if (recordMatch) {
    return {
      type: 'object',
      additionalProperties: mapTypeToSchema(recordMatch[1]),
    };
  }

  // Handle index signature object types: { [key: string]: T }
  // eslint-disable-next-line no-useless-escape
  const indexSigMatch = type.match(/^\{\s*\[\s*\w+\s*:\s*(string|number)\s*\]\s*:\s*(.+)\s*\}$/);
  if (indexSigMatch) {
    return {
      type: 'object',
      additionalProperties: mapTypeToSchema(indexSigMatch[2]),
    };
  }

  // Handle union with null
  if (type.includes('| null') || type.includes('null |')) {
    const baseType = type.replace(/\|\s*null/g, '').replace(/null\s*\|/g, '').trim();
    const schema = mapTypeToSchema(baseType);
    schema.nullable = true;
    return schema;
  }

  // Handle array types
  if (type.endsWith('[]')) {
    const itemType = type.slice(0, -2).trim();
    return {
      type: 'array',
      items: mapTypeToSchema(itemType)
    };
  }

  // Handle Array<T> syntax
  const arrayGenericMatch = type.match(/^Array<(.+)>$/);
  if (arrayGenericMatch) {
    return {
      type: 'array',
      items: mapTypeToSchema(arrayGenericMatch[1])
    };
  }

  // Handle object literal types (inline objects)
  if (type.startsWith('{')) {
    return { type: 'object' };
  }

  // Handle primitive types
  const lowerType = type.toLowerCase();
  switch (lowerType) {
    case 'string':
      return { type: 'string' };
    case 'number':
    case 'bigint':
      return { type: 'number' };
    case 'boolean':
      return { type: 'boolean' };
    case 'date':
      return { type: 'string', format: 'date-time' };
    case 'any':
    case 'unknown':
    case 'object':
      return { type: 'object' };
  }

  // Handle DTO references
  if (type.endsWith('DTO') || type.endsWith('Dto')) {
    return { $ref: `#/components/schemas/${normalizeDtoName(type)}` };
  }

  // Default to string for unknown types
  return { type: 'string' };
}

generateSpec().catch(console.error);
