/**
 * Asset Image Conversion Utility
 *
 * Converts brand assets between image formats (PNG, JPG, SVG, ICO)
 * using sharp for raster conversion and to-ico for ICO packaging.
 * Converted files are cached on disk to avoid redundant processing.
 */

import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import toIco from 'to-ico';
import { base64ToBuffer } from './fileStorage';
import { supportsTransparentBackground } from './assetFormats';
import { removeBackgroundWithAI } from './aiBackgroundRemoval';

// ─── Constants ────────────────────────────────────────────────────────────────

const BRAND_ASSETS_DIR = path.resolve(process.cwd(), 'uploads', 'brand-assets');
const CONVERTED_DIR = path.resolve(process.cwd(), 'uploads', 'brand-assets', 'converted');

// Suffix appended to cached conversion filenames. Bumped whenever the output of
// a conversion changes, so stale cache entries are simply never hit again.
const CACHE_VERSION = '_v2';

// Pseudo-format used as the cache key for the AI cut-out. Not a download format
// callers can ask for by name — it is reached through getAiCutoutAsset().
const AI_CUTOUT_FORMAT = 'png-cutout';

// Ensure converted directory exists
if (!fs.existsSync(CONVERTED_DIR)) {
  fs.mkdirSync(CONVERTED_DIR, { recursive: true });
}

// ─── Types ────────────────────────────────────────────────────────────────────

interface AssetDocument {
  _id: string;
  url?: string;
  base64Data?: string;
  format?: string;
  type?: string;
  name?: string;
  fileName?: string;
  companyId?: string;
}

interface ConversionResult {
  buffer: Buffer;
  mimeType: string;
  extension: string;
}

// ─── Source Resolution ────────────────────────────────────────────────────────

/**
 * Resolve the source image buffer for a brand asset.
 * Tries disk file first (for new records), then falls back to base64Data (legacy).
 *
 * @returns Source buffer and detected format, or null if no source is available
 */
export async function resolveSourceBuffer(
  asset: AssetDocument
): Promise<{ buffer: Buffer; format: string } | null> {
  // Try disk file first (new records store files on disk)
  if (asset.url && asset.url.startsWith('/uploads/brand-assets/')) {
    const filePath = path.join(process.cwd(), asset.url);
    if (fs.existsSync(filePath)) {
      const buffer = fs.readFileSync(filePath);
      // Detect format from file extension
      const ext = path.extname(filePath).toLowerCase().replace('.', '');
      const formatMap: Record<string, string> = {
        png: 'png',
        jpg: 'jpg',
        jpeg: 'jpg',
        webp: 'webp',
        gif: 'gif',
        svg: 'svg',
        ico: 'ico',
      };
      return { buffer, format: formatMap[ext] || ext };
    }
  }

  // Fallback to base64Data (legacy records)
  if (asset.base64Data) {
    const { buffer, mimeType } = base64ToBuffer(asset.base64Data);
    const formatMap: Record<string, string> = {
      'image/png': 'png',
      'image/jpeg': 'jpg',
      'image/webp': 'webp',
      'image/gif': 'gif',
      'image/svg+xml': 'svg',
      'image/x-icon': 'ico',
    };
    return { buffer, format: formatMap[mimeType] || 'png' };
  }

  // No source available
  return null;
}

// ─── Background Handling (driven by the requested output format) ─────────────

/**
 * Per-channel tolerance when deciding whether a pixel belongs to the background.
 * Wide enough to absorb JPEG artefacts and the slight banding an image generator
 * leaves in a flat backdrop, tight enough to keep real artwork.
 */
const BG_TOLERANCE = 28;

/** Alpha at or below this counts as "already transparent". */
const ALPHA_EPSILON = 8;

/** Skip background analysis above this pixel count (memory guard). */
const MAX_BG_PIXELS = 24000000;

/** Abandon background removal if it would erase almost the whole image. */
const MAX_ERASED_RATIO = 0.97;

