/**
 * Tests for isValidRelativeStackPath (pure function) and the stack-scoped
 * file methods on FileSystemService (listStackDirectory, readStackFile,
 * writeStackFile, writeStackFileBuffer, deleteStackPath, mkdirStackPath).
 *
 * FileSystemService stack methods are tested against a real temp directory so
 * that realpath, stat, and fs I/O all run with actual OS semantics.
 * NodeRegistry is mocked to redirect the composeDir to our temp location.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { isValidRelativeStackPath } from '../utils/validation';

// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR.
// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS).
// Skip those specific cases on Windows.
const isWindows = process.platform === 'win32';

// Mutable state the mocked NodeRegistry reads. Each beforeEach updates it
// before any FileSystemService method runs.
const mockState = { composeDir: '' };

vi.mock('../services/NodeRegistry', () => ({
  NodeRegistry: {
    getInstance: () => ({
      getComposeDir: () => mockState.composeDir,
      getDefaultNodeId: () => 1,
    }),
  },
}));

vi.mock('../utils/debug', () => ({ isDebugEnabled: () => false }));

import { FileSystemService } from '../services/FileSystemService';

// ── isValidRelativeStackPath ──────────────────────────────────────────────────

describe('isValidRelativeStackPath', () => {
  // Accepted inputs
  it('accepts empty string (stack root)', () => expect(isValidRelativeStackPath('')).toBe(true));
  it('accepts simple filename', () => expect(isValidRelativeStackPath('compose.yaml')).toBe(true));
  it('accepts dotfile', () => expect(isValidRelativeStackPath('.env')).toBe(true));
  it('accepts nested path', () => expect(isValidRelativeStackPath('config/app.conf')).toBe(true));
  it('accepts deeply nested path', () => expect(isValidRelativeStackPath('a/b/c/d.txt')).toBe(true));

  // Rejected inputs
  it('rejects ..', () => expect(isValidRelativeStackPath('..')).toBe(false));
  it('rejects ../etc/passwd traversal', () => expect(isValidRelativeStackPath('../etc/passwd')).toBe(false));
  it('rejects a/../b', () => expect(isValidRelativeStackPath('a/../b')).toBe(false));
  it('rejects absolute path', () => expect(isValidRelativeStackPath('/etc/passwd')).toBe(false));
  it('rejects Windows drive path', () => expect(isValidRelativeStackPath('C:/windows')).toBe(false));
  it('rejects NUL byte', () => expect(isValidRelativeStackPath('file\x00name')).toBe(false));
  it('rejects backslash', () => expect(isValidRelativeStackPath('path\\file')).toBe(false));
  it('rejects double-slash', () => expect(isValidRelativeStackPath('a//b')).toBe(false));
  it('rejects bare dot segment', () => expect(isValidRelativeStackPath('a/./b')).toBe(false));
});

// ── FileSystemService stack methods ──────────────────────────────────────────

describe('FileSystemService stack methods', () => {
  const STACK = 'mystack';
  let tmpBase: string;
  let stackDir: string;

  beforeEach(async () => {
    tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-fsp-'));
    stackDir = path.join(tmpBase, STACK);
    mockState.composeDir = tmpBase;
    await fs.mkdir(stackDir, { recursive: true });
  });

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

  // ── listStackDirectory ──────────────────────────────────────────────────

  describe('listStackDirectory', () => {
    it('returns entries with directories sorted before files', async () => {
      await fs.mkdir(path.join(stackDir, 'config'));
      await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
      await fs.writeFile(path.join(stackDir, '.env'), 'KEY=val\n');

      const service = FileSystemService.getInstance();
      const entries = await service.listStackDirectory(STACK, '');

      // Directories come first
      expect(entries[0].type).toBe('directory');
      expect(entries[0].name).toBe('config');

      // Files follow, sorted alphabetically (case-insensitive)
      const fileNames = entries.filter(e => e.type === 'file').map(e => e.name);
      expect(fileNames).toEqual(['.env', 'compose.yaml']);
    });

    it('marks compose.yaml and .env as protected', async () => {
      await fs.writeFile(path.join(stackDir, 'compose.yaml'), '');
      await fs.writeFile(path.join(stackDir, '.env'), '');
      await fs.writeFile(path.join(stackDir, 'custom.conf'), '');

      const service = FileSystemService.getInstance();
      const entries = await service.listStackDirectory(STACK, '');

      const byName = Object.fromEntries(entries.map(e => [e.name, e]));
      expect(byName['compose.yaml'].isProtected).toBe(true);
      expect(byName['.env'].isProtected).toBe(true);
      expect(byName['custom.conf'].isProtected).toBe(false);
    });

    it('includes size and mtime for files', async () => {
      await fs.writeFile(path.join(stackDir, 'test.txt'), 'hello');

      const service = FileSystemService.getInstance();
      const entries = await service.listStackDirectory(STACK, '');

      const file = entries.find(e => e.name === 'test.txt');
      expect(file).toBeDefined();
      expect(file!.size).toBe(5);
      expect(file!.mtime).toBeGreaterThan(0);
    });

    it('returns empty array for an empty stack directory', async () => {
      const service = FileSystemService.getInstance();
      const entries = await service.listStackDirectory(STACK, '');
      expect(entries).toEqual([]);
    });

    it('lists a subdirectory when relPath is provided', async () => {
      await fs.mkdir(path.join(stackDir, 'sub'));
      await fs.writeFile(path.join(stackDir, 'sub', 'child.txt'), 'data');

      const service = FileSystemService.getInstance();
      const entries = await service.listStackDirectory(STACK, 'sub');
      expect(entries.length).toBe(1);
      expect(entries[0].name).toBe('child.txt');
    });
  });

  // ── readStackFile ───────────────────────────────────────────────────────

  describe('readStackFile', () => {
    it('returns text content for a UTF-8 file', async () => {
      await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');

      const service = FileSystemService.getInstance();
      const result = await service.readStackFile(STACK, 'compose.yaml');
      expect(result.binary).toBe(false);
      expect(result.oversized).toBe(false);
      expect(result.content).toBe('services: {}\n');
    });

    it('returns binary:true and no content for a binary file', async () => {
      // PNG magic bytes followed by non-printable data
      const pngMagic = Buffer.concat([
        Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
        Buffer.alloc(50, 0xff),
      ]);
      await fs.writeFile(path.join(stackDir, 'icon.png'), pngMagic);

      const service = FileSystemService.getInstance();
      const result = await service.readStackFile(STACK, 'icon.png');
      expect(result.binary).toBe(true);
      expect(result.oversized).toBe(false);
      expect(result.content).toBeUndefined();
    });

    it('returns oversized:true for files exceeding maxBytes', async () => {
      // Write a text file larger than our small maxBytes limit
      await fs.writeFile(path.join(stackDir, 'big.txt'), 'a'.repeat(200));

      const service = FileSystemService.getInstance();
      // maxBytes=100 forces the oversized path
      const result = await service.readStackFile(STACK, 'big.txt', 100);
      expect(result.oversized).toBe(true);
      expect(result.size).toBe(200);
    });

    it('throws IS_DIRECTORY when path points to a directory', async () => {
      await fs.mkdir(path.join(stackDir, 'subdir'));

      const service = FileSystemService.getInstance();
      await expect(service.readStackFile(STACK, 'subdir')).rejects.toMatchObject({ code: 'IS_DIRECTORY' });
    });
  });

  // ── writeStackFile ──────────────────────────────────────────────────────

  describe('writeStackFile', () => {
    it('creates a new file with the given content', async () => {
      const service = FileSystemService.getInstance();
      await service.writeStackFile(STACK, 'new.txt', 'hello world');

      const content = await fs.readFile(path.join(stackDir, 'new.txt'), 'utf-8');
      expect(content).toBe('hello world');
    });

    it('overwrites an existing file', async () => {
      await fs.writeFile(path.join(stackDir, 'data.txt'), 'original');

      const service = FileSystemService.getInstance();
      await service.writeStackFile(STACK, 'data.txt', 'updated');

      const content = await fs.readFile(path.join(stackDir, 'data.txt'), 'utf-8');
      expect(content).toBe('updated');
    });

    it('creates parent directories if they do not exist', async () => {
      const service = FileSystemService.getInstance();
      await service.writeStackFile(STACK, 'deep/nested/file.txt', 'content');

      const content = await fs.readFile(path.join(stackDir, 'deep', 'nested', 'file.txt'), 'utf-8');
      expect(content).toBe('content');
    });
  });

  // ── writeStackFileBuffer ────────────────────────────────────────────────

  describe('writeStackFileBuffer', () => {
    it('writes raw bytes correctly', async () => {
      const data = Buffer.from([0x01, 0x02, 0x03, 0xff]);
      const service = FileSystemService.getInstance();
      await service.writeStackFileBuffer(STACK, 'binary.bin', data);

      const read = await fs.readFile(path.join(stackDir, 'binary.bin'));
      expect(read).toEqual(data);
    });

    it('creates parent directories when needed', async () => {
      const payload = Buffer.from([0xde, 0xad]);
      const service = FileSystemService.getInstance();
      await service.writeStackFileBuffer(STACK, 'sub/img.bin', payload);

      const read = await fs.readFile(path.join(stackDir, 'sub', 'img.bin'));
      expect(read).toEqual(payload);
    });
  });

  // ── deleteStackPath ─────────────────────────────────────────────────────

  describe('deleteStackPath', () => {
    it('deletes a file', async () => {
      await fs.writeFile(path.join(stackDir, 'todelete.txt'), '');

      const service = FileSystemService.getInstance();
      await service.deleteStackPath(STACK, 'todelete.txt');

      await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
    });

    it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => {
      await fs.mkdir(path.join(stackDir, 'emptydir'));

      const service = FileSystemService.getInstance();
      await service.deleteStackPath(STACK, 'emptydir');

      await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' });
    });

    it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => {
      await fs.mkdir(path.join(stackDir, 'nonempty'));
      await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), '');

      const service = FileSystemService.getInstance();
      await expect(service.deleteStackPath(STACK, 'nonempty', false)).rejects.toMatchObject({ code: 'NOT_EMPTY' });
    });

    it('recursively deletes a non-empty directory when recursive=true', async () => {
      await fs.mkdir(path.join(stackDir, 'tree'));
      await fs.writeFile(path.join(stackDir, 'tree', 'child.txt'), '');

      const service = FileSystemService.getInstance();
      await service.deleteStackPath(STACK, 'tree', true);

      await expect(fs.access(path.join(stackDir, 'tree'))).rejects.toMatchObject({ code: 'ENOENT' });
    });
  });

  // ── mkdirStackPath ──────────────────────────────────────────────────────

  describe('mkdirStackPath', () => {
    it('creates a new directory', async () => {
      const service = FileSystemService.getInstance();
      await service.mkdirStackPath(STACK, 'newdir');

      const stat = await fs.stat(path.join(stackDir, 'newdir'));
      expect(stat.isDirectory()).toBe(true);
    });

    it('creates nested directories', async () => {
      const service = FileSystemService.getInstance();
      await service.mkdirStackPath(STACK, 'a/b/c');

      const stat = await fs.stat(path.join(stackDir, 'a', 'b', 'c'));
      expect(stat.isDirectory()).toBe(true);
    });

    it('does not throw when directory already exists', async () => {
      await fs.mkdir(path.join(stackDir, 'existing'));

      const service = FileSystemService.getInstance();
      await expect(service.mkdirStackPath(STACK, 'existing')).resolves.toBeUndefined();
    });
  });

  // ── path traversal ──────────────────────────────────────────────────────

  describe('path traversal protection', () => {
    it('throws INVALID_PATH when relPath escapes stack directory via ..', async () => {
      // isValidRelativeStackPath rejects ".." before it reaches the service,
      // but we also test the service-level guard with a stack name that would
      // escape the compose dir (isPathWithinBase check in resolveSafeStackPath).
      const service = FileSystemService.getInstance();
      await expect(service.listStackDirectory('..', '')).rejects.toMatchObject({ code: 'INVALID_PATH' });
    });

    it('throws INVALID_PATH for a stack name with path separator', async () => {
      const service = FileSystemService.getInstance();
      await expect(service.readStackFile('../other', 'file.txt')).rejects.toMatchObject({ code: 'INVALID_PATH' });
    });

    it.skipIf(isWindows)('throws SYMLINK_ESCAPE when a symlink inside the stack points outside it (Linux/macOS only)', async () => {
      const externalFile = path.join(tmpBase, 'secret.txt');
      await fs.writeFile(externalFile, 'secret');
      await fs.symlink(externalFile, path.join(stackDir, 'escape-link'));

      const service = FileSystemService.getInstance();
      await expect(service.readStackFile(STACK, 'escape-link')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
    });
  });
});
