/**
 * File attachment rendering and image caching (memory + IndexedDB).
 * Also owns the server host state and URL resolution used by other modules.
 */

import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { observeMedia } from "@lib/media-visibility";
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { ensureHttpProxy } from "@lib/httpProxy";
import { save } from "@tauri-apps/plugin-dialog";

const log = createLogger("attachments");
import { writeFile } from "@tauri-apps/plugin-fs";
import type { Attachment } from "@lib/types";
import { openImageLightbox } from "./media";

// -- Server host state --------------------------------------------------------

/** Module-level server host for resolving relative attachment URLs. */
let _serverHost: string | null = null;

/** Set the server host (called once from MainPage on connect). */
export function setServerHost(host: string): void {
  _serverHost = host.toLowerCase();
}

/** Resolve a potentially relative URL to a full URL using the server host. */
export function resolveServerUrl(url: string): string {
  if (url.startsWith("http://") || url.startsWith("https://")) {
    return url;
  }
  if (_serverHost !== null) {
    return `https://${_serverHost}${url}`;
  }
  return url;
}

// -- Helpers ------------------------------------------------------------------

export function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function isImageMime(mime: string): boolean {
  return mime.startsWith("image/");
}

export function isSafeUrl(url: string): boolean {
  try {
    const parsed = new URL(url, window.location.origin);
    return parsed.protocol === "http:" || parsed.protocol === "https:";
  } catch {
    return false;
  }
}

// ---------------------------------------------------------------------------
// Image cache: memory + IndexedDB for persistence across restarts
// ---------------------------------------------------------------------------

/** In-memory cache for instant re-render (LRU eviction at CACHE_MAX). */
const memoryCache = new Map<string, string>();
const CACHE_MAX = 200;
let attachmentCacheGeneration = 0;

export function clearAttachmentCaches(): void {
  attachmentCacheGeneration += 1;
  memoryCache.clear();
  inFlight.clear();
}

/** Safe MIME types allowed in data: URIs — blocks script injection via crafted Content-Type. */
// Note: image/svg+xml is intentionally excluded — SVGs can execute JS if
// loaded in <object>, <embed>, or <iframe> contexts. Only raster formats
// are considered safe for data: URI rendering via <img>.
const SAFE_MIME_TYPES = new Set([
  "image/png",
  "image/jpeg",
  "image/gif",
  "image/webp",
  "image/avif",
  "image/bmp",
  "video/mp4",
  "video/webm",
  "audio/mpeg",
  "audio/ogg",
  "audio/wav",
  "application/pdf",
]);

/** Sanitize a Content-Type header value for use in a data: URI. */
function sanitizeContentType(raw: string): string {
  const mime = raw.split(";")[0]?.trim() ?? "";
  return SAFE_MIME_TYPES.has(mime) ? raw : "application/octet-stream";
}

/** Check if a URL points to the configured OwnCord server. */
function isServerUrl(url: string): boolean {
  if (_serverHost === null) return false;
  try {
    const parsed = new URL(url);
    return parsed.host === _serverHost;
  } catch {
    return false;
  }
}

/** Report whether a URL targets the configured OwnCord server host. */
export function isTrustedServerUrl(url: string): boolean {
  return isServerUrl(url);
}

/**
 * If `url` targets the OwnCord server, return an equivalent URL pointing at the
 * Rust HTTP TOFU proxy's loopback origin (cert-pinned) with the same path and
 * query. Non-server URLs (external images) are returned unchanged so they use a
 * normal validated HTTPS fetch.
 */
async function toFetchUrl(url: string): Promise<string> {
  if (!isServerUrl(url)) return url;
  const parsed = new URL(url);
  const origin = await ensureHttpProxy(parsed.host);
  return `${origin}${parsed.pathname}${parsed.search}`;
}

/** In-flight fetch promises to prevent duplicate concurrent requests. */
const inFlight = new Map<string, Promise<string | null>>();

/** IndexedDB database name and store. */
const IDB_NAME = "owncord-image-cache";
const IDB_STORE = "images";
const IDB_VERSION = 1;

/** Open (or create) the IndexedDB database. */
export function openCacheDb(): Promise<IDBDatabase | null> {
  return new Promise((resolve) => {
    try {
      const req = indexedDB.open(IDB_NAME, IDB_VERSION);
      req.onupgradeneeded = () => {
        const db = req.result;
        if (!db.objectStoreNames.contains(IDB_STORE)) {
          db.createObjectStore(IDB_STORE);
        }
      };
      // oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
      req.onsuccess = () => resolve(req.result);
      // oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
      req.onerror = () => resolve(null);
    } catch {
      resolve(null);
    }
  });
}