/** Treat the border as a solid backdrop only when this much of it is uniform. */
const MIN_BORDER_UNIFORMITY = 0.85;

interface BorderAnalysis {
  /** Mean colour of the opaque border pixels (white when there are none). */
  color: { r: number; g: number; b: number };
  /** Fraction of border pixels that are transparent or match `color`. */
  uniformity: number;
  /** Whether any opaque border pixel exists — i.e. whether `color` is meaningful. */
  hasOpaqueBorder: boolean;
}

/** Whether the RGBA pixel at byte offset `i` is within tolerance of `color`. */
function matchesColor(
  data: Buffer,
  i: number,
  color: { r: number; g: number; b: number }
): boolean {
  return (
    Math.abs(data[i] - color.r) <= BG_TOLERANCE &&
    Math.abs(data[i + 1] - color.g) <= BG_TOLERANCE &&
    Math.abs(data[i + 2] - color.b) <= BG_TOLERANCE
  );
}

/**
 * Inspect the one-pixel border of a raw RGBA image to work out what the
 * background is. An asset rendered on a flat backdrop has a uniform border;
 * a photo or full-bleed banner does not.
 */
function analyseBorder(data: Buffer, width: number, height: number): BorderAnalysis {
  const indices: number[] = [];
  for (let x = 0; x < width; x++) {
    indices.push(x); // top row
    indices.push((height - 1) * width + x); // bottom row
  }
  for (let y = 1; y < height - 1; y++) {
    indices.push(y * width); // left column
    indices.push(y * width + width - 1); // right column
  }

  let sumR = 0;
  let sumG = 0;
  let sumB = 0;
  let opaque = 0;
  for (const px of indices) {
    const i = px * 4;
    if (data[i + 3] <= ALPHA_EPSILON) continue;
    sumR += data[i];
    sumG += data[i + 1];
    sumB += data[i + 2];
    opaque++;
  }

  // Fully transparent border — nothing to strip and no colour to sample.
  if (opaque === 0) {
    return { color: { r: 255, g: 255, b: 255 }, uniformity: 1, hasOpaqueBorder: false };
  }

  const color = {
    r: Math.round(sumR / opaque),
    g: Math.round(sumG / opaque),
    b: Math.round(sumB / opaque),
  };

  let matching = 0;
  for (const px of indices) {
    const i = px * 4;
    if (data[i + 3] <= ALPHA_EPSILON || matchesColor(data, i, color)) matching++;
  }

  return { color, uniformity: matching / indices.length, hasOpaqueBorder: true };
}

/**
 * Make the solid background of an image transparent.
 *
 * Flood-fills inward from the edges, clearing every pixel that is connected to
 * the border and matches the border colour. Enclosed regions that happen to
 * share the background colour are only cleared when they are actually reachable
 * from an edge, so the artwork itself survives.
 *
 * Returns the source buffer untouched when the image has no flat backdrop to
 * remove, so photographic and full-bleed assets are never damaged.
 */
