import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { extname, join } from 'path';

export type UploadedVideoFile = {
  mimetype?: string;
  size: number;
  buffer: Buffer;
  originalname?: string;
};

export type OptimizedVideoResult = {
  file: UploadedVideoFile;
  generatedThumbnail?: {
    buffer: Buffer;
    extension: '.jpg';
    contentType: 'image/jpeg';
  };
  generatedHls?: {
    playlistRelativePath: string;
    files: Array<{
      relativePath: string;
      buffer: Buffer;
      contentType: string;
    }>;
  };
};

type ProbedVideoInfo = {
  hasAudio: boolean;
  width: number;
};

type HlsRendition = {
  width: number;
  videoBitrateKbps: number;
  maxRateKbps: number;
  bufSizeKbps: number;
  audioBitrateKbps: number;
};

@Injectable()
export class VideoProcessingService {
  private readonly logger = new Logger(VideoProcessingService.name);
  private ffmpegAvailabilityResolved = false;
  private ffmpegAvailable = false;

  constructor(private readonly configService: ConfigService) {}

  async optimizeForPlayback(file: UploadedVideoFile): Promise<OptimizedVideoResult> {
    if (!this.isEnabled()) {
      return { file };
    }

    const ffmpegReady = await this.ensureFfmpegAvailable();
    if (!ffmpegReady) {
      return { file };
    }

    const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-video-'));
    const inputPath = join(workingDir, `input-${randomUUID()}${this.resolveInputExtension(file)}`);
    const outputPath = join(workingDir, `optimized-${randomUUID()}.mp4`);
    const thumbnailPath = join(workingDir, `thumbnail-${randomUUID()}.jpg`);
    const hlsDir = join(workingDir, `hls-${randomUUID()}`);

    try {
      await writeFile(inputPath, file.buffer);
      await this.runFfmpeg([
        '-y',
        '-i',
        inputPath,
        '-map',
        '0:v:0',
        '-map',
        '0:a:0?',
        '-c:v',
        'libx264',
        '-preset',
        this.getPreset(),
        '-crf',
        String(this.getCrf()),
        '-pix_fmt',
        'yuv420p',
        '-profile:v',
        'main',
        '-level',
        '4.0',
        '-vf',
        this.buildVideoFilter(),
        '-movflags',
        '+faststart',
        '-c:a',
        'aac',
        '-b:a',
        `${this.getAudioBitrateKbps()}k`,
        '-ac',
        '2',
        '-ar',
        '44100',
        outputPath,
      ]);

      const optimizedBuffer = await readFile(outputPath);
      const optimizedFile: UploadedVideoFile = {
        buffer: optimizedBuffer,
        size: optimizedBuffer.length,
        mimetype: 'video/mp4',
        originalname: this.buildOptimizedFileName(file.originalname),
      };

      let generatedThumbnail: OptimizedVideoResult['generatedThumbnail'];
      if (this.shouldGenerateThumbnails()) {
        try {
          await this.runFfmpeg([
            '-y',
            '-ss',
            '00:00:00.100',
            '-i',
            outputPath,
            '-frames:v',
            '1',
            '-vf',
            this.buildThumbnailFilter(),
            '-q:v',
            '3',
            thumbnailPath,
          ]);

          const thumbnailBuffer = await readFile(thumbnailPath);
          generatedThumbnail = {
            buffer: thumbnailBuffer,
            extension: '.jpg',
            contentType: 'image/jpeg',
          };
        } catch (error) {
          this.logger.warn(
            `Thumbnail generation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
          );
        }
      }

      let generatedHls: OptimizedVideoResult['generatedHls'];
      if (this.shouldGenerateHls()) {
        try {
          generatedHls = await this.generateHlsPackage(outputPath, hlsDir);
        } catch (error) {
          this.logger.warn(
            `HLS generation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
          );
        }
      }

      return {
        file: optimizedFile,
        generatedThumbnail,
        generatedHls,
      };
    } catch (error) {
      this.logger.warn(
        `Video optimization failed for "${file.originalname ?? 'upload'}": ${
          error instanceof Error ? error.message : 'unknown error'
        }`,
      );
      return { file };
    } finally {
      await rm(workingDir, { recursive: true, force: true });
    }
  }

  private isEnabled(): boolean {
    return this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
  }