function closeDbAfterTransaction(tx: IDBTransaction, db: IDBDatabase): void {
  const close = (): void => db.close();
  // oxlint-disable-next-line prefer-add-event-listener -- IDBTransaction does not support addEventListener
  tx.oncomplete = close;
  // oxlint-disable-next-line prefer-add-event-listener -- IDBTransaction does not support addEventListener
  tx.onabort = close;
  // oxlint-disable-next-line prefer-add-event-listener -- IDBTransaction does not support addEventListener
  tx.onerror = close;
}

/** Read a cached data URL from IndexedDB. */
async function idbGet(url: string): Promise<string | null> {
  const db = await openCacheDb();
  if (db === null) return null;
  return new Promise((resolve) => {
    try {
      const tx = db.transaction(IDB_STORE, "readonly");
      closeDbAfterTransaction(tx, db);
      const store = tx.objectStore(IDB_STORE);
      const req = store.get(url);
      // oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
      req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null);
      // oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
      req.onerror = () => resolve(null);
    } catch {
      db.close();
      resolve(null);
    }
  });
}

/** Write a data URL to IndexedDB. */
async function idbPut(url: string, dataUrl: string): Promise<void> {
  const db = await openCacheDb();
  if (db === null) return;
  try {
    const tx = db.transaction(IDB_STORE, "readwrite");
    closeDbAfterTransaction(tx, db);
    tx.objectStore(IDB_STORE).put(dataUrl, url);
  } catch {
    db.close();
    // IndexedDB full or unavailable — ignore
  }
}

/** Convert a Uint8Array to a base64 string. */
export function uint8ToBase64(bytes: Uint8Array): string {
  // Process in chunks to avoid call stack overflow on large files
  const CHUNK = 8192;
  let binary = "";
  for (let i = 0; i < bytes.length; i += CHUNK) {
    const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length));
    binary += String.fromCharCode(...slice);
  }
  return btoa(binary);
}

/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */
export function fetchImageAsDataUrl(url: string): Promise<string | null> {
  const generation = attachmentCacheGeneration;

  // 1. Memory cache (instant)
  const cached = memoryCache.get(url);
  if (cached !== undefined) return Promise.resolve(cached);

  // 2. Deduplicate concurrent requests for the same URL
  const existing = inFlight.get(url);
  if (existing !== undefined) return existing;

  const promise = (async (): Promise<string | null> => {
    // 3. IndexedDB cache (persists across restarts)
    const idbCached = await idbGet(url);
    if (idbCached !== null) {
      if (generation !== attachmentCacheGeneration) return null;
      if (memoryCache.size >= CACHE_MAX) {
        const firstKey = memoryCache.keys().next().value;
        if (firstKey !== undefined) memoryCache.delete(firstKey);
      }
      memoryCache.set(url, idbCached);
      return idbCached;
    }

    // 4. Network fetch. Server-hosted images go through the Rust HTTP TOFU
    // proxy (cert-pinned, same trust store as the WS proxy); external images
    // use a normal validated HTTPS fetch. isSafeUrl restricts to http/https and
    // responses are only used as image data, never executed.
    try {
      const res = await tauriFetch(await toFetchUrl(url));
      if (!res.ok) return null;

      const rawCt = res.headers.get("content-type") ?? "";
      const contentType = sanitizeContentType(rawCt);
      const buffer = await res.arrayBuffer();
      const base64 = uint8ToBase64(new Uint8Array(buffer));
      const dataUrl = `data:${contentType};base64,${base64}`;

      if (generation !== attachmentCacheGeneration) {
        return null;
      }

      // Store in both caches (LRU eviction)
      if (memoryCache.size >= CACHE_MAX) {
        const firstKey = memoryCache.keys().next().value;
        if (firstKey !== undefined) memoryCache.delete(firstKey);
      }
      memoryCache.set(url, dataUrl);
      void idbPut(url, dataUrl);

      return dataUrl;
    } catch (err) {
      log.error("Failed to fetch attachment image", { url, error: String(err) });
      return null;
    }
  })();

  inFlight.set(url, promise);
  void promise.finally(() => {
    if (inFlight.get(url) === promise) {
      inFlight.delete(url);
    }
  });

  return promise;
}

// -- Attachment rendering -----------------------------------------------------