async function stripSolidBackground(sourceBuffer: Buffer): Promise<Buffer> {
  const { data, info } = await sharp(sourceBuffer)
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });

  const width = info.width;
  const height = info.height;
  const total = width * height;
  if (!width || !height || total > MAX_BG_PIXELS) return sourceBuffer;

  const border = analyseBorder(data, width, height);
  if (!border.hasOpaqueBorder) return sourceBuffer; // already transparent
  if (border.uniformity < MIN_BORDER_UNIFORMITY) return sourceBuffer; // not a flat backdrop

  // Flood fill inward from every border pixel that belongs to the background.
  const visited = new Uint8Array(total);
  const queue = new Int32Array(total);
  let head = 0;
  let tail = 0;

  const push = (px: number): void => {
    if (visited[px]) return;
    const i = px * 4;
    if (data[i + 3] > ALPHA_EPSILON && !matchesColor(data, i, border.color)) return;
    visited[px] = 1;
    queue[tail++] = px;
  };

  for (let x = 0; x < width; x++) {
    push(x);
    push((height - 1) * width + x);
  }
  for (let y = 0; y < height; y++) {
    push(y * width);
    push(y * width + width - 1);
  }

  let erased = 0;
  while (head < tail) {
    const px = queue[head++];
    data[px * 4 + 3] = 0;
    erased++;

    const x = px % width;
    const y = (px - x) / width;
    if (x > 0) push(px - 1);
    if (x < width - 1) push(px + 1);
    if (y > 0) push(px - width);
    if (y < height - 1) push(px + width);
  }

  // If nearly everything went, the subject shares the background colour —
  // hand back the original rather than an empty canvas.
  if (erased === 0 || erased / total > MAX_ERASED_RATIO) return sourceBuffer;

  // Soften the cut. Anti-aliased edge pixels sit just outside the match
  // tolerance, so a hard keep/clear decision leaves a fringe of background-
  // coloured pixels haloing the artwork. Ramp alpha across that band instead.
  for (let px = 0; px < total; px++) {
    if (visited[px]) continue;
    const i = px * 4;
    if (data[i + 3] === 0) continue;

    const x = px % width;
    const y = (px - x) / width;
    const touchesCleared =
      (x > 0 && !!visited[px - 1]) ||
      (x < width - 1 && !!visited[px + 1]) ||
      (y > 0 && !!visited[px - width]) ||
      (y < height - 1 && !!visited[px + width]);
    if (!touchesCleared) continue;

    const distance = Math.max(
      Math.abs(data[i] - border.color.r),
      Math.abs(data[i + 1] - border.color.g),
      Math.abs(data[i + 2] - border.color.b)
    );
    if (distance >= BG_TOLERANCE * 2) continue; // clearly artwork — leave it alone

    const ramp = Math.min(1, Math.max(0, (distance - BG_TOLERANCE) / BG_TOLERANCE));
    data[i + 3] = Math.round(data[i + 3] * ramp);
  }

  return sharp(data, { raw: { width, height, channels: 4 } }).png().toBuffer();
}

/**
 * Work out the colour a transparent image should be flattened onto for JPG.
 * Prefers the image's own backdrop colour so the existing background is kept,
 * falling back to white when there is nothing to sample.
 */
async function detectFlattenBackground(
  sourceBuffer: Buffer
): Promise<{ r: number; g: number; b: number }> {
  const white = { r: 255, g: 255, b: 255 };
  try {
    const meta = await sharp(sourceBuffer).metadata();
    if (!meta.hasAlpha) return white; // opaque source — flatten is a no-op anyway

    const { data, info } = await sharp(sourceBuffer)
      .ensureAlpha()
      .raw()
      .toBuffer({ resolveWithObject: true });
    if (!info.width || !info.height || info.width * info.height > MAX_BG_PIXELS) return white;

    const border = analyseBorder(data, info.width, info.height);
    return border.hasOpaqueBorder && border.uniformity >= MIN_BORDER_UNIFORMITY
      ? border.color
      : white;
  } catch {
    return white;
  }
}

// ─── Conversion Functions ────────────────────────────────────────────────────

/**
 * Convert an image buffer to JPG. JPG cannot carry an alpha channel, so any
 * transparency is flattened onto the asset's own backdrop colour (white when it
 * has none) — the result always has a normal, fully opaque background.
 */
async function pngToJpg(sourceBuffer: Buffer, quality = 92): Promise<Buffer> {
  const background = await detectFlattenBackground(sourceBuffer);
  return sharp(sourceBuffer)
    .flatten({ background })
    .jpeg({ quality })
    .toBuffer();
}

/**
 * Convert any raster image buffer to PNG (straight format conversion, background
 * left as-is). Used as the intermediate step for the SVG and ICO targets.
 */
