import { Queue, Worker, type Job } from 'bullmq';
import Redis from 'ioredis';
import { env } from '../config/env';
import { prisma } from '../config/database';
import {
  transcodeToHls,
  cleanupHlsOutput,
  type HlsTranscodeResult,
} from '../modules/media/services/hls-transcode.service';
import { logger } from '../utils/logger';

interface HlsTranscodeJobData {
  videoId: number;
}

interface HlsTranscodeJobResult {
  videoId: number;
  variants: Array<{ name: string; height: number; bitrate: number; path: string }>;
}

const QUEUE_NAME = 'hls-transcode';

class HlsTranscodeQueueService {
  private queue: Queue<HlsTranscodeJobData, HlsTranscodeJobResult>;
  private worker: Worker<HlsTranscodeJobData, HlsTranscodeJobResult> | null = null;
  private redis: Redis | null = null;

  constructor() {
    this.queue = new Queue(QUEUE_NAME, {
      connection: { url: env.REDIS_URL },
      defaultJobOptions: {
        attempts: 2,
        backoff: { type: 'exponential', delay: 60_000 },
        removeOnComplete: { age: 7 * 24 * 60 * 60, count: 200 },
        removeOnFail: { age: 30 * 24 * 60 * 60 },
      },
    });
  }

  private getRedis(): Redis {
    if (!this.redis) {
      this.redis = new Redis(env.REDIS_URL);
    }
    return this.redis;
  }

  /** Append a log line for a job (Redis list with 24h TTL + pubsub for SSE). */
  private async appendJobLog(jobId: string, line: string): Promise<void> {
    const key = `hls-log:${jobId}`;
    const redis = this.getRedis();
    await redis.rpush(key, line);
    await redis.expire(key, 86400);
    await redis.publish(`hls-log-stream:${jobId}`, line);
  }

  /** Get accumulated log lines for a job. */
  async getJobLog(jobId: string): Promise<string[]> {
    const key = `hls-log:${jobId}`;
    return this.getRedis().lrange(key, 0, -1);
  }

  /**
   * Start the in-process worker. Concurrency is 1 because FFmpeg saturates
   * the available cores; running two transcodes in parallel just slows both
   * down and risks OOM.
   */
  startWorker(): void {
    this.worker = new Worker<HlsTranscodeJobData, HlsTranscodeJobResult>(
      QUEUE_NAME,
      async (job) => this.processJob(job),
      {
        connection: { url: env.REDIS_URL },
        concurrency: 1,
      },
    );

    this.worker.on('completed', (job) => {
      logger.info(`[hls] job ${job.id} completed for video ${job.data.videoId}`);
    });

    this.worker.on('failed', (job, err) => {
      logger.error(`[hls] job ${job?.id} failed: ${err.message}`);
    });

    logger.info('[hls] HLS transcode queue worker started');
  }

  private async processJob(job: Job<HlsTranscodeJobData>): Promise<HlsTranscodeJobResult> {
    const { videoId } = job.data;
    const jobId = job.id!;

    await this.appendJobLog(jobId, `Starting HLS transcode for video ${videoId}`);

    const video = await prisma.video.findUnique({ where: { id: videoId } });
    if (!video) {
      throw new Error(`Video ${videoId} not found`);
    }
    if (!video.path) {
      throw new Error(`Video ${videoId} has no source path`);
    }
    if (!video.durationSeconds || video.durationSeconds <= 0) {
      throw new Error(`Video ${videoId} has no duration`);
    }
    if (!video.width || !video.height) {
      throw new Error(`Video ${videoId} has no dimensions`);
    }

    await prisma.video.update({
      where: { id: videoId },
      data: {
        hlsStatus: 'PROCESSING',
        hlsJobId: jobId,
        hlsTranscodeError: null,
      },
    });

    let result: HlsTranscodeResult;
    try {
      result = await transcodeToHls({
        videoId,
        sourcePath: video.path,
        durationSeconds: video.durationSeconds,
        sourceWidth: video.width,
        sourceHeight: video.height,
        onProgress: async (percent) => {
          await job.updateProgress(percent);
          // Coarse log lines so the UI can stream progress without flooding.
          if (percent % 10 === 0) {
            await this.appendJobLog(jobId, `Transcoding: ${percent}%`);
          }
        },
      });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      await this.appendJobLog(jobId, `FAILED: ${message}`);
      // Best-effort cleanup; transcodeToHls already rms partial output on failure.
      await cleanupHlsOutput(videoId).catch(() => {});
      await prisma.video.update({
        where: { id: videoId },
        data: {
          hlsStatus: 'FAILED',
          hlsTranscodeError: message.slice(0, 1000),
        },
      });
      throw err; // BullMQ will retry per attempts/backoff config.
    }

    await prisma.video.update({
      where: { id: videoId },
      data: {
        hlsStatus: 'READY',
        hlsManifestPath: result.manifestRelativePath,
        hlsTranscodedAt: new Date(),
        hlsTranscodeError: null,
        hlsVariants: result.variants as unknown as object,
      },
    });

    await this.appendJobLog(jobId, `Transcode complete: ${result.variants.map(v => v.name).join(', ')}`);
    await this.getRedis().publish(`hls-log-stream:${jobId}`, '__DONE__');

    return {
      videoId,
      variants: result.variants,
    };
  }

  /**
   * Submit a transcode job. When ENABLE_HLS_TRANSCODE=false, this is a no-op
   * that marks the video as SKIPPED (so the upload flow stays synchronous and
   * the operator can opt in later via the backfill script).
   */
  async submitTranscode(videoId: number): Promise<{ jobId: string | null; skipped: boolean }> {
    if (env.ENABLE_HLS_TRANSCODE !== 'true') {
      await prisma.video.update({
        where: { id: videoId },
        data: { hlsStatus: 'SKIPPED' },
      });
      return { jobId: null, skipped: true };
    }

    const job = await this.queue.add('transcode', { videoId });
    await prisma.video.update({
      where: { id: videoId },
      data: { hlsStatus: 'PENDING', hlsJobId: job.id ?? null },
    });

    logger.info(`[hls] enqueued transcode job ${job.id} for video ${videoId}`);
    return { jobId: job.id ?? null, skipped: false };
  }

  /**
   * Force-enqueue a transcode job, bypassing the ENABLE_HLS_TRANSCODE flag.
   * Used by the backfill script so an admin can run the backfill against
   * existing videos without flipping the flag for new uploads.
   */
  async forceSubmitTranscode(videoId: number): Promise<string> {
    const job = await this.queue.add('transcode', { videoId });
    await prisma.video.update({
      where: { id: videoId },
      data: { hlsStatus: 'PENDING', hlsJobId: job.id ?? null },
    });
    logger.info(`[hls] (forced) enqueued transcode job ${job.id} for video ${videoId}`);
    return job.id!;
  }

  /** Get a single job by ID. */
  async getJob(jobId: string) {
    const job = await this.queue.getJob(jobId);
    if (!job) return null;

    const state = await job.getState();
    return {
      id: job.id,
      data: job.data,
      state,
      progress: job.progress as number,
      returnvalue: job.returnvalue,
      failedReason: job.failedReason,
      timestamp: job.timestamp,
      finishedOn: job.finishedOn,
      processedOn: job.processedOn,
    };
  }

  async close(): Promise<void> {
    if (this.worker) {
      await this.worker.close();
    }
    await this.queue.close();
    if (this.redis) {
      await this.redis.quit();
    }
    logger.info('[hls] HLS transcode queue closed');
  }
}

export const hlsTranscodeQueueService = new HlsTranscodeQueueService();