export function renderAttachment(att: Attachment): HTMLDivElement {
  const resolvedUrl = resolveServerUrl(att.url);
  if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) {
    const wrap = createElement("div", { class: "msg-image" });

    // Reserve space using server-provided dimensions to prevent layout shift.
    if (att.width != null && att.height != null && att.width > 0 && att.height > 0) {
      const maxW = 400,
        maxH = 350;
      const scale = Math.min(1, maxW / att.width, maxH / att.height);
      const w = Math.round(att.width * scale);
      const h = Math.round(att.height * scale);
      wrap.style.width = `${w}px`;
      wrap.style.height = `${h}px`;
    } else {
      // Fallback for old attachments without dimensions — use placeholder height.
      wrap.style.minHeight = "200px";
    }

    function attachLightbox(img: HTMLImageElement): void {
      img.addEventListener("click", () => {
        openImageLightbox(img.src, att.filename);
      });
    }

    const isGif = att.mime === "image/gif";

    // Clear min-height reservation and cache the natural height so virtual
    // scroll rebuilds don't oscillate between estimated and actual heights.
    // Measure synchronously to avoid rAF race with ResizeObserver.
    const clearReservation = (): void => {
      wrap.style.minHeight = "";
      const h = wrap.offsetHeight;
      if (h > 0 && att.width == null) {
        // Only cache for fallback path (no server-provided dimensions).
        // Set min-height to prevent oscillation on virtual scroll rebuild.
        wrap.style.minHeight = `${h}px`;
      }
    };

    // Check cache first for instant render
    const cached = memoryCache.get(resolvedUrl);
    if (cached !== undefined) {
      const img = createElement("img", {
        src: cached,
        alt: att.filename,
      });
      attachLightbox(img);
      img.addEventListener(
        "load",
        () => {
          clearReservation();
          if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
        },
        { once: true },
      );
      wrap.appendChild(img);
    } else {
      // Show loading placeholder, then replace with image
      const placeholder = createElement("div", { class: "placeholder-img loading" }, att.filename);
      wrap.appendChild(placeholder);

      void fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => {
        if (dataUrl !== null) {
          const img = createElement("img", {
            src: dataUrl,
            alt: att.filename,
          });
          attachLightbox(img);
          img.addEventListener(
            "load",
            () => {
              clearReservation();
              if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
            },
            { once: true },
          );
          placeholder.replaceWith(img);
        } else {
          placeholder.classList.remove("loading");
        }
      });
    }

    return wrap;
  }
  const wrap = createElement("div", { class: "msg-file" });
  const inner = createElement("div", { class: "msg-file-inner" });
  const icon = createElement("div", { class: "msg-file-icon" });
  icon.appendChild(createIcon("file-text", 20));
  const nameEl = createElement("div", { class: "msg-file-name" }, att.filename);
  nameEl.addEventListener("click", () => {
    void downloadFile(resolvedUrl, att.filename);
  });
  const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size));
  const info = createElement("div", {});
  appendChildren(info, nameEl, sizeEl);
  const downloadBtn = createElement("button", {
    class: "msg-file-download",
    title: "Download",
  });
  downloadBtn.appendChild(createIcon("download", 16));
  downloadBtn.addEventListener("click", () => {
    void downloadFile(resolvedUrl, att.filename);
  });
  appendChildren(inner, icon, info, downloadBtn);
  wrap.appendChild(inner);
  return wrap;
}

/** Download a file via Tauri HTTP plugin and save to disk with native dialog.
 *  NOTE: This requires fs:allow-write-file with path "**" in capabilities because
 *  the user chooses the save location via the native OS dialog — the destination is
 *  not under our control. The dialog itself is the security boundary. */
async function downloadFile(url: string, filename: string): Promise<void> {
  try {
    // Show native save dialog with suggested filename
    const filePath = await save({ defaultPath: filename });
    if (filePath === null) return; // User cancelled

    // Fetch file data — server downloads go through the cert-pinned HTTP proxy.
    const res = await tauriFetch(await toFetchUrl(url));
    if (!res.ok) {
      log.error("Download failed", { filename, status: res.status });
      alert(`Download failed: server returned ${res.status}`);
      return;
    }

    const buffer = await res.arrayBuffer();
    await writeFile(filePath, new Uint8Array(buffer));
  } catch (err) {
    log.error("Download failed", { filename, error: String(err) });
    alert(`Download failed for ${filename} — check logs for details`);
  }
}
