/**
 * @file services/galleryService.js
 * @description Durable gallery for tool-generated media.
 *
 * Why this exists:
 *   Before, the web-UI gallery listed files by scanning each agent's
 *   `working-dir/images/` folder. That breaks the moment a file is
 *   moved, an agent is deleted, or the agent's workingDirectory
 *   changes — and there's nowhere to store the prompt/model metadata
 *   that made the image in the first place. GalleryService gives each
 *   generation a durable home at `{userDataDir}/gallery/{images|videos}/`
 *   with a `.json` sidecar carrying the full metadata.
 *
 * Layout:
 *   {userDataDir}/gallery/
 *     images/
 *       2026-04-22T12-34-56_fox.png           ← the asset
 *       2026-04-22T12-34-56_fox.png.json      ← metadata sidecar
 *     videos/
 *       2026-04-22T12-34-56_dragon.mp4
 *       2026-04-22T12-34-56_dragon.mp4.json
 *
 * Sidecar shape (whatever the caller passed to saveImage/saveVideo):
 *   {
 *     filename:        "2026-04-22T12-34-56_fox.png",
 *     sourcePath:      "/original/path/images/fox.png",
 *     agentId:         "agent-xyz",
 *     agentName:       "Alice",
 *     prompt:          "a fox on a skateboard",
 *     model:           "dall-e-3",
 *     size:            "1024x1024",      // or width/height
 *     duration:        5,                 // video only
 *     variants:        1,                 // video only
 *     createdAt:       "2026-04-22T12:34:56.789Z",
 *     bytes:           834221,
 *   }
 *
 * Save-time naming guarantees:
 *   - Timestamp prefix prevents collisions when two agents pick the
 *     same filename.
 *   - Original filename is retained after the prefix so users can still
 *     recognise what they asked for.
 */

import { promises as fs } from 'fs';
import path from 'path';
import { getUserDataPaths, ensureUserDataDirs } from '../utilities/userDataDir.js';

const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.gif']);

function _ts() {
  // Filesystem-safe ISO — replaces ':' (illegal on Windows) with '-'.
  return new Date().toISOString().replace(/[:.]/g, '-');
}

function _isImage(name) { return IMAGE_EXTS.has(path.extname(name).toLowerCase()); }
function _isVideo(name) { return VIDEO_EXTS.has(path.extname(name).toLowerCase()); }

async function _ensureDir(dir) {
  await fs.mkdir(dir, { recursive: true });
}

/** Write `data` to `{target}.json` — pretty, atomic-ish (write + rename). */
async function _writeSidecar(target, data) {
  const json = JSON.stringify(data, null, 2);
  const tmp = `${target}.json.tmp`;
  await fs.writeFile(tmp, json, 'utf8');
  await fs.rename(tmp, `${target}.json`);
}

