import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  Header,
  HttpCode,
  HttpStatus,
  UseInterceptors,
  UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { PluginsService } from './plugins.service';
import {
  PluginDto,
  PluginConfigDto,
  PluginSessionsDto,
  InstallFromUrlDto,
  PluginActionResponseDto,
  PluginHealthResponseDto,
  PluginCatalogEntryDto,
} from './dto/plugin.dto';
import type { CatalogPlugin } from './catalog';
import { RequireRole, RequireUnscopedKey } from '../auth/decorators/auth.decorators';
import { ApiKeyRole } from '../auth/entities/api-key.entity';

/** Max accepted upload size for a plugin package (compressed). */
const MAX_PLUGIN_UPLOAD_BYTES = 5 * 1024 * 1024;

@ApiTags('plugins')
@Controller('plugins')
// Plugin installation and lifecycle are deployment-global and execute plugin code as the OpenWA
// process user, so session-restricted keys are fenced off route by route below. The fence is NOT
// applied at class level because @RequireUnscopedKey takes no argument and cannot be opted out of:
// `updateSessions` is fenced too (it overwrites the ENTIRE active set, so a scoped key could delete
// another tenant's activation by sending its own session or []), and `updateSessionConfig` is
// already covered by the guard through its own :sessionId route param.
export class PluginsController {
  constructor(private readonly pluginsService: PluginsService) {}

