/**
 * PublishEngine Unit Tests
 *
 * Tests the site upload engine that publishes generated site content
 * via SCP or rsync to a remote server. Each directory (html, thumbnails,
 * media) is uploaded as an independent operation with per-file progress.
 */

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import path from 'path';
import { PublishEngine, type PublishCredentials, type DirectoryUploadResult } from '../../src/main/engine/PublishEngine';

// Hoist mock variables so they're available inside vi.mock factories
const {
  mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
  mockReaddir, mockFsStat, mockAccess,
  mockRsync,
} = vi.hoisted(() => {
  const mockStat = vi.fn();
  const mockUploadFile = vi.fn();
  const mockMkdir = vi.fn();
  const mockClose = vi.fn();
  const mockScpClient = {
    stat: mockStat,
    uploadFile: mockUploadFile,
    mkdir: mockMkdir,
    close: mockClose,
  };
  const mockReaddir = vi.fn();
  const mockFsStat = vi.fn();
  const mockAccess = vi.fn();
  const mockRsync = vi.fn((_options: any, callback: any) => {
    callback(null, '', '', 'rsync command');
  });
  return {
    mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
    mockReaddir, mockFsStat, mockAccess,
    mockRsync,
  };
});

// Mock node-scp
vi.mock('node-scp', () => ({
  Client: vi.fn().mockResolvedValue(mockScpClient),
  default: vi.fn().mockResolvedValue(mockScpClient),
}));

// Mock rsyncwrapper
vi.mock('rsyncwrapper', () => ({
  default: mockRsync,
}));

// Mock fs/promises
vi.mock('fs/promises', () => ({
  default: {
    readdir: (...args: any[]) => mockReaddir(...args),
    stat: (...args: any[]) => mockFsStat(...args),
    access: (...args: any[]) => mockAccess(...args),
  },
  readdir: (...args: any[]) => mockReaddir(...args),
  stat: (...args: any[]) => mockFsStat(...args),
  access: (...args: any[]) => mockAccess(...args),
}));

// Mock fs
vi.mock('fs', () => ({
  default: { constants: { F_OK: 0 } },
  constants: { F_OK: 0 },
}));

