import { Queue, Worker, type Job } from 'bullmq';
import { spawn, type ChildProcess } from 'child_process';
import { writeFile, readFile, unlink, mkdir } from 'fs/promises';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { tmpdir } from 'os';
import Redis from 'ioredis';
import { env } from '../config/env';
import { prisma } from '../config/database';
import { extractVideoMetadata, validateVideoFile } from '../modules/media/services/ffprobe.service';
import { ThumbnailService } from '../modules/media/services/thumbnail.service';
import { logger } from '../utils/logger';

interface VideoFetchJobData {
  urls: string[];
  submittedByUserId: string;
}

interface FetchResult {
  url: string;
  success: boolean;
  videoId?: number;
  title?: string;
  error?: string;
}

interface FetchJobResult {
  results: FetchResult[];
  totalUrls: number;
  successCount: number;
  failCount: number;
}

// Shell metacharacters that could enable command injection
const SHELL_METACHAR_REGEX = /[`$;|&<>(){}[\]\\!#]/;

// Hostnames that resolve to internal/cloud-metadata addresses
const BLOCKED_HOSTNAMES = new Set([
  'localhost',
  'metadata.google.internal',
  'metadata.gcp.internal',
  '169.254.169.254',          // AWS/GCP/Azure metadata
  '169.254.170.2',            // AWS ECS task metadata
  'kubernetes.default',
  'kubernetes.default.svc',
]);

// Docker internal container names used in this project
const DOCKER_INTERNAL_SUFFIXES = [
  '-changemaker',             // Our container naming convention
  '-rocketchat',
  '-listmonk',
];

/**
 * Check if an IP address is in a private/reserved range (SSRF protection).
 */
function isPrivateIP(hostname: string): boolean {
  // IPv6 loopback
  if (hostname === '::1' || hostname === '[::1]') return true;

  // Strip IPv6 brackets
  const clean = hostname.replace(/^\[|\]$/g, '');

  // IPv4 patterns
  const parts = clean.split('.').map(Number);
  if (parts.length === 4 && parts.every(p => !isNaN(p) && p >= 0 && p <= 255)) {
    const [a, b] = parts;
    if (a === 127) return true;                         // 127.0.0.0/8   loopback
    if (a === 10) return true;                          // 10.0.0.0/8    private
    if (a === 172 && b >= 16 && b <= 31) return true;   // 172.16.0.0/12 private
    if (a === 192 && b === 168) return true;            // 192.168.0.0/16 private
    if (a === 169 && b === 254) return true;            // 169.254.0.0/16 link-local / cloud metadata
    if (a === 0) return true;                           // 0.0.0.0/8
  }

  // IPv6 private ranges (simplified check for common prefixes)
  if (clean.startsWith('fc') || clean.startsWith('fd')) return true;   // ULA
  if (clean.startsWith('fe80')) return true;                           // Link-local

  return false;
}

/**
 * Sanitize and validate a URL for safe shell usage.
 * Returns the URL if valid, or null if invalid, contains shell metacharacters,
 * or targets private/internal network addresses (SSRF protection).
 */
function sanitizeUrl(url: string): string | null {
  if (!url || typeof url !== 'string') return null;

  const trimmed = url.trim();
  if (!trimmed) return null;

  // Validate URL structure
  let parsed: URL;
  try {
    parsed = new URL(trimmed);
    if (!['http:', 'https:'].includes(parsed.protocol)) return null;
  } catch {
    return null;
  }

  // Reject shell metacharacters
  if (SHELL_METACHAR_REGEX.test(trimmed)) return null;

  // SSRF protection: block private/internal IPs and hostnames
  const hostname = parsed.hostname.toLowerCase();

  if (isPrivateIP(hostname)) return null;
  if (BLOCKED_HOSTNAMES.has(hostname)) return null;
  if (DOCKER_INTERNAL_SUFFIXES.some(suffix => hostname.endsWith(suffix))) return null;

  // Block numeric IPv6 addresses entirely (too many bypass variants)
  if (hostname.includes(':')) return null;

  return trimmed;
}

// Active child processes for cancellation
const activeProcesses = new Map<string, ChildProcess>();

class VideoFetchQueueService {
  private queue: Queue;
  private worker: Worker | null = null;
  private redis: Redis | null = null;

  constructor() {
    this.queue = new Queue('video-fetch', {
      connection: { url: env.REDIS_URL },
      defaultJobOptions: {
        attempts: 1, // No retries for fetch jobs
        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 (stored in Redis list with 24h TTL)
   */
  private async appendJobLog(jobId: string, line: string): Promise<void> {
    const key = `fetch-log:${jobId}`;
    const redis = this.getRedis();
    await redis.rpush(key, line);
    await redis.expire(key, 86400); // 24 hours
    // Publish for SSE subscribers
    await redis.publish(`fetch-log-stream:${jobId}`, line);
  }

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

  /**
   * Start the worker to process fetch jobs
   */
  startWorker(): void {
    this.worker = new Worker(
      'video-fetch',
      async (job: Job<VideoFetchJobData>) => {
        const { urls, submittedByUserId } = job.data;
        const jobId = job.id!;
        const results: FetchResult[] = [];

        logger.info(`Processing video fetch job ${jobId}`, { urlCount: urls.length });
        await this.appendJobLog(jobId, `Starting fetch of ${urls.length} URL(s)...`);

        for (let i = 0; i < urls.length; i++) {
          const url = urls[i];
          const urlNum = i + 1;

          await this.appendJobLog(jobId, `\n--- URL ${urlNum}/${urls.length}: ${url} ---`);

          try {
            const result = await this.fetchSingleUrl(jobId, url);
            results.push({ url, success: true, videoId: result.videoId, title: result.title });
            await this.appendJobLog(jobId, `Imported as video #${result.videoId}: ${result.title}`);
          } catch (err) {
            const error = err instanceof Error ? err.message : String(err);
            results.push({ url, success: false, error });
            await this.appendJobLog(jobId, `FAILED: ${error}`);
          }

          // Update overall progress
          const percent = Math.round(((i + 1) / urls.length) * 100);
          await job.updateProgress(percent);
          // Publish progress event for SSE
          const redis = this.getRedis();
          await redis.publish(`fetch-log-stream:${jobId}`, `__PROGRESS__:${percent}`);
        }

        const successCount = results.filter(r => r.success).length;
        const failCount = results.length - successCount;

        await this.appendJobLog(jobId, `\n=== Fetch complete: ${successCount} succeeded, ${failCount} failed ===`);

        // Publish done event
        const redis = this.getRedis();
        await redis.publish(`fetch-log-stream:${jobId}`, '__DONE__');

        const jobResult: FetchJobResult = {
          results,
          totalUrls: urls.length,
          successCount,
          failCount,
        };

        return jobResult;
      },
      {
        connection: { url: env.REDIS_URL },
        concurrency: 2,
      }
    );

    this.worker.on('completed', (job) => {
      logger.info(`Video fetch job ${job.id} completed`);
    });

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

    logger.info('Video fetch queue worker started');
  }

  /**
   * Fetch a single URL using yt-dlp, extract metadata, and create Video record
   */
  private async fetchSingleUrl(
    jobId: string,
    url: string
  ): Promise<{ videoId: number; title: string }> {
    const inboxDir = '/media/local/inbox';
    await mkdir(inboxDir, { recursive: true });

    // Create a temp file to capture the output filepath from yt-dlp
    const tempFile = join(tmpdir(), `yt-dlp-output-${randomUUID()}.txt`);

    return new Promise(async (resolve, reject) => {
      const args = [
        '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
        '--merge-output-format', 'mp4',
        '-o', join(inboxDir, '%(title)s.%(ext)s'),
        '--restrict-filenames',
        '--no-overwrites',
        '--newline',
        '--print-to-file', 'after_move:filepath', tempFile,
        url,
      ];

      const child = spawn('yt-dlp', args);
      activeProcesses.set(jobId, child);

      let stderr = '';

      child.stdout.on('data', async (data) => {
        const lines = data.toString().split('\n');
        for (const line of lines) {
          const trimmed = line.trim();
          if (trimmed) {
            await this.appendJobLog(jobId, trimmed);
          }
        }
      });

      child.stderr.on('data', async (data) => {
        const text = data.toString();
        stderr += text;
        const lines = text.split('\n');
        for (const line of lines) {
          const trimmed = line.trim();
          if (trimmed) {
            await this.appendJobLog(jobId, `[stderr] ${trimmed}`);
          }
        }
      });

      child.on('close', async (code, signal) => {
        activeProcesses.delete(jobId);

        if (signal === 'SIGTERM' || signal === 'SIGKILL') {
          // Clean up temp file
          try { await unlink(tempFile); } catch {}
          reject(new Error('Fetch cancelled'));
          return;
        }

        if (code !== 0) {
          try { await unlink(tempFile); } catch {}
          reject(new Error(`yt-dlp exited with code ${code}: ${stderr.slice(0, 500)}`));
          return;
        }

        try {
          // Read the output file path
          const outputPath = (await readFile(tempFile, 'utf-8')).trim();
          await unlink(tempFile);

          if (!outputPath) {
            reject(new Error('yt-dlp did not produce an output file path'));
            return;
          }

          await this.appendJobLog(jobId, `Downloaded to: ${outputPath}`);

          // Validate the video file
          await this.appendJobLog(jobId, 'Validating video file...');
          const isValid = await validateVideoFile(outputPath);
          if (!isValid) {
            reject(new Error('Downloaded file is invalid or corrupted'));
            return;
          }

          // Extract metadata
          await this.appendJobLog(jobId, 'Extracting metadata...');
          const metadata = await extractVideoMetadata(outputPath);

          // Derive filename and title from the output path
          const filename = outputPath.split('/').pop() || 'unknown.mp4';
          const title = filename.replace(/\.[^.]+$/, '').replace(/_/g, ' ');

          // Create Video record (same pattern as upload.routes.ts)
          const video = await prisma.video.create({
            data: {
              path: outputPath,
              filename,
              originalFilename: filename,
              title,
              durationSeconds: metadata.durationSeconds,
              width: metadata.width,
              height: metadata.height,
              orientation: metadata.orientation,
              quality: metadata.quality,
              hasAudio: metadata.hasAudio,
              fileSize: metadata.fileSize,
              directoryType: 'inbox',
              isValid: true,
            },
          });

          await this.appendJobLog(jobId, `Created video record #${video.id}`);

          // Generate thumbnail
          try {
            const thumbnailPath = await ThumbnailService.generateThumbnail({
              videoPath: outputPath,
              videoId: video.id,
              duration: metadata.durationSeconds,
              orientation: metadata.orientation,
            });

            await prisma.video.update({
              where: { id: video.id },
              data: { thumbnailPath },
            });

            await this.appendJobLog(jobId, 'Thumbnail generated');
          } catch (thumbnailErr) {
            await this.appendJobLog(jobId, `Thumbnail generation failed (non-fatal): ${thumbnailErr instanceof Error ? thumbnailErr.message : 'Unknown error'}`);
          }

          // Enqueue HLS transcode (lazy import to avoid module-cycle with media-server bootstrap).
          try {
            const { hlsTranscodeQueueService } = await import('./hls-transcode-queue.service');
            const result = await hlsTranscodeQueueService.submitTranscode(video.id);
            if (result.skipped) {
              await this.appendJobLog(jobId, 'HLS transcode skipped (flag off)');
            } else {
              await this.appendJobLog(jobId, `HLS transcode enqueued (job ${result.jobId})`);
            }
          } catch (hlsErr) {
            await this.appendJobLog(jobId, `HLS enqueue failed (non-fatal): ${hlsErr instanceof Error ? hlsErr.message : 'Unknown error'}`);
          }

          resolve({ videoId: video.id, title });
        } catch (err) {
          reject(err);
        }
      });

      child.on('error', async (err) => {
        activeProcesses.delete(jobId);
        try { await unlink(tempFile); } catch {}
        reject(new Error(`Failed to spawn yt-dlp: ${err.message}`));
      });
    });
  }

  /**
   * Submit a new fetch job
   */
  async submitFetch(
    urls: string[],
    userId: string
  ): Promise<{ jobId: string; urlCount: number; sanitizedUrls: string[] }> {
    // Sanitize URLs
    const sanitizedUrls = urls
      .map(u => sanitizeUrl(u))
      .filter((u): u is string => u !== null);

    if (sanitizedUrls.length === 0) {
      throw new Error('No valid URLs provided. URLs must be valid HTTP(S) URLs without shell metacharacters.');
    }

    if (sanitizedUrls.length > 20) {
      throw new Error('Maximum 20 URLs per submission');
    }

    const job = await this.queue.add('fetch', {
      urls: sanitizedUrls,
      submittedByUserId: userId,
    });

    logger.info(`Submitted video fetch job ${job.id}`, {
      urlCount: sanitizedUrls.length,
      userId,
    });

    return {
      jobId: job.id!,
      urlCount: sanitizedUrls.length,
      sanitizedUrls,
    };
  }

  /**
   * 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();
    const progress = job.progress as number;

    return {
      id: job.id,
      data: job.data,
      state,
      progress,
      returnvalue: job.returnvalue as FetchJobResult | null,
      failedReason: job.failedReason,
      timestamp: job.timestamp,
      finishedOn: job.finishedOn,
      processedOn: job.processedOn,
    };
  }

  /**
   * Get recent fetch jobs
   */
  async getRecentJobs(limit: number = 20) {
    const jobs = await this.queue.getJobs(
      ['active', 'waiting', 'delayed', 'completed', 'failed'],
      0,
      limit
    );

    const results = await Promise.all(
      jobs.map(async (job) => {
        const state = await job.getState();
        return {
          id: job.id,
          urls: job.data.urls,
          urlCount: job.data.urls.length,
          state,
          progress: job.progress as number,
          returnvalue: job.returnvalue as FetchJobResult | null,
          failedReason: job.failedReason,
          timestamp: job.timestamp,
          finishedOn: job.finishedOn,
          processedOn: job.processedOn,
        };
      })
    );

    // Sort by timestamp descending (newest first)
    return results.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
  }

  /**
   * Cancel a running or waiting job
   */
  async cancelJob(jobId: string): Promise<boolean> {
    const job = await this.queue.getJob(jobId);
    if (!job) return false;

    const state = await job.getState();

    if (state === 'active') {
      // Kill the yt-dlp process
      const proc = activeProcesses.get(jobId);
      if (proc) {
        proc.kill('SIGTERM');
        setTimeout(() => {
          try { proc.kill('SIGKILL'); } catch {}
        }, 3000);
      }
      // Move to failed
      await job.moveToFailed(new Error('Cancelled by user'), job.id!, true);
      await this.appendJobLog(jobId, '\n=== FETCH CANCELLED BY USER ===');
    } else if (state === 'waiting' || state === 'delayed') {
      await job.remove();
    } else {
      return false; // Can't cancel completed/failed jobs
    }

    return true;
  }

  /**
   * Subscribe to log stream for a job (returns Redis subscriber)
   */
  createLogSubscriber(jobId: string): Redis {
    const sub = new Redis(env.REDIS_URL);
    sub.subscribe(`fetch-log-stream:${jobId}`);
    return sub;
  }

  /**
   * Close queue and worker
   */
  async close(): Promise<void> {
    // Kill any active processes
    for (const [id, proc] of activeProcesses) {
      try { proc.kill('SIGKILL'); } catch {}
      activeProcesses.delete(id);
    }

    if (this.worker) {
      await this.worker.close();
    }
    await this.queue.close();
    if (this.redis) {
      await this.redis.quit();
    }
    logger.info('Video fetch queue closed');
  }
}

export const videoFetchQueueService = new VideoFetchQueueService();
