/**
 * Tests for GalleryService — the durable store for tool-generated
 * images + videos with metadata sidecars.
 *
 * Locks:
 *   - save copies file + writes a JSON sidecar
 *   - filename gets a timestamp prefix to avoid collisions
 *   - list returns entries newest-first with metadata from the sidecar
 *   - list filters by agentId when supplied
 *   - delete removes both the asset and its sidecar
 *   - delete rejects path traversal + unsupported extensions
 *   - saveImage on a non-image source returns null (no-op, non-fatal)
 *   - IO failures on the gallery path don't crash the caller (warn + null)
 */

import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import path from 'path';
import os from 'os';
import { promises as fs } from 'fs';

// Point userDataDir to a tmp dir for the duration of the test.
let tmpRoot;
jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
  getUserDataDir: () => tmpRoot,
  getUserDataPaths: () => ({
    base: tmpRoot,
    state: path.join(tmpRoot, 'state'),
    agents: path.join(tmpRoot, 'state', 'agents'),
    settings: path.join(tmpRoot, 'settings'),
    attachments: path.join(tmpRoot, 'attachments'),
    logs: path.join(tmpRoot, 'logs'),
    cache: path.join(tmpRoot, 'cache'),
    operations: path.join(tmpRoot, 'state', 'operations'),
    models: path.join(tmpRoot, 'state', 'models'),
    runtime: path.join(tmpRoot, 'runtime'),
    skills: path.join(tmpRoot, 'state', 'skills'),
    gallery: path.join(tmpRoot, 'gallery'),
    galleryImages: path.join(tmpRoot, 'gallery', 'images'),
    galleryVideos: path.join(tmpRoot, 'gallery', 'videos'),
  }),
  ensureUserDataDirs: async () => {},
}));

const mod = await import('../galleryService.js');
const { GalleryService, _resetGalleryServiceForTests, getGalleryService } = mod;

const LOGGER = { info() {}, warn() {}, error() {}, debug() {} };

beforeEach(async () => {
  _resetGalleryServiceForTests();
  tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gallery-svc-'));
});

afterEach(async () => {
  await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});

async function makeSourceImage(name = 'fox.png') {
  const srcDir = path.join(tmpRoot, 'src');
  await fs.mkdir(srcDir, { recursive: true });
  const src = path.join(srcDir, name);
  await fs.writeFile(src, 'PNG-DATA');
  return src;
}

async function makeSourceVideo(name = 'dragon.mp4') {
  const srcDir = path.join(tmpRoot, 'src');
  await fs.mkdir(srcDir, { recursive: true });
  const src = path.join(srcDir, name);
  await fs.writeFile(src, 'MP4-DATA');
  return src;
}

describe('GalleryService.saveImage', () => {
  test('copies the file into gallery/images and writes a sidecar', async () => {
    const svc = new GalleryService(LOGGER);
    const src = await makeSourceImage('fox.png');
    const result = await svc.saveImage({
      sourcePath: src,
      metadata: { prompt: 'a fox', model: 'dall-e-3', agentId: 'agent-1', agentName: 'Alice' },
    });
    expect(result).toBeTruthy();
    expect(result.filename).toMatch(/_fox\.png$/);
    const contents = await fs.readFile(result.path, 'utf8');
    expect(contents).toBe('PNG-DATA');
    const sidecar = JSON.parse(await fs.readFile(`${result.path}.json`, 'utf8'));
    expect(sidecar.prompt).toBe('a fox');
    expect(sidecar.model).toBe('dall-e-3');
    expect(sidecar.agentId).toBe('agent-1');
    expect(sidecar.kind).toBe('image');
    expect(sidecar.createdAt).toBeTruthy();
  });

  test('returns null for a non-image source (no throw, no file)', async () => {
    const svc = new GalleryService(LOGGER);
    const txt = path.join(tmpRoot, 'src', 'notes.txt');
    await fs.mkdir(path.dirname(txt), { recursive: true });
    await fs.writeFile(txt, 'hi');
    const r = await svc.saveImage({ sourcePath: txt });
    expect(r).toBeNull();
  });

  test('returns null when the source path does not exist (warn, not crash)', async () => {
    const svc = new GalleryService(LOGGER);
    const r = await svc.saveImage({
      sourcePath: path.join(tmpRoot, 'does-not-exist.png'),
    });
    expect(r).toBeNull();
  });

  test('two saves of the same basename do not collide', async () => {
    const svc = new GalleryService(LOGGER);
    const src = await makeSourceImage('boom.png');
    const a = await svc.saveImage({ sourcePath: src });
    // Enough wall-clock for the timestamp prefix to change.
    await new Promise(r => setTimeout(r, 5));
    const b = await svc.saveImage({ sourcePath: src });
    expect(a.filename).not.toBe(b.filename);
  });
});