  private shouldGenerateThumbnails(): boolean {
    return (
      this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ?? true
    );
  }

  private shouldGenerateHls(): boolean {
    return this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
  }

  private getFfmpegPath(): string {
    return (
      this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
    ).trim();
  }

  private getFfprobePath(): string {
    const configured = (
      this.configService.get<string>('videoProcessing.ffprobePath', { infer: true }) ?? ''
    ).trim();
    if (configured) {
      return configured;
    }

    const ffmpegPath = this.getFfmpegPath();
    if (/ffmpeg(?:\.exe)?$/i.test(ffmpegPath)) {
      return ffmpegPath.replace(/ffmpeg(?:\.exe)?$/i, (match) =>
        match.toLowerCase().endsWith('.exe') ? 'ffprobe.exe' : 'ffprobe',
      );
    }

    return 'ffprobe';
  }

  private getMaxWidth(): number {
    return this.configService.get<number>('videoProcessing.maxWidth', { infer: true }) ?? 1280;
  }

  private getThumbnailWidth(): number {
    return this.configService.get<number>('videoProcessing.thumbnailWidth', { infer: true }) ?? 720;
  }

  private getHlsSegmentDurationSeconds(): number {
    return (
      this.configService.get<number>('videoProcessing.hlsSegmentDurationSeconds', {
        infer: true,
      }) ?? 4
    );
  }

  private getMaxFps(): number {
    return this.configService.get<number>('videoProcessing.maxFps', { infer: true }) ?? 30;
  }

  private getCrf(): number {
    return this.configService.get<number>('videoProcessing.crf', { infer: true }) ?? 28;
  }

  private getPreset(): string {
    return (
      this.configService.get<string>('videoProcessing.preset', { infer: true }) ?? 'veryfast'
    ).trim();
  }

  private getAudioBitrateKbps(): number {
    return (
      this.configService.get<number>('videoProcessing.audioBitrateKbps', { infer: true }) ?? 128
    );
  }

  private buildVideoFilter(): string {
    return `scale='min(${this.getMaxWidth()},iw)':-2:force_original_aspect_ratio=decrease,fps=${this.getMaxFps()},format=yuv420p`;
  }

  private buildThumbnailFilter(): string {
    return `scale='min(${this.getThumbnailWidth()},iw)':-2:force_original_aspect_ratio=decrease`;
  }

  private buildOptimizedFileName(originalname?: string): string {
    const baseName = (originalname ?? 'video').replace(/\.[^.]+$/, '');
    return `${baseName}-optimized.mp4`;
  }

  private resolveInputExtension(file: UploadedVideoFile): string {
    const extension = extname(file.originalname ?? '').toLowerCase();
    if (extension) {
      return extension;
    }

    switch (file.mimetype) {
      case 'video/mp4':
        return '.mp4';
      case 'video/quicktime':
        return '.mov';
      case 'video/webm':
        return '.webm';
      case 'video/x-matroska':
        return '.mkv';
      case 'video/x-msvideo':
        return '.avi';
      default:
        return '.mp4';
    }
  }