async function toPng(sourceBuffer: Buffer): Promise<Buffer> {
  return sharp(sourceBuffer)
    .png()
    .toBuffer();
}

/**
 * Convert any raster image buffer to PNG with a transparent background — the
 * PNG download path. Falls back to a plain PNG conversion when the image has no
 * solid backdrop to strip.
 */
async function toTransparentPng(sourceBuffer: Buffer): Promise<Buffer> {
  const stripped = await stripSolidBackground(sourceBuffer);
  return sharp(stripped).png().toBuffer();
}

/**
 * Create an SVG wrapper with an embedded base64 PNG image.
 * This produces a valid SVG file containing a raster image —
 * commonly used for logos that need SVG format for web compatibility.
 */
function svgFromPng(pngBuffer: Buffer, width: number, height: number): string {
  const base64 = pngBuffer.toString('base64');
  return [
    '<?xml version="1.0" encoding="UTF-8"?>',
    `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
    `  <image href="data:image/png;base64,${base64}" width="${width}" height="${height}"/>`,
    '</svg>',
  ].join('\n');
}

/**
 * Get image dimensions from a buffer using sharp metadata.
 */
async function getImageDimensions(buffer: Buffer): Promise<{ width: number; height: number }> {
  const metadata = await sharp(buffer).metadata();
  return {
    width: metadata.width || 1024,
    height: metadata.height || 1024,
  };
}

/**
 * Convert a PNG buffer to ICO format with multiple sizes (16x16, 32x32, 48x48).
 */
async function pngToIco(sourceBuffer: Buffer): Promise<Buffer> {
  // Resize to standard favicon sizes
  const sizes = [16, 32, 48];
  const pngBuffers: Buffer[] = [];

  for (const size of sizes) {
    const resized = await sharp(sourceBuffer)
      .resize(size, size, { fit: 'cover' })
      .png()
      .toBuffer();
    pngBuffers.push(resized);
  }

  // Package as ICO
  const icoBuffer = await toIico(pngBuffers);
  return Buffer.from(icoBuffer);
}

/**
 * Wrapper around to-ico that handles the module interface.
 */
async function toIico(buffers: Buffer[]): Promise<Buffer> {
  // to-ico expects an array of PNG buffers
  const result = await toIco(buffers);
  return Buffer.isBuffer(result) ? result : Buffer.from(result);
}

/**
 * Convert a JPG buffer to SVG (embed as base64 JPG inside SVG).
 */
async function jpgToSvg(sourceBuffer: Buffer, width: number, height: number): Promise<string> {
  const base64 = sourceBuffer.toString('base64');
  return [
    '<?xml version="1.0" encoding="UTF-8"?>',
    `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
    `  <image href="data:image/jpeg;base64,${base64}" width="${width}" height="${height}"/>`,
    '</svg>',
  ].join('\n');
}

// ─── Cache Management ─────────────────────────────────────────────────────────

/**
 * Get the cached file path for a converted asset.
 * Format: uploads/brand-assets/converted/<uuid-stem>_<format>.<ext>
 */
export function getCachedPath(originalUrl: string, targetFormat: string): string {
  const filename = originalUrl.replace('/uploads/brand-assets/', '');
  const stem = path.parse(filename).name;
  const extMap: Record<string, string> = {
    png: 'png',
    jpg: 'jpg',
    svg: 'svg',
    ico: 'ico',
    webp: 'webp',
    [AI_CUTOUT_FORMAT]: 'png',
  };
  const ext = extMap[targetFormat] || targetFormat;
  // CACHE_VERSION is part of the filename so conversions produced before the
  // format-driven background handling landed are not served from cache.
  return path.join(CONVERTED_DIR, `${stem}_${targetFormat}${CACHE_VERSION}.${ext}`);
}

/**
 * Check if a cached conversion exists on disk.
 */
function cacheExists(cachePath: string): boolean {
  return fs.existsSync(cachePath);
}

