/**
 * Component: Metadata Tagging Utility Tests
 * Documentation: documentation/phase3/file-organization.md
 */

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger';

const execFileMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
  access: vi.fn(),
  unlink: vi.fn(),
}));

vi.mock('child_process', () => ({
  execFile: execFileMock,
}));

vi.mock('fs/promises', () => ({
  default: fsMock,
  ...fsMock,
}));

function mockExecFileSuccess(stdout = 'ok') {
  execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => {
    const cb = typeof options === 'function' ? options : callback;
    cb(null, stdout, '');
  });
}

function mockExecFileFailure(message = 'ffmpeg error') {
  execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => {
    const cb = typeof options === 'function' ? options : callback;
    cb(new Error(message), '', '');
  });
}

function lastCallArgv(): string[] {
  const call = execFileMock.mock.calls.at(-1);
  if (!call) throw new Error('execFile was not called');
  return call[1] as string[];
}

function lastCallCommand(): string {
  const call = execFileMock.mock.calls.at(-1);
  if (!call) throw new Error('execFile was not called');
  return call[0] as string;
}

/**
 * Assert that argv contains the adjacent pair `['-metadata', '${key}=${value}']`.
 * This is the regression-catching assertion — it verifies the metadata payload
 * is a single argv element with no embedded shell quoting.
 */
function expectMetadataArg(argv: string[], key: string, value: string): void {
  const expectedPayload = `${key}=${value}`;
  for (let i = 0; i < argv.length - 1; i++) {
    if (argv[i] === '-metadata' && argv[i + 1] === expectedPayload) {
      return;
    }
  }
  throw new Error(
    `Expected argv to contain ['-metadata', '${expectedPayload}'] as adjacent elements.\nArgv: ${JSON.stringify(argv)}`
  );
}