async function _readSidecar(target) {
  try {
    const raw = await fs.readFile(`${target}.json`, 'utf8');
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

/** Best-effort: strip known internal keys before returning to callers. */
function _sanitiseMeta(meta, file, stat, url) {
  const out = { ...(meta || {}) };
  out.filename = file;
  out.bytes = stat.size;
  out.mtime = stat.mtime.toISOString();
  if (url) out.url = url;
  return out;
}

class GalleryService {
  constructor(logger = null) {
    this.logger = logger;
    this.paths = getUserDataPaths();
  }

  /**
   * Copy a freshly-generated image into the durable gallery.
   * @param {object} opts
   * @param {string} opts.sourcePath  absolute path to source file
   * @param {object} opts.metadata    free-form sidecar data
   * @param {string} [opts.filenameHint] override the suggested filename
   * @returns {Promise<{ filename: string, path: string } | null>}
   *          null when sourcePath couldn't be read (non-fatal)
   */
  async saveImage({ sourcePath, metadata = {}, filenameHint } = {}) {
    if (!sourcePath) return null;
    if (!_isImage(filenameHint || sourcePath)) return null;
    try {
      await _ensureDir(this.paths.galleryImages);
      const base = path.basename(filenameHint || sourcePath);
      const final = `${_ts()}_${base}`;
      const target = path.join(this.paths.galleryImages, final);
      await fs.copyFile(sourcePath, target);
      const stat = await fs.stat(target);
      await _writeSidecar(target, {
        ...metadata,
        filename: final,
        sourcePath,
        bytes: stat.size,
        createdAt: metadata.createdAt || new Date().toISOString(),
        kind: 'image',
      });
      this.logger?.info?.('[Gallery] saved image', { final, sourcePath });
      return { filename: final, path: target };
    } catch (err) {
      // Gallery save is non-fatal — the tool's primary save to the
      // agent's working dir already succeeded.
      this.logger?.warn?.('[Gallery] saveImage failed', { error: err.message, sourcePath });
      return null;
    }
  }

  /** Same shape as saveImage, but for videos. */
  async saveVideo({ sourcePath, metadata = {}, filenameHint } = {}) {
    if (!sourcePath) return null;
    if (!_isVideo(filenameHint || sourcePath)) return null;
    try {
      await _ensureDir(this.paths.galleryVideos);
      const base = path.basename(filenameHint || sourcePath);
      const final = `${_ts()}_${base}`;
      const target = path.join(this.paths.galleryVideos, final);
      await fs.copyFile(sourcePath, target);
      const stat = await fs.stat(target);
      await _writeSidecar(target, {
        ...metadata,
        filename: final,
        sourcePath,
        bytes: stat.size,
        createdAt: metadata.createdAt || new Date().toISOString(),
        kind: 'video',
      });
      this.logger?.info?.('[Gallery] saved video', { final, sourcePath });
      return { filename: final, path: target };
    } catch (err) {
      this.logger?.warn?.('[Gallery] saveVideo failed', { error: err.message, sourcePath });
      return null;
    }
  }

  /**
   * List gallery images, newest first. Optional { agentId } filter.
   * Returns rich entries populated from the sidecar where available.
   */
  async listImages({ agentId } = {}) {
    return this._listDir(this.paths.galleryImages, _isImage, agentId, 'image');
  }

  /** Same for videos. */
  async listVideos({ agentId } = {}) {
    return this._listDir(this.paths.galleryVideos, _isVideo, agentId, 'video');
  }

  async _listDir(dir, extCheck, agentId, kind) {
    let entries;
    try {
      entries = await fs.readdir(dir);
    } catch {
      return [];
    }
    const results = [];
    for (const name of entries) {
      if (!extCheck(name)) continue;
      const abs = path.join(dir, name);
      let stat;
      try { stat = await fs.stat(abs); } catch { continue; }
      if (!stat.isFile()) continue;
      const meta = (await _readSidecar(abs)) || {};
      if (agentId && meta.agentId && meta.agentId !== agentId) continue;
      results.push(_sanitiseMeta({ ...meta, kind }, name, stat, /* url */ null));
    }
    // Sort newest-first. Use createdAt from sidecar when available (more
    // precise than mtime, which can collide at ms resolution on fast
    // filesystems). Filename fallback works because we embed a timestamp
    // prefix at save time.
    results.sort((a, b) => {
      const ka = a.createdAt || a.filename || '';
      const kb = b.createdAt || b.filename || '';
      return kb.localeCompare(ka);
    });
    return results;
  }

  /** Delete a gallery image by filename (+ its sidecar). 404 → false. */
  async deleteImage(filename) {
    return this._deleteFile(this.paths.galleryImages, filename, _isImage);
  }

  /** Delete a gallery video by filename (+ its sidecar). */
  async deleteVideo(filename) {
    return this._deleteFile(this.paths.galleryVideos, filename, _isVideo);
  }

  async _deleteFile(dir, filename, extCheck) {
    const normalized = path.basename(filename || '');
    if (!normalized || normalized !== filename) {
      throw Object.assign(new Error('Invalid filename'), { code: 'INVALID_FILENAME' });
    }
    if (!extCheck(normalized)) {
      throw Object.assign(new Error('Unsupported extension'), { code: 'INVALID_EXT' });
    }
    const target = path.join(dir, normalized);
    let existed = false;
    try {
      await fs.unlink(target);
      existed = true;
    } catch (err) {
      if (err.code !== 'ENOENT') throw err;
    }
    // Sidecar is best-effort — missing sidecar is fine.
    await fs.unlink(`${target}.json`).catch(() => {});
    return existed;
  }

  /** Diagnostic helper (used by tests). */
  async _ensureStructure() {
    await ensureUserDataDirs();
    await _ensureDir(this.paths.galleryImages);
    await _ensureDir(this.paths.galleryVideos);
  }
}

/* Singleton. Tools and webServer routes share one instance. */
let _instance = null;

export function getGalleryService(logger = null) {
  if (!_instance) _instance = new GalleryService(logger);
  return _instance;
}

/** Test-only — reset module state. */
export function _resetGalleryServiceForTests() {
  _instance = null;
}

export default GalleryService;
export { GalleryService };