/**
 * Save a conversion result to the cache.
 */
function saveToCache(cachePath: string, data: Buffer | string): void {
  const dir = path.dirname(cachePath);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  if (typeof data === 'string') {
    fs.writeFileSync(cachePath, data, 'utf-8');
  } else {
    fs.writeFileSync(cachePath, data);
  }
}

/**
 * Delete all cached conversion files for a given original asset URL.
 * Called when an asset is updated or deleted.
 */
export function deleteConvertedFiles(originalUrl: string): void {
  if (!originalUrl || !originalUrl.startsWith('/uploads/brand-assets/')) {
    return;
  }

  const filename = originalUrl.replace('/uploads/brand-assets/', '');
  const stem = path.parse(filename).name;

  try {
    const files = fs.readdirSync(CONVERTED_DIR);
    for (const file of files) {
      if (file.startsWith(`${stem}_`)) {
        fs.unlinkSync(path.join(CONVERTED_DIR, file));
      }
    }
  } catch (err) {
    console.error('[AssetConversion] Failed to clean up converted files:', err);
  }
}

// ─── Main Conversion Orchestrator ────────────────────────────────────────────

/**
 * Convert a brand asset to the requested format.
 * Uses disk caching to avoid redundant conversions.
 *
 * @param asset - The brand asset document
 * @param targetFormat - The desired output format (png, jpg, svg, ico)
 * @returns Conversion result with buffer, mime type, and extension
 */
export async function getConvertedAsset(
  asset: AssetDocument,
  targetFormat: string
): Promise<ConversionResult | null> {
  // Resolve the source image
  const source = await resolveSourceBuffer(asset);
  if (!source) {
    return null;
  }

  const { buffer: sourceBuffer, format: sourceFormat } = source;

  // If the target format matches the source format, no conversion needed.
  // A PNG asked of a cut-out type is the exception: its background still has to
  // be made transparent, so it goes through the conversion path below.
  const needsBackgroundRemoval =
    targetFormat === 'png' && supportsTransparentBackground(asset.type || '');
  const isPassThrough =
    !needsBackgroundRemoval &&
    (sourceFormat === targetFormat || (sourceFormat === 'jpeg' && targetFormat === 'jpg'));
  if (isPassThrough) {
    const mimeTypeMap: Record<string, string> = {
      png: 'image/png',
      jpg: 'image/jpeg',
      jpeg: 'image/jpeg',
      webp: 'image/webp',
      gif: 'image/gif',
      svg: 'image/svg+xml',
      ico: 'image/x-icon',
    };
    return {
      buffer: sourceBuffer,
      mimeType: mimeTypeMap[sourceFormat] || 'application/octet-stream',
      extension: sourceFormat === 'jpeg' ? 'jpg' : sourceFormat,
    };
  }

  // Check cache
  if (asset.url) {
    const cachePath = getCachedPath(asset.url, targetFormat);
    if (cacheExists(cachePath)) {
      const cachedBuffer = fs.readFileSync(cachePath);
      const mimeTypeMap: Record<string, string> = {
        png: 'image/png',
        jpg: 'image/jpeg',
        svg: 'image/svg+xml',
        ico: 'image/x-icon',
        webp: 'image/webp',
      };
      return {
        buffer: cachedBuffer,
        mimeType: mimeTypeMap[targetFormat] || 'application/octet-stream',
        extension: targetFormat,
      };
    }
  }

  // Perform the conversion
  let result: ConversionResult;

  switch (targetFormat) {
    case 'jpg': {
      const convertedBuffer = await pngToJpg(sourceBuffer);
      result = {
        buffer: convertedBuffer,
        mimeType: 'image/jpeg',
        extension: 'jpg',
      };
      break;
    }

    case 'png': {
      // Only cut-out asset types get their background stripped; everything else
      // is a straight PNG encode of the stored image.
      const convertedBuffer = supportsTransparentBackground(asset.type || '')
        ? await toTransparentPng(sourceBuffer)
        : await toPng(sourceBuffer);
      result = {
        buffer: convertedBuffer,
        mimeType: 'image/png',
        extension: 'png',
      };
      break;
    }

    case 'svg': {
      // For SVG, we embed the source image as base64 in an SVG wrapper
      let pngBuffer: Buffer;
      if (sourceFormat === 'png') {
        pngBuffer = sourceBuffer;
      } else {
        // Convert to PNG first for consistent embedding
        pngBuffer = await toPng(sourceBuffer);
      }
      const dimensions = await getImageDimensions(pngBuffer);
      const svgString = svgFromPng(pngBuffer, dimensions.width, dimensions.height);
      result = {
        buffer: Buffer.from(svgString, 'utf-8'),
        mimeType: 'image/svg+xml',
        extension: 'svg',
      };
      break;
    }

    case 'ico': {
      // Convert to PNG first if needed, then package as ICO
      let pngBuffer: Buffer;
      if (sourceFormat === 'png') {
        pngBuffer = sourceBuffer;
      } else {
        pngBuffer = await toPng(sourceBuffer);
      }
      const icoBuffer = await pngToIco(pngBuffer);
      result = {
        buffer: icoBuffer,
        mimeType: 'image/x-icon',
        extension: 'ico',
      };
      break;
    }

    default:
      return null;
  }

  // Save to cache
  if (asset.url) {
    const cachePath = getCachedPath(asset.url, targetFormat);
    try {
      saveToCache(cachePath, result.buffer);
    } catch (err) {
      console.error('[AssetConversion] Failed to cache conversion:', err);
      // Non-critical — the conversion still succeeded
    }
  }

  return result;
}

