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

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

export type ProcessedImageVariantName = 'original' | 'low' | 'medium' | 'high';

export type ProcessedImageResult = {
  primaryVariantName: ProcessedImageVariantName;
  variants: Array<{
    name: ProcessedImageVariantName;
    relativePath: string;
    extension: string;
    buffer: Buffer;
    contentType: string;
  }>;
};

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

  constructor(private readonly configService: ConfigService) {}

  async processForResponsiveDelivery(file: UploadedImageFile): Promise<ProcessedImageResult> {
    const originalVariant: ProcessedImageResult['variants'][number] = {
      name: 'original',
      relativePath: `original${this.resolveInputExtension(file)}`,
      extension: this.resolveInputExtension(file),
      buffer: file.buffer,
      contentType: this.resolveOriginalContentType(file),
    };

    if (!this.isEnabled()) {
      return {
        primaryVariantName: 'original',
        variants: [originalVariant],
      };
    }

    const ffmpegReady = await this.ensureFfmpegAvailable();
    if (!ffmpegReady) {
      return {
        primaryVariantName: 'original',
        variants: [originalVariant],
      };
    }

    const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-image-'));
    const inputPath = join(workingDir, `input${this.resolveInputExtension(file)}`);

    try {
      await writeFile(inputPath, file.buffer);

      const lowVariant = await this.generateWebpVariant(inputPath, workingDir, 'low', this.getLowWidth());
      const mediumVariant = await this.generateWebpVariant(
        inputPath,
        workingDir,
        'medium',
        this.getMediumWidth(),
      );
      const highVariant = await this.generateWebpVariant(inputPath, workingDir, 'high', this.getHighWidth());

      return {
        primaryVariantName: 'medium',
        variants: [originalVariant, lowVariant, mediumVariant, highVariant],
      };
    } catch (error) {
      this.logger.warn(
        `Image optimization failed for "${file.originalname ?? 'upload'}": ${
          error instanceof Error ? error.message : 'unknown error'
        }`,
      );

      return {
        primaryVariantName: 'original',
        variants: [originalVariant],
      };
    } finally {
      await rm(workingDir, { recursive: true, force: true });
    }
  }

  private isEnabled(): boolean {
    const explicit = this.configService.get<boolean>('imageProcessing.enabled', { infer: true });
    if (typeof explicit === 'boolean') {
      return explicit;
    }

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

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

  private getLowWidth(): number {
    return this.configService.get<number>('imageProcessing.lowWidth', { infer: true }) ?? 360;
  }

  private getMediumWidth(): number {
    return this.configService.get<number>('imageProcessing.mediumWidth', { infer: true }) ?? 720;
  }

  private getHighWidth(): number {
    return this.configService.get<number>('imageProcessing.highWidth', { infer: true }) ?? 1280;
  }

  private getQuality(): number {
    return this.configService.get<number>('imageProcessing.quality', { infer: true }) ?? 78;
  }

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

    switch (file.mimetype) {
      case 'image/jpeg':
        return '.jpg';
      case 'image/png':
        return '.png';
      case 'image/webp':
        return '.webp';
      case 'image/gif':
        return '.gif';
      default:
        return '.jpg';
    }
  }

  private resolveOriginalContentType(file: UploadedImageFile): string {
    if (file.mimetype?.trim()) {
      return file.mimetype;
    }

    switch (this.resolveInputExtension(file)) {
      case '.png':
        return 'image/png';
      case '.webp':
        return 'image/webp';
      case '.gif':
        return 'image/gif';
      case '.jpg':
      case '.jpeg':
      default:
        return 'image/jpeg';
    }
  }

  private async generateWebpVariant(
    inputPath: string,
    workingDir: string,
    name: Exclude<ProcessedImageVariantName, 'original'>,
    width: number,
  ): Promise<ProcessedImageResult['variants'][number]> {
    const outputPath = join(workingDir, `${name}.webp`);

    await this.runFfmpeg([
      '-y',
      '-i',
      inputPath,
      '-frames:v',
      '1',
      '-vf',
      `scale='min(${width},iw)':-2:force_original_aspect_ratio=decrease`,
      '-c:v',
      'libwebp',
      '-compression_level',
      '6',
      '-q:v',
      String(this.getQuality()),
      outputPath,
    ]);

    return {
      name,
      relativePath: `${name}.webp`,
      extension: '.webp',
      buffer: await readFile(outputPath),
      contentType: 'image/webp',
    };
  }

  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(
        `Image processing is enabled, but ffmpeg is unavailable at "${this.getFfmpegPath()}": ${
          error instanceof Error ? error.message : 'unknown error'
        }`,
      );
    }

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

  private async runFfmpeg(args: string[]): Promise<void> {
    await new Promise<void>((resolve, reject) => {
      const child = spawn(this.getFfmpegPath(), args, { windowsHide: true });
      let stderr = '';

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

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

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

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