#!/usr/bin/env node

import { spawnSync } from 'node:child_process'
import {
  copyFileSync,
  existsSync,
  mkdirSync,
  mkdtempSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'

const GENERATED_HEADER = `/*\n * This file is generated by ui/scripts/api_codegen.mjs.\n * Do not edit this file directly.\n */\n`

const __filename = fileURLToPath(import.meta.url)
const scriptsDir = path.dirname(__filename)
const uiRoot = path.resolve(scriptsDir, '..')
const repoRoot = path.resolve(uiRoot, '..')
const generatedDir = path.join(uiRoot, 'src', 'api', 'generated')
const mode = process.argv[2] ?? 'generate'

if (mode !== 'generate' && mode !== 'check') {
  console.error(`Unsupported mode "${mode}". Use "generate" or "check".`)
  process.exit(1)
}

function runCommand(command, args, options = {}) {
  const result = spawnSync(command, args, {
    cwd: options.cwd,
    env: { ...process.env, ...(options.env ?? {}) },
    stdio: options.stdio ?? 'pipe',
    encoding: 'utf-8',
  })

  if (result.error) {
    throw result.error
  }

  if (result.status !== 0 && !options.allowFailure) {
    const output = [result.stdout, result.stderr].filter(Boolean).join('\n')
    throw new Error(
      `Command failed (${command} ${args.join(' ')}):\n${output || '<no output>'}`,
    )
  }

  return result
}

function readJson(filePath) {
  return JSON.parse(readFileSync(filePath, 'utf-8'))
}

function buildTypesFile({
  cameraSchemaName,
  cameraCreateSchemaName,
  cameraUpdateSchemaName,
  configChangeSchemaName,
  setupStatusSchemaName,
  finalizeRequestSchemaName,
  finalizeResponseSchemaName,
  preflightResponseSchemaName,
  preflightCheckSchemaName,
  testConnectionRequestSchemaName,
  testConnectionResponseSchemaName,
  healthSchemaName,
  statsSchemaName,
  diagnosticsSchemaName,
  runtimeReloadSchemaName,
  runtimeStateSchemaName,
  runtimeStatusSchemaName,
  clipListSchemaName,
  clipSchemaName,
  clipStatusSchemaName,
  discoverRequestSchemaName,
  discoveredCameraSchemaName,
  probeRequestSchemaName,
  probeResponseSchemaName,
  mediaProfileSchemaName,
  deviceInfoSchemaName,
}) {
  return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type ListClipsQuery = NonNullable<paths["/api/v1/clips"]["get"]["parameters"]["query"]>\n`
}

function buildClientFile() {
  return `${GENERATED_HEADER}\nimport type {\n  CameraCreate,\n  CameraListResponse,\n  CameraResponse,\n  CameraUpdate,\n  ClipListResponse,\n  ClipResponse,\n  ConfigChangeResponse,\n  DiagnosticsResponse,\n  DiscoverRequest,\n  DiscoveredCameraResponse,\n  FinalizeRequest,\n  FinalizeResponse,\n  HealthResponse,\n  ListClipsQuery,\n  PreflightResponse,\n  TestConnectionRequest,\n  TestConnectionResponse,\n  ProbeRequest,\n  ProbeResponse,\n  RuntimeReloadResponse,\n  RuntimeStatusResponse,\n  SetupStatusResponse,\n  StatsResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n  signal?: AbortSignal\n  apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n  applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus<TPayload extends object> = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n  getCameras(options?: ApiRequestOptions): Promise<CameraListResponse>\n  getCamera(name: string, options?: ApiRequestOptions): Promise<CameraResponse>\n  createCamera(\n    payload: CameraCreate,\n    options?: CameraMutationOptions,\n  ): Promise<ApiResponseWithStatus<ConfigChangeResponse>>\n  updateCamera(\n    name: string,\n    payload: CameraUpdate,\n    options?: CameraMutationOptions,\n  ): Promise<ApiResponseWithStatus<ConfigChangeResponse>>\n  deleteCamera(\n    name: string,\n    options?: CameraMutationOptions,\n  ): Promise<ApiResponseWithStatus<ConfigChangeResponse>>\n  getSetupStatus(options?: ApiRequestOptions): Promise<ApiResponseWithStatus<SetupStatusResponse>>\n  finalizeSetup(\n    payload: FinalizeRequest,\n    options?: ApiRequestOptions,\n  ): Promise<ApiResponseWithStatus<FinalizeResponse>>\n  runSetupPreflight(options?: ApiRequestOptions): Promise<ApiResponseWithStatus<PreflightResponse>>\n  runSetupTestConnection(\n    payload: TestConnectionRequest,\n    options?: ApiRequestOptions,\n  ): Promise<ApiResponseWithStatus<TestConnectionResponse>>\n  getHealth(options?: ApiRequestOptions): Promise<ApiResponseWithStatus<HealthResponse>>\n  getStats(options?: ApiRequestOptions): Promise<ApiResponseWithStatus<StatsResponse>>\n  getDiagnostics(options?: ApiRequestOptions): Promise<ApiResponseWithStatus<DiagnosticsResponse>>\n  reloadRuntime(options?: ApiRequestOptions): Promise<ApiResponseWithStatus<RuntimeReloadResponse>>\n  getRuntimeStatus(\n    options?: ApiRequestOptions,\n  ): Promise<ApiResponseWithStatus<RuntimeStatusResponse>>\n  discoverOnvifCameras(\n    payload?: DiscoverRequest,\n    options?: ApiRequestOptions,\n  ): Promise<DiscoveredCameraResponse[]>\n  probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise<ProbeResponse>\n  getClips(\n    query?: ListClipsQuery,\n    options?: ApiRequestOptions,\n  ): Promise<ApiResponseWithStatus<ClipListResponse>>\n  getClip(clipId: string, options?: ApiRequestOptions): Promise<ApiResponseWithStatus<ClipResponse>>\n}\n`
}

function resolveResponseSchemaName(openapiSchema, { pathName, method, statuses, fallbackSchemaName }) {
  const route = openapiSchema.paths?.[pathName]?.[method]
  if (!route || typeof route !== 'object') {
    throw new Error(`Missing ${method.toUpperCase()} ${pathName} route in exported OpenAPI schema`)
  }

  for (const statusCode of statuses) {
    const schema = route.responses?.[statusCode]?.content?.['application/json']?.schema
    if (schema?.$ref && typeof schema.$ref === 'string') {
      const prefix = '#/components/schemas/'
      if (schema.$ref.startsWith(prefix)) {
        return schema.$ref.slice(prefix.length)
      }
    }
  }

  if (
    typeof fallbackSchemaName === 'string'
    && openapiSchema.components?.schemas?.[fallbackSchemaName]
  ) {
    return fallbackSchemaName
  }

  throw new Error(
    `Unable to derive schema type for ${method.toUpperCase()} ${pathName} from OpenAPI schema`,
  )
}

function resolveArrayItemResponseSchemaName(
  openapiSchema,
  { pathName, method, statuses, fallbackSchemaName },
) {
  const route = openapiSchema.paths?.[pathName]?.[method]
  if (!route || typeof route !== 'object') {
    throw new Error(`Missing ${method.toUpperCase()} ${pathName} route in exported OpenAPI schema`)
  }

  for (const statusCode of statuses) {
    const schema = route.responses?.[statusCode]?.content?.['application/json']?.schema
    if (schema?.type === 'array') {
      const itemRef = schema.items?.$ref
      if (typeof itemRef === 'string') {
        const prefix = '#/components/schemas/'
        if (itemRef.startsWith(prefix)) {
          return itemRef.slice(prefix.length)
        }
      }
    }
  }

  if (
    typeof fallbackSchemaName === 'string'
    && openapiSchema.components?.schemas?.[fallbackSchemaName]
  ) {
    return fallbackSchemaName
  }

  throw new Error(
    `Unable to derive array response item schema for ${method.toUpperCase()} ${pathName} from OpenAPI schema`,
  )
}

function resolveComponentSchemaName(openapiSchema, schemaName) {
  if (openapiSchema.components?.schemas?.[schemaName]) {
    return schemaName
  }
  throw new Error(`Missing component schema ${schemaName} in exported OpenAPI spec`)
}

function resolveRequestBodySchemaName(openapiSchema, { pathName, method, fallbackSchemaName }) {
  const route = openapiSchema.paths?.[pathName]?.[method]
  if (!route || typeof route !== 'object') {
    throw new Error(`Missing ${method.toUpperCase()} ${pathName} route in exported OpenAPI schema`)
  }

  const schema = route.requestBody?.content?.['application/json']?.schema
  if (schema?.$ref && typeof schema.$ref === 'string') {
    const prefix = '#/components/schemas/'
    if (schema.$ref.startsWith(prefix)) {
      return schema.$ref.slice(prefix.length)
    }
  }

  if (
    typeof fallbackSchemaName === 'string'
    && openapiSchema.components?.schemas?.[fallbackSchemaName]
  ) {
    return fallbackSchemaName
  }

  throw new Error(
    `Unable to derive request schema type for ${method.toUpperCase()} ${pathName} from OpenAPI schema`,
  )
}

function resolveSchemaPropertyRefName(
  openapiSchema,
  { schemaName, propertyName, fallbackSchemaName },
) {
  const schema = openapiSchema.components?.schemas?.[schemaName]
  if (schema && typeof schema === 'object') {
    const propertySchema = schema.properties?.[propertyName]
    if (propertySchema?.$ref && typeof propertySchema.$ref === 'string') {
      const prefix = '#/components/schemas/'
      if (propertySchema.$ref.startsWith(prefix)) {
        return propertySchema.$ref.slice(prefix.length)
      }
    }
  }

  if (
    typeof fallbackSchemaName === 'string'
    && openapiSchema.components?.schemas?.[fallbackSchemaName]
  ) {
    return fallbackSchemaName
  }

  throw new Error(
    `Unable to derive property schema for ${schemaName}.${propertyName} from OpenAPI schema`,
  )
}

function generateOpenApiArtifacts(tempGeneratedDir) {
  const openapiJsonPath = path.join(tempGeneratedDir, 'openapi.json')
  const schemaTsPath = path.join(tempGeneratedDir, 'schema.ts')
  const typesTsPath = path.join(tempGeneratedDir, 'types.ts')
  const clientTsPath = path.join(tempGeneratedDir, 'client.ts')

  runCommand(
    'uv',
    ['run', 'python', '-m', 'homesec.api.openapi_export', '--output', openapiJsonPath],
    { cwd: repoRoot },
  )

  runCommand(
    'pnpm',
    ['exec', 'openapi-typescript', openapiJsonPath, '--output', schemaTsPath],
    { cwd: uiRoot },
  )

  const schema = readJson(openapiJsonPath)
  const cameraSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/cameras',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'CameraResponse',
  })
  const cameraCreateSchemaName = resolveRequestBodySchemaName(schema, {
    pathName: '/api/v1/cameras',
    method: 'post',
    fallbackSchemaName: 'CameraCreate',
  })
  const cameraUpdateSchemaName = resolveRequestBodySchemaName(schema, {
    pathName: '/api/v1/cameras/{name}',
    method: 'patch',
    fallbackSchemaName: 'CameraUpdate',
  })
  const configChangeSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/cameras',
    method: 'post',
    statuses: ['201', '200', 'default'],
    fallbackSchemaName: 'ConfigChangeResponse',
  })
  const setupStatusSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/setup/status',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'SetupStatusResponse',
  })
  const finalizeRequestSchemaName = resolveRequestBodySchemaName(schema, {
    pathName: '/api/v1/setup/finalize',
    method: 'post',
    fallbackSchemaName: 'FinalizeRequest',
  })
  const finalizeResponseSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/setup/finalize',
    method: 'post',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'FinalizeResponse',
  })
  const preflightResponseSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/setup/preflight',
    method: 'post',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'PreflightResponse',
  })
  const preflightCheckSchemaName = resolveComponentSchemaName(schema, 'PreflightCheckResponse')
  const testConnectionRequestSchemaName = resolveRequestBodySchemaName(schema, {
    pathName: '/api/v1/setup/test-connection',
    method: 'post',
    fallbackSchemaName: 'TestConnectionRequest',
  })
  const testConnectionResponseSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/setup/test-connection',
    method: 'post',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'TestConnectionResponse',
  })
  const healthSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/health',
    method: 'get',
    statuses: ['200', '503', 'default'],
    fallbackSchemaName: 'HealthResponse',
  })
  const statsSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/stats',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'StatsResponse',
  })
  const diagnosticsSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/diagnostics',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'DiagnosticsResponse',
  })
  const runtimeReloadSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/runtime/reload',
    method: 'post',
    statuses: ['202', '200', 'default'],
    fallbackSchemaName: 'RuntimeReloadResponse',
  })
  const runtimeStatusSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/runtime/status',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'RuntimeStatusResponse',
  })
  const runtimeStateSchemaName = resolveSchemaPropertyRefName(schema, {
    schemaName: runtimeStatusSchemaName,
    propertyName: 'state',
    fallbackSchemaName: 'RuntimeState',
  })
  const clipListSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/clips',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'ClipListResponse',
  })
  const clipSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/clips/{clip_id}',
    method: 'get',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'ClipResponse',
  })
  const discoveredCameraSchemaName = resolveArrayItemResponseSchemaName(schema, {
    pathName: '/api/v1/onvif/discover',
    method: 'post',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'DiscoveredCameraResponse',
  })
  const probeRequestSchemaName = resolveRequestBodySchemaName(schema, {
    pathName: '/api/v1/onvif/probe',
    method: 'post',
    fallbackSchemaName: 'ProbeRequest',
  })
  const probeResponseSchemaName = resolveResponseSchemaName(schema, {
    pathName: '/api/v1/onvif/probe',
    method: 'post',
    statuses: ['200', 'default'],
    fallbackSchemaName: 'ProbeResponse',
  })
  const discoverRequestSchemaName = resolveComponentSchemaName(schema, 'DiscoverRequest')
  const mediaProfileSchemaName = resolveComponentSchemaName(schema, 'MediaProfileResponse')
  const deviceInfoSchemaName = resolveComponentSchemaName(schema, 'DeviceInfoResponse')
  if (!schema.components?.schemas?.ClipStatus) {
    throw new Error('Missing ClipStatus schema in exported OpenAPI spec')
  }
  writeFileSync(
    typesTsPath,
    buildTypesFile({
      cameraSchemaName,
      cameraCreateSchemaName,
      cameraUpdateSchemaName,
      configChangeSchemaName,
      setupStatusSchemaName,
      finalizeRequestSchemaName,
      finalizeResponseSchemaName,
      preflightResponseSchemaName,
      preflightCheckSchemaName,
      testConnectionRequestSchemaName,
      testConnectionResponseSchemaName,
      healthSchemaName,
      statsSchemaName,
      diagnosticsSchemaName,
      runtimeReloadSchemaName,
      runtimeStateSchemaName,
      runtimeStatusSchemaName,
      clipListSchemaName,
      clipSchemaName,
      clipStatusSchemaName: 'ClipStatus',
      discoverRequestSchemaName,
      discoveredCameraSchemaName,
      probeRequestSchemaName,
      probeResponseSchemaName,
      mediaProfileSchemaName,
      deviceInfoSchemaName,
    }),
    'utf-8',
  )
  writeFileSync(clientTsPath, buildClientFile(), 'utf-8')

  return {
    openapiJsonPath,
    schemaTsPath,
    typesTsPath,
    clientTsPath,
  }
}

function filesDiffer(sourcePath, targetPath) {
  if (!existsSync(targetPath)) {
    return true
  }
  return readFileSync(sourcePath, 'utf-8') !== readFileSync(targetPath, 'utf-8')
}

function applyGeneratedFiles(tempFiles) {
  mkdirSync(generatedDir, { recursive: true })

  const artifactMap = {
    'openapi.json': tempFiles.openapiJsonPath,
    'schema.ts': tempFiles.schemaTsPath,
    'types.ts': tempFiles.typesTsPath,
    'client.ts': tempFiles.clientTsPath,
  }

  const changedFiles = []
  for (const [name, sourcePath] of Object.entries(artifactMap)) {
    const targetPath = path.join(generatedDir, name)
    if (filesDiffer(sourcePath, targetPath)) {
      copyFileSync(sourcePath, targetPath)
      changedFiles.push(targetPath)
    }
  }

  return changedFiles
}

function checkGeneratedFiles(tempFiles) {
  const artifactMap = {
    'openapi.json': tempFiles.openapiJsonPath,
    'schema.ts': tempFiles.schemaTsPath,
    'types.ts': tempFiles.typesTsPath,
    'client.ts': tempFiles.clientTsPath,
  }

  const staleFiles = []
  for (const [name, sourcePath] of Object.entries(artifactMap)) {
    const targetPath = path.join(generatedDir, name)
    if (filesDiffer(sourcePath, targetPath)) {
      staleFiles.push(`src/api/generated/${name}`)
    }
  }

  if (staleFiles.length > 0) {
    console.error('Generated API artifacts are stale:')
    for (const fileName of staleFiles) {
      console.error(`  - ${fileName}`)
    }
    console.error('Run `pnpm api:generate` in ui/ and commit the updated files.')
    process.exit(1)
  }
}

function main() {
  const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'homesec-api-codegen-'))
  const tempGeneratedDir = path.join(tempRoot, 'generated')
  mkdirSync(tempGeneratedDir, { recursive: true })

  try {
    const tempFiles = generateOpenApiArtifacts(tempGeneratedDir)

    if (mode === 'generate') {
      const changedFiles = applyGeneratedFiles(tempFiles)
      if (changedFiles.length === 0) {
        console.log('API artifacts are already up to date.')
      } else {
        console.log('Updated generated API artifacts:')
        for (const filePath of changedFiles) {
          console.log(`  - ${path.relative(uiRoot, filePath)}`)
        }
      }
      return
    }

    checkGeneratedFiles(tempFiles)
    console.log('Generated API artifacts are up to date.')
  } finally {
    rmSync(tempRoot, { recursive: true, force: true })
  }
}

main()