/**
 * PNG with the subject cut out of its background by the AI segmentation pass.
 *
 * Use this when the cheap path cannot help — a photographic or otherwise
 * non-flat background, which the flood fill in toTransparentPng() deliberately
 * leaves alone. Results are cached on disk exactly like every other conversion,
 * because each miss is a paid, multi-second provider call.
 *
 * @throws when the provider call fails, so the caller can report why.
 */
export async function getAiCutoutAsset(
  asset: AssetDocument,
  userId?: string
): Promise<ConversionResult | null> {
  const cachePath = asset.url ? getCachedPath(asset.url, AI_CUTOUT_FORMAT) : null;
  if (cachePath && cacheExists(cachePath)) {
    return { buffer: fs.readFileSync(cachePath), mimeType: 'image/png', extension: 'png' };
  }

  const source = await resolveSourceBuffer(asset);
  if (!source) return null;

  const sourceMimeMap: Record<string, string> = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    webp: 'image/webp',
    gif: 'image/gif',
  };
  const sourceMime = sourceMimeMap[source.format] || 'image/png';

  const buffer = await removeBackgroundWithAI(source.buffer, sourceMime, userId);

  if (cachePath) {
    try {
      saveToCache(cachePath, buffer);
    } catch (err) {
      console.error('[AssetConversion] Failed to cache AI cut-out:', err);
      // Non-critical — the cut-out still succeeded.
    }
  }

  return { buffer, mimeType: 'image/png', extension: 'png' };
}

/**
 * Stream the original file directly from disk (no conversion needed).
 * Used when the requested format matches the stored format.
 */
export function streamOriginalFile(asset: AssetDocument): fs.ReadStream | null {
  if (!asset.url || !asset.url.startsWith('/uploads/brand-assets/')) {
    return null;
  }

  const filePath = path.join(process.cwd(), asset.url);
  if (!fs.existsSync(filePath)) {
    return null;
  }

  return fs.createReadStream(filePath);
}

/**
 * Sanitize an asset name for use as a download filename.
 * Replaces spaces with hyphens, removes special characters.
 */
export function sanitizeFilename(name: string): string {
  return name
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^a-z0-9\-]/g, '')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '') || 'asset';
}