  private async generateHlsPackage(
    optimizedMp4Path: string,
    hlsDir: string,
  ): Promise<NonNullable<OptimizedVideoResult['generatedHls']>> {
    await mkdir(hlsDir, { recursive: true });

    let probe: ProbedVideoInfo | null = null;
    try {
      probe = await this.probeVideo(optimizedMp4Path);
    } catch (error) {
      this.logger.warn(
        `Video probe failed before adaptive HLS generation: ${
          error instanceof Error ? error.message : 'unknown error'
        }`,
      );
    }

    const renditions = this.buildHlsRenditions(probe?.width ?? this.getMaxWidth());
    if (!probe || renditions.length <= 1) {
      return this.generateSingleRenditionHlsPackage(optimizedMp4Path, hlsDir);
    }

    for (let index = 0; index < renditions.length; index += 1) {
      await mkdir(join(hlsDir, `stream_${index}`), { recursive: true });
    }

    const args = ['-y', '-i', optimizedMp4Path];
    args.push('-filter_complex', this.buildAdaptiveHlsFilterComplex(renditions));

    for (let index = 0; index < renditions.length; index += 1) {
      args.push('-map', `[v${index}out]`);
      if (probe.hasAudio) {
        args.push('-map', '0:a:0');
      }
    }

    renditions.forEach((rendition, index) => {
      args.push(
        `-c:v:${index}`,
        'libx264',
        `-b:v:${index}`,
        `${rendition.videoBitrateKbps}k`,
        `-maxrate:v:${index}`,
        `${rendition.maxRateKbps}k`,
        `-bufsize:v:${index}`,
        `${rendition.bufSizeKbps}k`,
        `-g:v:${index}`,
        String(this.getMaxFps() * 2),
        `-keyint_min:v:${index}`,
        String(this.getMaxFps() * 2),
        `-sc_threshold:v:${index}`,
        '0',
        `-preset:v:${index}`,
        this.getPreset(),
      );

      if (probe.hasAudio) {
        args.push(
          `-c:a:${index}`,
          'aac',
          `-b:a:${index}`,
          `${rendition.audioBitrateKbps}k`,
          `-ac:a:${index}`,
          '2',
          `-ar:a:${index}`,
          '44100',
        );
      }
    });

    args.push(
      '-f',
      'hls',
      '-hls_time',
      String(this.getHlsSegmentDurationSeconds()),
      '-hls_playlist_type',
      'vod',
      '-hls_list_size',
      '0',
      '-hls_flags',
      'independent_segments',
      '-hls_segment_type',
      'fmp4',
      '-master_pl_name',
      'master.m3u8',
      '-hls_fmp4_init_filename',
      'stream_%v/init.mp4',
      '-hls_segment_filename',
      join(hlsDir, 'stream_%v/segment-%03d.m4s'),
      '-var_stream_map',
      probe.hasAudio
        ? renditions.map((_, index) => `v:${index},a:${index}`).join(' ')
        : renditions.map((_, index) => `v:${index}`).join(' '),
      join(hlsDir, 'stream_%v/playlist.m3u8'),
    );

    await this.runFfmpeg(args);

    const files = await this.readFilesRecursively(hlsDir);

    return {
      playlistRelativePath: 'master.m3u8',
      files,
    };
  }

  private async generateSingleRenditionHlsPackage(
    optimizedMp4Path: string,
    hlsDir: string,
  ): Promise<NonNullable<OptimizedVideoResult['generatedHls']>> {
    await mkdir(hlsDir, { recursive: true });

    await this.runFfmpeg([
      '-y',
      '-i',
      optimizedMp4Path,
      '-map',
      '0:v:0',
      '-map',
      '0:a:0?',
      '-c:v',
      'copy',
      '-c:a',
      'copy',
      '-f',
      'hls',
      '-hls_time',
      String(this.getHlsSegmentDurationSeconds()),
      '-hls_playlist_type',
      'vod',
      '-hls_list_size',
      '0',
      '-hls_flags',
      'independent_segments',
      '-hls_segment_type',
      'fmp4',
      '-hls_fmp4_init_filename',
      'init.mp4',
      '-hls_segment_filename',
      join(hlsDir, 'segment-%03d.m4s'),
      join(hlsDir, 'playlist.m3u8'),
    ]);

    const files = await this.readFilesRecursively(hlsDir);

    return {
      playlistRelativePath: 'playlist.m3u8',
      files,
    };
  }

  private buildAdaptiveHlsFilterComplex(renditions: HlsRendition[]): string {
    if (renditions.length === 1) {
      return `[0:v]${this.buildAdaptiveScaleFilter(renditions[0].width)}[v0out]`;
    }

    const splitOutputs = renditions.map((_, index) => `[v${index}]`).join('');
    const split = `[0:v]split=${renditions.length}${splitOutputs}`;
    const transforms = renditions.map(
      (rendition, index) =>
        `[v${index}]${this.buildAdaptiveScaleFilter(rendition.width)}[v${index}out]`,
    );

    return [split, ...transforms].join(';');
  }

  private buildAdaptiveScaleFilter(width: number): string {
    return `scale='min(${width},iw)':-2:force_original_aspect_ratio=decrease,fps=${this.getMaxFps()},format=yuv420p`;
  }

  private buildHlsRenditions(sourceWidth: number): HlsRendition[] {
    const requestedWidths = [
      Math.min(480, this.getMaxWidth()),
      Math.min(720, this.getMaxWidth()),
      this.getMaxWidth(),
    ];
    const widths = Array.from(
      new Set(
        requestedWidths
          .map((width) => Math.max(240, Math.min(width, sourceWidth)))
          .filter((width) => Number.isFinite(width)),
      ),
    ).sort((left, right) => left - right);

    return widths.map((width) => this.resolveHlsRendition(width));
  }

