/**
 * Tests for FileSystemService.findImportCandidates: the read-only compose-dir
 * walk behind the guided import scan. Uses a real temp directory so the nesting
 * and placement-status logic is exercised against the actual filesystem.
 */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'fs';
import path from 'path';

const { tmpRoot } = vi.hoisted(() => {
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const nodeOs = require('os');
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const nodePath = require('path');
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  const nodeFs = require('fs');
  const tmpRoot: string = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'sencho-import-'));
  return { tmpRoot };
});

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

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

const COMPOSE = 'services:\n  app:\n    image: nginx:1.27\n';

describe('FileSystemService.findImportCandidates', () => {
  beforeAll(() => {
    // Already a stack: top-level subdir with a compose file.
    fs.mkdirSync(path.join(tmpRoot, 'immich'), { recursive: true });
    fs.writeFileSync(path.join(tmpRoot, 'immich', 'compose.yaml'), COMPOSE);
    // Loose at the root: will not auto-register.
    fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
    // One directory too deep: apps/ has no compose, apps/vault/ does.
    fs.mkdirSync(path.join(tmpRoot, 'apps', 'vault'), { recursive: true });
    fs.writeFileSync(path.join(tmpRoot, 'apps', 'vault', 'compose.yaml'), COMPOSE);
    // A directory with no compose file at all: ignored.
    fs.mkdirSync(path.join(tmpRoot, 'notes'), { recursive: true });
    fs.writeFileSync(path.join(tmpRoot, 'notes', 'README.md'), '# notes');
  });

  afterAll(() => {
    fs.rmSync(tmpRoot, { recursive: true, force: true });
  });

  it('surfaces loose-root and nested files and skips directories already a stack', async () => {
    const candidates = await FileSystemService.getInstance().findImportCandidates();

    // immich is a top-level subdir with a compose file, so it is already a stack
    // (it shows in the sidebar) and is not offered as an import candidate.
    expect(candidates.some((c) => c.name === 'immich')).toBe(false);

    const loose = candidates.find((c) => c.status === 'loose-root');
    expect(loose).toMatchObject({ name: '', composeFile: 'docker-compose.yml', location: 'docker-compose.yml' });
    expect(loose?.content).toContain('services:');

    const nested = candidates.find((c) => c.status === 'nested');
    expect(nested).toMatchObject({ name: 'vault', composeFile: 'compose.yaml', location: 'apps/vault/compose.yaml' });

    // The directory with only a README produced no candidate.
    expect(candidates.some((c) => c.name === 'notes')).toBe(false);
    expect(candidates).toHaveLength(2);
  });

  it('flags oversized compose files instead of reading them', async () => {
    // Nested under a wrapper with no top-level compose, so the scan descends and
    // surfaces the inner file (a top-level dir with a compose file is a stack and
    // would be skipped).
    const bigDir = path.join(tmpRoot, 'oversized-wrap', 'big');
    fs.mkdirSync(bigDir, { recursive: true });
    fs.writeFileSync(path.join(bigDir, 'compose.yaml'), 'x'.repeat(1_048_577));
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const big = candidates.find((c) => c.name === 'big');
      expect(big?.status).toBe('nested');
      expect(big?.oversized).toBe(true);
      expect(big?.content).toBeNull();
    } finally {
      fs.rmSync(path.join(tmpRoot, 'oversized-wrap'), { recursive: true, force: true });
    }
  });

  it('skips a non-regular file (a directory named compose.yaml) without reading it', async () => {
    // A directory named compose.yaml passes the access() probe; the isFile()
    // guard means it is reported as unreadable rather than read as content.
    // Nested under a wrapper so it surfaces as a candidate at all.
    const weirdDir = path.join(tmpRoot, 'weird-wrap', 'weird');
    fs.mkdirSync(path.join(weirdDir, 'compose.yaml'), { recursive: true });
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const weird = candidates.find((c) => c.name === 'weird');
      expect(weird).toBeDefined();
      expect(weird?.content).toBeNull();
      expect(weird?.oversized).toBe(false);
    } finally {
      fs.rmSync(path.join(tmpRoot, 'weird-wrap'), { recursive: true, force: true });
    }
  });

  it('does not read a compose file that symlinks outside the compose directory', async () => {
    // Sibling of the temp compose root, i.e. outside the base dir.
    const outside = path.join(path.dirname(tmpRoot), `sencho-outside-${Date.now()}.yaml`);
    fs.writeFileSync(outside, COMPOSE);
    // Nested under a wrapper so the symlinked compose file surfaces as a candidate.
    const escDir = path.join(tmpRoot, 'escape-wrap', 'escape');
    fs.mkdirSync(escDir, { recursive: true });
    let linked = true;
    try {
      fs.symlinkSync(outside, path.join(escDir, 'compose.yaml'));
    } catch {
      // Creating symlinks needs privilege on some platforms; the assertion below
      // runs for real on the Linux CI runners.
      linked = false;
    }
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
    try {
      if (!linked) return;
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const esc = candidates.find((c) => c.name === 'escape');
      expect(esc).toBeDefined();
      expect(esc?.content).toBeNull();
      expect(warnSpy).toHaveBeenCalled();
    } finally {
      warnSpy.mockRestore();
      fs.rmSync(path.join(tmpRoot, 'escape-wrap'), { recursive: true, force: true });
      fs.rmSync(outside, { force: true });
    }
  });

  it('skips a directory that is already a stack and does not descend into it', async () => {
    // A directory that is already a stack (top-level compose) and also has a
    // compose file one level deeper yields no candidates: it is skipped as an
    // existing stack, and the scan does not descend into it to surface the child.
    const dir = path.join(tmpRoot, 'both');
    fs.mkdirSync(path.join(dir, 'sub'), { recursive: true });
    fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
    fs.writeFileSync(path.join(dir, 'sub', 'compose.yaml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const fromBoth = candidates.filter((c) => c.location.startsWith('both/'));
      expect(fromBoth).toHaveLength(0);
    } finally {
      fs.rmSync(dir, { recursive: true, force: true });
    }
  });


  it('surfaces non-canonical loose-root yaml (e.g. nginx.yml)', async () => {
    fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const loose = candidates.find((c) => c.location === 'nginx.yml');
      expect(loose).toMatchObject({
        name: '',
        composeFile: 'nginx.yml',
        status: 'loose-root',
      });
      expect(loose?.content).toContain('services:');
    } finally {
      fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true });
    }
  });

  it('surfaces nested non-canonical yaml (e.g. apps/plex/plex.yml)', async () => {
    const dir = path.join(tmpRoot, 'apps', 'plex');
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const nested = candidates.find((c) => c.location === 'apps/plex/plex.yml');
      expect(nested).toMatchObject({
        name: 'plex',
        composeFile: 'plex.yml',
        status: 'nested',
      });
    } finally {
      fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true });
    }
  });

  it('does not surface compose override filenames as adopt candidates', async () => {
    fs.writeFileSync(path.join(tmpRoot, 'compose.override.yml'), COMPOSE);
    const wrap = path.join(tmpRoot, 'ovr-wrap', 'ovr');
    fs.mkdirSync(wrap, { recursive: true });
    fs.writeFileSync(path.join(wrap, 'docker-compose.override.yaml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      expect(candidates.some((c) => c.composeFile.includes('override'))).toBe(false);
    } finally {
      fs.rmSync(path.join(tmpRoot, 'compose.override.yml'), { force: true });
      fs.rmSync(path.join(tmpRoot, 'ovr-wrap'), { recursive: true, force: true });
    }
  });

  it('does not surface non-canonical yaml inside a top-level folder (not a stack, not adoptable)', async () => {
    // plex/plex.yml at the compose root is neither a stack (no canonical compose)
    // nor an adopt candidate (promoting with destName === folder would conflict).
    const dir = path.join(tmpRoot, 'plex-top');
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      expect(candidates.some((c) => c.location.includes('plex-top'))).toBe(false);
    } finally {
      fs.rmSync(dir, { recursive: true, force: true });
    }
  });

  it('picks the localeCompare-first non-canonical yaml when several exist', async () => {
    const dir = path.join(tmpRoot, 'sort-wrap', 'svc');
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(path.join(dir, 'z.yml'), COMPOSE);
    fs.writeFileSync(path.join(dir, 'a.yml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const nested = candidates.find((c) => c.name === 'svc');
      expect(nested).toMatchObject({
        composeFile: 'a.yml',
        location: 'sort-wrap/svc/a.yml',
        status: 'nested',
      });
    } finally {
      fs.rmSync(path.join(tmpRoot, 'sort-wrap'), { recursive: true, force: true });
    }
  });

  it('prefers a canonical compose filename over a sibling non-canonical yaml', async () => {
    const dir = path.join(tmpRoot, 'pref-wrap', 'svc');
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
    fs.writeFileSync(path.join(dir, 'nginx.yml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates();
      const nested = candidates.find((c) => c.name === 'svc');
      expect(nested).toMatchObject({
        composeFile: 'compose.yaml',
        location: 'pref-wrap/svc/compose.yaml',
        status: 'nested',
      });
    } finally {
      fs.rmSync(path.join(tmpRoot, 'pref-wrap'), { recursive: true, force: true });
    }
  });

  it('truncates at maxCandidates', async () => {
    // Base fixtures yield 2 candidates (loose-root + nested); add a third loose
    // file so a cap of 2 actually truncates rather than coincidentally matching.
    fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
    try {
      const candidates = await FileSystemService.getInstance().findImportCandidates(2);
      expect(candidates).toHaveLength(2);
    } finally {
      fs.rmSync(path.join(tmpRoot, 'compose.yaml'), { force: true });
    }
  });
});