describe('GalleryService.saveVideo', () => {
  test('copies + writes sidecar with kind=video', async () => {
    const svc = new GalleryService(LOGGER);
    const src = await makeSourceVideo('dragon.mp4');
    const result = await svc.saveVideo({
      sourcePath: src,
      metadata: { prompt: 'a dragon', duration: 5, variants: 1, agentId: 'agent-1' },
    });
    expect(result).toBeTruthy();
    const sidecar = JSON.parse(await fs.readFile(`${result.path}.json`, 'utf8'));
    expect(sidecar.kind).toBe('video');
    expect(sidecar.duration).toBe(5);
  });
});

describe('GalleryService.listImages', () => {
  test('returns saved images with sidecar metadata, newest first', async () => {
    const svc = new GalleryService(LOGGER);
    const src = await makeSourceImage('a.png');
    const a = await svc.saveImage({ sourcePath: src, metadata: { prompt: 'first', agentId: 'x' } });
    await new Promise(r => setTimeout(r, 20));
    const b = await svc.saveImage({ sourcePath: src, metadata: { prompt: 'second', agentId: 'x' } });

    const list = await svc.listImages();
    expect(list.map(i => i.filename)).toEqual([b.filename, a.filename]);
    expect(list[0].prompt).toBe('second');
    expect(list[0].kind).toBe('image');
    expect(list[0].bytes).toBeGreaterThan(0);
    expect(list[0].mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });

  test('filters by agentId', async () => {
    const svc = new GalleryService(LOGGER);
    const src = await makeSourceImage('a.png');
    await svc.saveImage({ sourcePath: src, metadata: { agentId: 'agent-A' } });
    await svc.saveImage({ sourcePath: src, metadata: { agentId: 'agent-B' } });

    const onlyB = await svc.listImages({ agentId: 'agent-B' });
    expect(onlyB).toHaveLength(1);
    expect(onlyB[0].agentId).toBe('agent-B');
  });

  test('empty gallery → empty list', async () => {
    const svc = new GalleryService(LOGGER);
    const list = await svc.listImages();
    expect(list).toEqual([]);
  });

  test('files without sidecars are still listed (best-effort)', async () => {
    const svc = new GalleryService(LOGGER);
    const dir = path.join(tmpRoot, 'gallery', 'images');
    await fs.mkdir(dir, { recursive: true });
    await fs.writeFile(path.join(dir, 'orphan.png'), 'x');
    const list = await svc.listImages();
    expect(list.map(i => i.filename)).toEqual(['orphan.png']);
    expect(list[0].prompt).toBeUndefined();
  });
});

describe('GalleryService.deleteImage', () => {
  test('removes the image AND its sidecar', async () => {
    const svc = new GalleryService(LOGGER);
    const src = await makeSourceImage('gone.png');
    const { filename, path: assetPath } = await svc.saveImage({ sourcePath: src });
    await fs.access(assetPath);
    await fs.access(`${assetPath}.json`);

    const ok = await svc.deleteImage(filename);
    expect(ok).toBe(true);
    await expect(fs.access(assetPath)).rejects.toBeTruthy();
    await expect(fs.access(`${assetPath}.json`)).rejects.toBeTruthy();
  });

  test('rejects path-traversal filenames', async () => {
    const svc = new GalleryService(LOGGER);
    await expect(svc.deleteImage('../secret')).rejects.toMatchObject({ code: 'INVALID_FILENAME' });
  });

  test('rejects unsupported extensions', async () => {
    const svc = new GalleryService(LOGGER);
    await expect(svc.deleteImage('secret.txt')).rejects.toMatchObject({ code: 'INVALID_EXT' });
  });

  test('returns false when the file is already gone (no throw)', async () => {
    const svc = new GalleryService(LOGGER);
    const r = await svc.deleteImage('ghost.png');
    expect(r).toBe(false);
  });
});

describe('getGalleryService singleton', () => {
  test('returns the same instance across calls', () => {
    const a = getGalleryService(LOGGER);
    const b = getGalleryService(LOGGER);
    expect(a).toBe(b);
  });

  test('_resetGalleryServiceForTests creates a fresh instance', () => {
    const a = getGalleryService(LOGGER);
    _resetGalleryServiceForTests();
    const b = getGalleryService(LOGGER);
    expect(a).not.toBe(b);
  });
});