  private resolveHlsRendition(width: number): HlsRendition {
    if (width <= 480) {
      return {
        width,
        videoBitrateKbps: 800,
        maxRateKbps: 960,
        bufSizeKbps: 1600,
        audioBitrateKbps: 96,
      };
    }

    if (width <= 720) {
      return {
        width,
        videoBitrateKbps: 1600,
        maxRateKbps: 1920,
        bufSizeKbps: 3200,
        audioBitrateKbps: 128,
      };
    }

    return {
      width,
      videoBitrateKbps: 2800,
      maxRateKbps: 3360,
      bufSizeKbps: 5600,
      audioBitrateKbps: 128,
    };
  }

  private async probeVideo(inputPath: string): Promise<ProbedVideoInfo> {
    const stdout = await this.runCommand(this.getFfprobePath(), [
      '-v',
      'error',
      '-show_entries',
      'stream=codec_type,width',
      '-of',
      'json',
      inputPath,
    ]);

    const parsed = JSON.parse(stdout) as {
      streams?: Array<{ codec_type?: string; width?: number }>;
    };
    const streams = parsed.streams ?? [];
    const videoStream = streams.find((stream) => stream.codec_type === 'video');
    if (!videoStream?.width) {
      throw new Error('ffprobe did not return a video width');
    }

    return {
      width: videoStream.width,
      hasAudio: streams.some((stream) => stream.codec_type === 'audio'),
    };
  }

  private async readFilesRecursively(
    baseDir: string,
    currentDir = baseDir,
  ): Promise<Array<{ relativePath: string; buffer: Buffer; contentType: string }>> {
    const entries = await readdir(currentDir, { withFileTypes: true });
    const files: Array<{ relativePath: string; buffer: Buffer; contentType: string }> = [];

    for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
      const absolutePath = join(currentDir, entry.name);
      if (entry.isDirectory()) {
        files.push(...(await this.readFilesRecursively(baseDir, absolutePath)));
        continue;
      }

      const relativePath = absolutePath.slice(baseDir.length + 1).replace(/\\/g, '/');
      files.push({
        relativePath,
        buffer: await readFile(absolutePath),
        contentType: this.resolveStreamingContentType(relativePath),
      });
    }

    return files;
  }

  private resolveStreamingContentType(fileName: string): string {
    const extension = extname(fileName).toLowerCase();

    switch (extension) {
      case '.m3u8':
        return 'application/vnd.apple.mpegurl';
      case '.m4s':
        return 'video/iso.segment';
      case '.ts':
        return 'video/mp2t';
      case '.mp4':
        return 'video/mp4';
      default:
        return 'application/octet-stream';
    }
  }

  private async ensureFfmpegAvailable(): Promise<boolean> {
    if (this.ffmpegAvailabilityResolved) {
      return this.ffmpegAvailable;
    }

    try {
      await this.runFfmpeg(['-version']);
      this.ffmpegAvailable = true;
    } catch (error) {
      this.ffmpegAvailable = false;
      this.logger.warn(
        `VIDEO_PROCESSING_ENABLED is on, but ffmpeg is unavailable at "${this.getFfmpegPath()}": ${
          error instanceof Error ? error.message : 'unknown error'
        }`,
      );
    }

    this.ffmpegAvailabilityResolved = true;
    return this.ffmpegAvailable;
  }

  private async runCommand(command: string, args: string[]): Promise<string> {
    return new Promise<string>((resolve, reject) => {
      const child = spawn(command, args, { windowsHide: true });
      let stdout = '';
      let stderr = '';

      child.stdout.on('data', (chunk) => {
        stdout += chunk.toString();
      });

      child.stderr.on('data', (chunk) => {
        stderr += chunk.toString();
      });

      child.on('error', (error) => {
        reject(error);
      });

      child.on('close', (code) => {
        if (code === 0) {
          resolve(stdout);
          return;
        }

        reject(new Error(stderr.trim() || `${command} exited with code ${code ?? 'unknown'}`));
      });
    });
  }

  private async runFfmpeg(args: string[]): Promise<void> {
    await this.runCommand(this.getFfmpegPath(), args);
  }
}