describe('PublishEngine', () => {
  let engine: PublishEngine;
  const dataDir = '/projects/test-project';

  const defaultCredentials: PublishCredentials = {
    sshHost: 'example.com',
    sshUser: 'deploy',
    sshRemotePath: '/var/www/html',
    sshMode: 'scp',
  };

  beforeEach(() => {
    vi.clearAllMocks();
    engine = new PublishEngine();
    engine.setProjectContext('test-project', dataDir);

    // Default: directories exist, files are regular files
    mockAccess.mockResolvedValue(undefined);
    mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: Date.now() });
    mockReaddir.mockResolvedValue([]);
    mockStat.mockRejectedValue(new Error('No such file')); // Remote file doesn't exist → upload
    mockUploadFile.mockResolvedValue(undefined);
    mockMkdir.mockResolvedValue(undefined);
    mockClose.mockResolvedValue(undefined);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('constructor and project context', () => {
    it('should throw if no project context is set', async () => {
      const noContextEngine = new PublishEngine();
      await expect(
        noContextEngine.uploadHtml(defaultCredentials, vi.fn()),
      ).rejects.toThrow('No project context');
    });
  });

  describe('credential validation', () => {
    it('should throw if sshHost is empty', async () => {
      await expect(
        engine.uploadHtml({ ...defaultCredentials, sshHost: '' }, vi.fn()),
      ).rejects.toThrow('SSH host is required');
    });

    it('should throw if sshUser is empty', async () => {
      await expect(
        engine.uploadHtml({ ...defaultCredentials, sshUser: '' }, vi.fn()),
      ).rejects.toThrow('SSH user is required');
    });

    it('should throw if sshRemotePath is empty', async () => {
      await expect(
        engine.uploadHtml({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()),
      ).rejects.toThrow('Remote path is required');
    });
  });

  describe('directory validation', () => {
    it('should throw if html directory does not exist', async () => {
      mockAccess.mockImplementation(async (p: string) => {
        if ((p as string).endsWith('html')) throw new Error('ENOENT');
      });

      await expect(
        engine.uploadHtml(defaultCredentials, vi.fn()),
      ).rejects.toThrow('Generated site not found');
    });
  });

  // ── SCP mode: uploadHtml ──────────────────────────────────────────────

  describe('SCP mode – uploadHtml', () => {
    it('should upload html files to remote root', async () => {
      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
          return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }];
        }
        return [];
      });

      const onProgress = vi.fn();
      const result = await engine.uploadHtml(defaultCredentials, onProgress);

      expect(result.filesUploaded).toBe(1);
      expect(mockUploadFile).toHaveBeenCalledTimes(1);
      expect(onProgress).toHaveBeenCalled();
    });

    it('should recurse into subdirectories', async () => {
      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
          return [{ name: '2026', isDirectory: () => true, isFile: () => false }];
        }
        if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) {
          return [{ name: 'post.html', isDirectory: () => false, isFile: () => true }];
        }
        return [];
      });

      const result = await engine.uploadHtml(defaultCredentials, vi.fn());

      expect(mockMkdir).toHaveBeenCalled();
      expect(result.filesUploaded).toBe(1);
    });

    it('should skip files that are not newer than remote', async () => {
      const remoteTime = Date.now() / 1000;
      const localTimeOlder = (remoteTime - 100) * 1000;

      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
          return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }];
        }
        return [];
      });
      mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: localTimeOlder });
      mockStat.mockResolvedValue({ mtime: remoteTime });

      const result = await engine.uploadHtml(defaultCredentials, vi.fn());

      expect(mockUploadFile).not.toHaveBeenCalled();
      expect(result.filesSkipped).toBe(1);
    });

    it('should upload files that are newer than remote', async () => {
      const remoteTime = Date.now() / 1000 - 100;
      const localTimeNewer = Date.now();

      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
          return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }];
        }
        return [];
      });
      mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: localTimeNewer });
      mockStat.mockResolvedValue({ mtime: remoteTime });

      const result = await engine.uploadHtml(defaultCredentials, vi.fn());

      expect(mockUploadFile).toHaveBeenCalled();
      expect(result.filesUploaded).toBe(1);
    });

    it('should report per-file progress with filename in message', async () => {
      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
          return [
            { name: 'a.html', isDirectory: () => false, isFile: () => true },
            { name: 'b.html', isDirectory: () => false, isFile: () => true },
            { name: 'c.html', isDirectory: () => false, isFile: () => true },
          ];
        }
        return [];
      });

      const onProgress = vi.fn();
      await engine.uploadHtml(defaultCredentials, onProgress);

      // progress reported per file, including final 100
      const progressValues = onProgress.mock.calls.map(([p]: [number]) => p);
      expect(progressValues.length).toBeGreaterThanOrEqual(3);
      expect(progressValues[progressValues.length - 1]).toBe(100);
      // intermediate progress should be between 0 and 100 exclusive
      expect(progressValues.some(v => v > 0 && v < 100)).toBe(true);
      // should include filenames in messages
      const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m);
      expect(messages.some(m => m.includes('a.html'))).toBe(true);
    });

    it('should return a DirectoryUploadResult', async () => {
      mockReaddir.mockResolvedValue([]);
      const result = await engine.uploadHtml(defaultCredentials, vi.fn());

      expect(result).toHaveProperty('filesUploaded');
      expect(result).toHaveProperty('filesSkipped');
      expect(typeof result.filesUploaded).toBe('number');
      expect(typeof result.filesSkipped).toBe('number');
    });
  });

  // ── SCP mode: uploadThumbnails ────────────────────────────────────────

  describe('SCP mode – uploadThumbnails', () => {
    it('should upload to remote thumbnails/ subdirectory', async () => {
      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
          return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }];
        }
        return [];
      });

      const result = await engine.uploadThumbnails(defaultCredentials, vi.fn());
      expect(result.filesUploaded).toBe(1);
    });

    it('should return zero counts if thumbnails dir does not exist', async () => {
      mockAccess.mockImplementation(async (p: string) => {
        if ((p as string).includes('thumbnails')) throw new Error('ENOENT');
      });

      const result = await engine.uploadThumbnails(defaultCredentials, vi.fn());
      expect(result.filesUploaded).toBe(0);
      expect(result.filesSkipped).toBe(0);
    });
  });

  // ── SCP mode: uploadMedia ────────────────────────────────────────────

  describe('SCP mode – uploadMedia', () => {
    it('should upload to remote media/ subdirectory, excluding .meta sidecars', async () => {
      mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
        if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) {
          return [
            { name: 'photo.jpg', isDirectory: () => false, isFile: () => true },
            { name: 'photo.jpg.meta', isDirectory: () => false, isFile: () => true },
            { name: 'document.pdf', isDirectory: () => false, isFile: () => true },
            { name: 'document.pdf.meta', isDirectory: () => false, isFile: () => true },
          ];
        }
        return [];
      });

      const result = await engine.uploadMedia(defaultCredentials, vi.fn());

      // Only photo.jpg and document.pdf should be uploaded
      expect(result.filesUploaded).toBe(2);
      expect(mockUploadFile).toHaveBeenCalledTimes(2);
    });

    it('should return zero counts if media dir does not exist', async () => {
      mockAccess.mockImplementation(async (p: string) => {
        if ((p as string).includes('media')) throw new Error('ENOENT');
      });

      const result = await engine.uploadMedia(defaultCredentials, vi.fn());
      expect(result.filesUploaded).toBe(0);
      expect(result.filesSkipped).toBe(0);
    });
  });

  // ── rsync mode ────────────────────────────────────────────────────────

  describe('rsync mode – uploadHtml', () => {
    const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' };

    it('should call rsync for html directory', async () => {
      await engine.uploadHtml(rsyncCredentials, vi.fn());
      expect(mockRsync).toHaveBeenCalledTimes(1);
    });

    it('should use --update and --times flags for incremental transfer', async () => {
      await engine.uploadHtml(rsyncCredentials, vi.fn());

      const [options] = mockRsync.mock.calls[0];
      expect(options.args).toContain('--update');
      expect(options.times).toBe(true);
      expect(options.recursive).toBe(true);
    });
  });

  describe('rsync mode – uploadMedia', () => {
    const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' };

    it('should exclude .meta files when syncing media', async () => {
      await engine.uploadMedia(rsyncCredentials, vi.fn());

      const [options] = mockRsync.mock.calls[0];
      expect(options.exclude).toContain('*.meta');
    });
  });

  // ── rsync live progress via onStdout ──────────────────────────────────

  describe('rsync mode – live progress', () => {
    const rsyncCredentials: PublishCredentials = { ...defaultCredentials, sshMode: 'rsync' };

    it('should pass --verbose flag so rsync reports each file', async () => {
      await engine.uploadHtml(rsyncCredentials, vi.fn());
      const [options] = mockRsync.mock.calls[0];
      expect(options.args).toContain('--verbose');
    });

    it('should provide onStdout callback to rsync options', async () => {
      await engine.uploadHtml(rsyncCredentials, vi.fn());
      const [options] = mockRsync.mock.calls[0];
      expect(typeof options.onStdout).toBe('function');
    });

    it('should report filenames from rsync stdout as progress messages', async () => {
      mockRsync.mockImplementation((options: any, callback: any) => {
        if (options.onStdout) {
          options.onStdout('sending incremental file list\n');
          options.onStdout('index.html\n');
          options.onStdout('about.html\n');
          options.onStdout('css/style.css\n');
        }
        callback(null, 'index.html\nabout.html\ncss/style.css\n', '', 'rsync cmd');
      });

      const onProgress = vi.fn();
      await engine.uploadHtml(rsyncCredentials, onProgress);

      const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m);
      expect(messages.some(m => m.includes('index.html'))).toBe(true);
      expect(messages.some(m => m.includes('about.html'))).toBe(true);
      expect(messages.some(m => m.includes('css/style.css'))).toBe(true);
    });

    it('should show the remote destination in progress messages', async () => {
      mockRsync.mockImplementation((options: any, callback: any) => {
        if (options.onStdout) {
          options.onStdout('index.html\n');
        }
        callback(null, 'index.html\n', '', 'rsync cmd');
      });

      const onProgress = vi.fn();
      await engine.uploadHtml(rsyncCredentials, onProgress);

      const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m);
      expect(messages.some(m => m.includes('example.com'))).toBe(true);
    });

    it('should skip non-file lines from rsync output', async () => {
      mockRsync.mockImplementation((options: any, callback: any) => {
        if (options.onStdout) {
          options.onStdout('sending incremental file list\n');
          options.onStdout('index.html\n');
          options.onStdout('\n');
          options.onStdout('sent 1234 bytes  received 56 bytes\n');
          options.onStdout('total size is 9876  speedup is 2.50\n');
        }
        callback(null, 'index.html\n', '', 'rsync cmd');
      });

      const onProgress = vi.fn();
      await engine.uploadHtml(rsyncCredentials, onProgress);

      const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m);
      expect(messages.some(m => m.includes('sending incremental'))).toBe(false);
      expect(messages.some(m => m.includes('sent 1234'))).toBe(false);
      expect(messages.some(m => m.includes('total size'))).toBe(false);
    });

    it('should handle multi-line chunks from onStdout', async () => {
      mockRsync.mockImplementation((options: any, callback: any) => {
        if (options.onStdout) {
          // rsync may send multiple files in a single stdout chunk
          options.onStdout('file1.html\nfile2.html\nfile3.html\n');
        }
        callback(null, 'file1.html\nfile2.html\nfile3.html\n', '', 'rsync cmd');
      });

      const onProgress = vi.fn();
      await engine.uploadHtml(rsyncCredentials, onProgress);

      const messages = onProgress.mock.calls.map(([, m]: [number, string]) => m);
      expect(messages.some(m => m.includes('file1.html'))).toBe(true);
      expect(messages.some(m => m.includes('file2.html'))).toBe(true);
      expect(messages.some(m => m.includes('file3.html'))).toBe(true);
    });

    it('should reach 100% on completion with file count', async () => {
      mockRsync.mockImplementation((options: any, callback: any) => {
        if (options.onStdout) {
          options.onStdout('file1.html\nfile2.html\n');
        }
        callback(null, 'file1.html\nfile2.html\n', '', 'rsync cmd');
      });

      const onProgress = vi.fn();
      await engine.uploadHtml(rsyncCredentials, onProgress);

      const last = onProgress.mock.calls[onProgress.mock.calls.length - 1];
      expect(last[0]).toBe(100);
    });
  });

  // ── per-file progress across methods ──────────────────────────────────

  describe('per-file progress', () => {
    it('uploadHtml progress reaches 100 on completion', async () => {
      mockReaddir.mockResolvedValue([]);
      const onProgress = vi.fn();
      await engine.uploadHtml(defaultCredentials, onProgress);

      const last = onProgress.mock.calls[onProgress.mock.calls.length - 1];
      expect(last[0]).toBe(100);
    });

    it('uploadThumbnails progress reaches 100 on completion', async () => {
      mockReaddir.mockResolvedValue([]);
      const onProgress = vi.fn();
      await engine.uploadThumbnails(defaultCredentials, onProgress);

      const last = onProgress.mock.calls[onProgress.mock.calls.length - 1];
      expect(last[0]).toBe(100);
    });

    it('uploadMedia progress reaches 100 on completion', async () => {
      mockReaddir.mockResolvedValue([]);
      const onProgress = vi.fn();
      await engine.uploadMedia(defaultCredentials, onProgress);

      const last = onProgress.mock.calls[onProgress.mock.calls.length - 1];
      expect(last[0]).toBe(100);
    });
  });
});