  @Get()
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'List all plugins' })
  @ApiResponse({ status: 200, description: 'List of all plugins', type: PluginDto, isArray: true })
  findAll(): PluginDto[] {
    return this.pluginsService.findAll();
  }

  @Post('install')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_UPLOAD_BYTES } }))
  @ApiConsumes('multipart/form-data')
  // @ApiConsumes only names the media type; without a body schema the upload this route exists for is
  // invisible in the contract, and a generated client has no parameter to attach the file to.
  @ApiBody({
    required: true,
    schema: {
      type: 'object',
      required: ['file'],
      properties: { file: { type: 'string', format: 'binary', description: 'The plugin .zip package' } },
    },
  })
  @ApiOperation({ summary: 'Install a plugin from an uploaded .zip package' })
  @ApiResponse({ status: 201, description: 'Plugin installed', type: PluginDto })
  @ApiResponse({ status: 400, description: 'Invalid package' })
  @ApiResponse({ status: 409, description: 'Plugin already installed' })
  install(@UploadedFile() file: { buffer?: Buffer }): PluginDto {
    return this.pluginsService.install(file);
  }

  @Post('install-url')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'Install a plugin by downloading its .zip from a URL (SSRF-guarded)' })
  @ApiResponse({ status: 201, description: 'Plugin installed', type: PluginDto })
  @ApiResponse({ status: 400, description: 'Invalid URL, download failed, or invalid package' })
  @ApiResponse({ status: 409, description: 'Plugin already installed' })
  async installFromUrl(@Body() dto: InstallFromUrlDto): Promise<PluginDto> {
    return await this.pluginsService.installFromUrl(dto.url);
  }

  // Declared before `:id` so `GET /plugins/catalog` is not captured by the `:id` route.
  @Get('catalog')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'List the remote plugin catalog, annotated with install state' })
  @ApiResponse({ status: 200, description: 'Catalog entries', type: [PluginCatalogEntryDto] })
  @ApiResponse({ status: 400, description: 'Catalog could not be fetched or parsed' })
  async catalog(): Promise<CatalogPlugin[]> {
    return await this.pluginsService.getCatalog();
  }

  @Get(':id')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'Get plugin by ID' })
  @ApiResponse({ status: 200, description: 'Plugin details', type: PluginDto })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  findOne(@Param('id') id: string): PluginDto {
    return this.pluginsService.findOne(id);
  }

  @Post(':id/enable')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Enable a plugin' })
  @ApiResponse({ status: 200, description: 'Plugin enabled successfully', type: PluginActionResponseDto })
  async enable(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
    return await this.pluginsService.enable(id);
  }

  @Post(':id/disable')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Disable a plugin' })
  @ApiResponse({ status: 200, description: 'Plugin disabled successfully', type: PluginActionResponseDto })
  async disable(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
    return await this.pluginsService.disable(id);
  }

  @Put(':id/config')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'Update plugin configuration' })
  @ApiResponse({ status: 200, description: 'Plugin configuration updated', type: PluginActionResponseDto })
  updateConfig(@Param('id') id: string, @Body() configDto: PluginConfigDto): { success: boolean; message: string } {
    return this.pluginsService.updateConfig(id, configDto.config);
  }

  // The dashboard fetches this WITH the API key and injects the body as an opaque-origin sandboxed
  // iframe srcdoc. It attaches that document's response-specific nonce to inline scripts so the
  // inherited CSP allows only the isolated editor bootstrap, without enabling parent unsafe-inline.
  @Get(':id/config-ui')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @Header('Content-Type', 'text/html; charset=utf-8')
  @Header('Content-Security-Policy', 'sandbox')
  @Header('X-Content-Type-Options', 'nosniff')
  @ApiOperation({ summary: "Serve a plugin's sandboxed config-UI entry HTML (for an iframe srcdoc)" })
  // The handler pins the media type with @Header, which the document cannot see — declare it, or the
  // only HTML route in the API publishes a 200 a client would decode as JSON.
  @ApiResponse({
    status: 200,
    description: 'Config UI HTML',
    content: { 'text/html': { schema: { type: 'string' } } },
  })
  @ApiResponse({ status: 404, description: 'Plugin not found or has no config UI' })
  getConfigUi(@Param('id') id: string): string {
    return this.pluginsService.getConfigUiHtml(id);
  }

  @Put(':id/config/:sessionId')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Set a plugin config override for a specific session (empty = clear it)' })
  @ApiResponse({ status: 200, description: 'Per-session plugin configuration updated', type: PluginActionResponseDto })
  @ApiResponse({ status: 400, description: 'Plugin is global (not session-scoped)' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  updateSessionConfig(
    @Param('id') id: string,
    @Param('sessionId') sessionId: string,
    @Body() configDto: PluginConfigDto,
  ): { success: boolean; message: string } {
    return this.pluginsService.updateSessionConfig(id, sessionId, configDto.config);
  }

  @Put(':id/sessions')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: "Set which sessions a session-scoped plugin is activated for (['*'] = all)" })
  @ApiResponse({ status: 200, description: 'Plugin session activation updated', type: PluginDto })
  @ApiResponse({ status: 400, description: 'Plugin is global (not session-scoped)' })
  @ApiResponse({
    status: 403,
    description:
      'A session-restricted key may not replace the full active set — full activation replacement requires an unrestricted key',
  })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  updateSessions(@Param('id') id: string, @Body() dto: PluginSessionsDto): PluginDto {
    // This is a full-replacement PUT (setPluginSessions overwrites the entire activeSessions array),
    // so a scoped key must never reach it: sending [] or its own session would delete every other
    // tenant's activation. The @RequireUnscopedKey fence above enforces that before the handler runs.
    return this.pluginsService.updateSessions(id, dto.sessions);
  }

  @Post(':id/update')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'Update an installed plugin in place from a URL (preserves config + enabled state)' })
  @ApiResponse({ status: 201, description: 'Plugin updated', type: PluginDto })
  @ApiResponse({ status: 400, description: 'Invalid URL/package, id mismatch, or built-in' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  async update(@Param('id') id: string, @Body() dto: InstallFromUrlDto): Promise<PluginDto> {
    return await this.pluginsService.updateFromUrl(id, dto.url);
  }

  @Delete(':id')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'Uninstall a plugin (removes its files; built-ins are protected)' })
  @ApiResponse({ status: 200, description: 'Plugin uninstalled', type: PluginActionResponseDto })
  @ApiResponse({ status: 400, description: 'Cannot uninstall (e.g. built-in)' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  async uninstall(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
    return await this.pluginsService.uninstall(id);
  }

  @Get(':id/health')
  @RequireRole(ApiKeyRole.ADMIN)
  @RequireUnscopedKey()
  @ApiOperation({ summary: 'Check plugin health' })
  @ApiResponse({ status: 200, description: 'Plugin health status', type: PluginHealthResponseDto })
  async healthCheck(@Param('id') id: string): Promise<{ healthy: boolean; message?: string }> {
    return await this.pluginsService.healthCheck(id);
  }
}