describe('metadata tagger', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns an error for unsupported file formats', async () => {
    fsMock.access.mockResolvedValue(undefined);

    const result = await tagAudioFileMetadata('/tmp/book.wav', {
      title: 'Book',
      author: 'Author',
    });

    expect(result.success).toBe(false);
    expect(result.error).toContain('Unsupported file format');
    expect(execFileMock).not.toHaveBeenCalled();
  });

  it('tags an m4b file with metadata using argv form', async () => {
    fsMock.access.mockResolvedValue(undefined);
    mockExecFileSuccess('done');

    const result = await tagAudioFileMetadata('/tmp/book.m4b', {
      title: 'Book',
      author: 'Author',
      narrator: 'Narrator',
      year: 2020,
      asin: 'ASIN123',
    });

    expect(result.success).toBe(true);
    expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp');

    expect(lastCallCommand()).toBe('ffmpeg');

    const argv = lastCallArgv();
    expectMetadataArg(argv, 'title', 'Book');
    expectMetadataArg(argv, 'album', 'Book');
    expectMetadataArg(argv, 'album_artist', 'Author');
    expectMetadataArg(argv, 'artist', 'Author');
    expectMetadataArg(argv, 'composer', 'Narrator');
    expectMetadataArg(argv, 'date', '2020');
    expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'ASIN123');

    expect(argv).toContain('/tmp/book.m4b');
    expect(argv).toContain('/tmp/book.m4b.tmp');

    const fIdx = argv.indexOf('-f');
    expect(fIdx).toBeGreaterThanOrEqual(0);
    expect(argv[fIdx + 1]).toBe('mp4');
  });

  it('tags an mp3 file with mp3 output format', async () => {
    fsMock.access.mockResolvedValue(undefined);
    mockExecFileSuccess('done');

    await tagAudioFileMetadata('/tmp/book.mp3', {
      title: 'Book',
      author: 'Author',
      asin: 'ASIN999',
    });

    const argv = lastCallArgv();
    expectMetadataArg(argv, 'title', 'Book');
    expectMetadataArg(argv, 'ASIN', 'ASIN999');
    const fIdx = argv.indexOf('-f');
    expect(argv[fIdx + 1]).toBe('mp3');
  });

  it('tags a flac file with flac output format', async () => {
    fsMock.access.mockResolvedValue(undefined);
    mockExecFileSuccess('done');

    await tagAudioFileMetadata('/tmp/book.flac', {
      title: 'Book',
      author: 'Author',
    });

    const argv = lastCallArgv();
    expectMetadataArg(argv, 'title', 'Book');
    expectMetadataArg(argv, 'albumartist', 'Author');
    const fIdx = argv.indexOf('-f');
    expect(argv[fIdx + 1]).toBe('flac');
  });

  it('cleans up temp files and returns errors when ffmpeg fails', async () => {
    fsMock.access.mockResolvedValue(undefined);
    fsMock.unlink.mockResolvedValue(undefined);
    mockExecFileFailure('exec failed');

    const result = await tagAudioFileMetadata('/tmp/book.mp3', {
      title: 'Book',
      author: 'Author',
      asin: 'ASIN123',
    });

    expect(result.success).toBe(false);
    expect(result.error).toContain('ffmpeg failed');
    expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/book.mp3.tmp');
  });

  it('tags multiple files in sequence', async () => {
    fsMock.access.mockResolvedValue(undefined);
    mockExecFileSuccess('done');

    const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], {
      title: 'Book',
      author: 'Author',
    });

    expect(results).toHaveLength(2);
    expect(results.every((result) => result.success)).toBe(true);
  });

  it('checks ffmpeg availability', async () => {
    mockExecFileSuccess('ffmpeg version');
    await expect(checkFfmpegAvailable()).resolves.toBe(true);

    mockExecFileFailure('not installed');
    await expect(checkFfmpegAvailable()).resolves.toBe(false);
  });

  describe('series metadata', () => {
    it('writes show/episode_id for m4b when series/seriesPart provided', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book',
        author: 'Author',
        series: 'The Mistborn Saga',
        seriesPart: '1',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'show', 'The Mistborn Saga');
      expectMetadataArg(argv, 'episode_id', '1');
    });

    it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.mp3', {
        title: 'Book',
        author: 'Author',
        series: 'The Mistborn Saga',
        seriesPart: '1.5',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga');
      expectMetadataArg(argv, 'SERIES-PART', '1.5');
    });

    it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.flac', {
        title: 'Book',
        author: 'Author',
        series: 'The Mistborn Saga',
        seriesPart: '2',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga');
      expectMetadataArg(argv, 'SERIES-PART', '2');
    });

    it('omits series tags when fields are absent', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book',
        author: 'Author',
      });

      const argv = lastCallArgv();
      const payloads = argv.filter((_, i) => i > 0 && argv[i - 1] === '-metadata');
      expect(payloads.some((p) => p.startsWith('show='))).toBe(false);
      expect(payloads.some((p) => p.startsWith('episode_id='))).toBe(false);
      expect(payloads.some((p) => p.startsWith('SERIES='))).toBe(false);
      expect(payloads.some((p) => p.startsWith('SERIES-PART='))).toBe(false);
    });
  });

  describe('quote regression (#171) — embedded `"` flows through untouched', () => {
    const tricky = 'Test "Quoted" Book';

    it('m4b: embedded double-quote in title is a single clean argv element', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: tricky,
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', tricky);
      expectMetadataArg(argv, 'album', tricky);
      // Adjacent fields must not have inherited a stray leading `"`.
      expectMetadataArg(argv, 'album_artist', 'Author');
      expectMetadataArg(argv, 'artist', 'Author');
    });

    it('mp3: embedded double-quote in author is a single clean argv element', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.mp3', {
        title: 'Book',
        author: 'Alexandre "Dumas',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'album_artist', 'Alexandre "Dumas');
      expectMetadataArg(argv, 'artist', 'Alexandre "Dumas');
      // Title must NOT have inherited a leading quote (the exact #171 symptom).
      expectMetadataArg(argv, 'title', 'Book');
    });

    it('flac: embedded double-quote in series flows through cleanly', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.flac', {
        title: 'Book',
        author: 'Author',
        series: 'The "Final" Saga',
        seriesPart: '1',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'SERIES', 'The "Final" Saga');
      expectMetadataArg(argv, 'SERIES-PART', '1');
    });
  });

  describe('special characters flow through as literals (no shell escaping)', () => {
    it('single quotes are untouched', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: "It's Not Her",
        author: "O'Brien",
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', "It's Not Her");
      expectMetadataArg(argv, 'album_artist', "O'Brien");
    });

    it('dollar signs are untouched (no shell variable expansion)', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book $100',
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Book $100');
    });

    it('backticks are untouched (no command substitution)', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book `test`',
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Book `test`');
    });

    it('backslashes are untouched', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Path\\to\\book',
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Path\\to\\book');
    });

    it('combined torture-test value flows through as one literal argv element', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      const tortureTitle = `Don't Say "Hello" for $5 \`backtick\` and \\back`;

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: tortureTitle,
        author: "O'Brien",
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', tortureTitle);
      expectMetadataArg(argv, 'album_artist', "O'Brien");
    });
  });

  describe('input sanitization (boundary defense for invisible chars)', () => {
    it('strips leading BOM from title', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: '﻿Alexandre Dumas Title',
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Alexandre Dumas Title');
    });

    it('strips leading NBSP from author', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book',
        author: ' Alexandre Dumas',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'album_artist', 'Alexandre Dumas');
      expectMetadataArg(argv, 'artist', 'Alexandre Dumas');
    });

    it('strips leading zero-width chars', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: '​‌‍Book Title',
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Book Title');
    });

    it('trims surrounding whitespace', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: '   Spaced Book   ',
        author: '\tAuthor Name\t',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Spaced Book');
      expectMetadataArg(argv, 'album_artist', 'Author Name');
    });

    it('strips null bytes and line breaks anywhere in value', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book\x00With\nBreaks\r',
        author: 'Author',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'BookWithBreaks');
    });

    it('leaves clean inputs untouched', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Perfectly Clean Title',
        author: 'Clean Author',
        narrator: 'Clean Narrator',
        asin: 'B0009JKV9W',
        series: 'Clean Series',
        seriesPart: '1',
      });

      const argv = lastCallArgv();
      expectMetadataArg(argv, 'title', 'Perfectly Clean Title');
      expectMetadataArg(argv, 'album_artist', 'Clean Author');
      expectMetadataArg(argv, 'composer', 'Clean Narrator');
      expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'B0009JKV9W');
      expectMetadataArg(argv, 'show', 'Clean Series');
      expectMetadataArg(argv, 'episode_id', '1');
    });

    it('sanitization applies across all three format branches', async () => {
      fsMock.access.mockResolvedValue(undefined);

      for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) {
        mockExecFileSuccess('done');
        await tagAudioFileMetadata(filePath, {
          title: '﻿  Dirty Title  ',
          author: ' Dirty Author',
        });
        const argv = lastCallArgv();
        expectMetadataArg(argv, 'title', 'Dirty Title');
        // m4b/mp3 use album_artist; flac uses albumartist (no underscore).
        const authorKey = filePath.endsWith('.flac') ? 'albumartist' : 'album_artist';
        expectMetadataArg(argv, authorKey, 'Dirty Author');
      }
    });
  });

  describe('argv structural integrity', () => {
    it('no -metadata payload starts or ends with `"` (the #171 bug class)', async () => {
      fsMock.access.mockResolvedValue(undefined);

      const torture = {
        title: 'Has "quotes" $dollar `tick` \\back',
        author: 'A "B" C',
        narrator: 'N "ar" tor',
        asin: 'ASIN"123',
        series: 'Ser "ies"',
        seriesPart: '"1"',
        year: 2020,
      };

      for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) {
        mockExecFileSuccess('done');
        await tagAudioFileMetadata(filePath, torture);
        const argv = lastCallArgv();
        for (let i = 0; i < argv.length - 1; i++) {
          if (argv[i] === '-metadata') {
            const payload = argv[i + 1];
            // Split key=value; the VALUE part must not have leading/trailing wrapping quotes.
            const eqIdx = payload.indexOf('=');
            const value = eqIdx >= 0 ? payload.substring(eqIdx + 1) : payload;
            // The seriesPart torture value is literally '"1"', so we check that the value
            // matches one of the original torture values (no extra wrapping was added).
            if (value.startsWith('"') && value.endsWith('"')) {
              // Only acceptable when the original input was already wrapped (seriesPart='"1"').
              expect(value).toBe('"1"');
            }
          }
        }
      }
    });

    it('input and output paths are passed as single argv elements (no quote wrapping)', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/path with spaces/book.m4b', {
        title: 'Book',
        author: 'Author',
      });

      const argv = lastCallArgv();
      const iIdx = argv.indexOf('-i');
      expect(iIdx).toBeGreaterThanOrEqual(0);
      expect(argv[iIdx + 1]).toBe('/path with spaces/book.m4b');
      expect(argv[argv.length - 1]).toBe('/path with spaces/book.m4b.tmp');
    });

    it('execFile is called with command "ffmpeg" (not a shell)', async () => {
      fsMock.access.mockResolvedValue(undefined);
      mockExecFileSuccess('done');

      await tagAudioFileMetadata('/tmp/book.m4b', {
        title: 'Book',
        author: 'Author',
      });

      expect(execFileMock).toHaveBeenCalled();
      expect(lastCallCommand()).toBe('ffmpeg');
    });
  });
});
