import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, writeFile, chmod, readFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { runPostInstall, hashScript } from '../../src/utils/postinstall.js';

describe('postinstall executor', () => {
  let tmp: string;

  beforeEach(async () => {
    tmp = await mkdtemp(join(tmpdir(), 'mcpctl-postinstall-'));
  });

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

  describe('hashScript', () => {
    it('returns deterministic sha256-prefixed hash', () => {
      expect(hashScript('hello')).toMatch(/^sha256:[0-9a-f]{64}$/);
      expect(hashScript('hello')).toBe(hashScript('hello'));
      expect(hashScript('hello')).not.toBe(hashScript('hellp'));
    });
  });

  describe('runPostInstall — success path', () => {
    it('runs a passing script and returns exit 0 + script hash', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'install.sh');
      await writeFile(scriptPath, '#!/bin/sh\necho hello-stdout\necho hello-stderr 1>&2\nexit 0\n');
      await chmod(scriptPath, 0o755);

      const result = await runPostInstall({
        installDir,
        scriptPath: 'install.sh',
        skillName: 'test-skill',
        semver: '0.1.0',
        logsDir: join(tmp, 'logs'),
      });

      expect(result.exitCode).toBe(0);
      expect(result.timedOut).toBe(false);
      expect(result.stdoutTail).toContain('hello-stdout');
      expect(result.stderrTail).toContain('hello-stderr');
      expect(result.scriptHash).toMatch(/^sha256:/);
    });

    it('passes curated env (MCPCTL_SKILL_NAME, _VERSION, _DIR, _PROJECT)', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'install.sh');
      // Write env vars to a file we can read back.
      const outFile = join(tmp, 'env-dump.txt');
      await writeFile(scriptPath, `#!/bin/sh
echo "name=$MCPCTL_SKILL_NAME" > ${JSON.stringify(outFile)}
echo "version=$MCPCTL_SKILL_VERSION" >> ${JSON.stringify(outFile)}
echo "dir=$MCPCTL_SKILL_DIR" >> ${JSON.stringify(outFile)}
echo "project=$MCPCTL_PROJECT" >> ${JSON.stringify(outFile)}
`);
      await chmod(scriptPath, 0o755);

      const result = await runPostInstall({
        installDir,
        scriptPath: 'install.sh',
        skillName: 'env-test',
        semver: '1.2.3',
        projectName: 'demo',
        logsDir: join(tmp, 'logs'),
      });
      expect(result.exitCode).toBe(0);

      const dumped = await readFile(outFile, 'utf-8');
      expect(dumped).toContain('name=env-test');
      expect(dumped).toContain('version=1.2.3');
      expect(dumped).toContain('dir=' + installDir);
      expect(dumped).toContain('project=demo');
    });

    it('chmods 0644 scripts to executable before spawn', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'install.sh');
      await writeFile(scriptPath, '#!/bin/sh\nexit 0\n');
      await chmod(scriptPath, 0o644); // not executable

      const result = await runPostInstall({
        installDir,
        scriptPath: 'install.sh',
        skillName: 't',
        semver: '0.1.0',
        logsDir: join(tmp, 'logs'),
      });

      expect(result.exitCode).toBe(0);
    });
  });

  describe('runPostInstall — failure paths', () => {
    it('captures non-zero exit code and returns it', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'fail.sh');
      await writeFile(scriptPath, '#!/bin/sh\necho oops 1>&2\nexit 7\n');
      await chmod(scriptPath, 0o755);

      const result = await runPostInstall({
        installDir,
        scriptPath: 'fail.sh',
        skillName: 't',
        semver: '0.1.0',
        logsDir: join(tmp, 'logs'),
      });

      expect(result.exitCode).toBe(7);
      expect(result.timedOut).toBe(false);
      expect(result.stderrTail).toContain('oops');
    });

    it('honors timeoutSec — kills via SIGTERM and reports timedOut=true', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'hang.sh');
      // `exec` so SIGTERM hits sleep directly — without it /bin/sh
      // catches the signal but the orphaned sleep keeps the streams
      // open until SIGKILL; the test then has to wait for the 2s grace
      // window before we force-kill, which is fine but flakier.
      await writeFile(scriptPath, '#!/bin/sh\nexec sleep 30\n');
      await chmod(scriptPath, 0o755);

      const start = Date.now();
      const result = await runPostInstall({
        installDir,
        scriptPath: 'hang.sh',
        skillName: 't',
        semver: '0.1.0',
        timeoutSec: 1,
        logsDir: join(tmp, 'logs'),
      });
      const elapsed = Date.now() - start;

      expect(result.timedOut).toBe(true);
      // 1s timeout + up to 2s grace before SIGKILL.
      expect(elapsed).toBeLessThan(5000);
      expect(elapsed).toBeGreaterThanOrEqual(1000);
    }, 15_000);

    it('rejects path-escape attempts', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });

      await expect(runPostInstall({
        installDir,
        scriptPath: '../escape.sh',
        skillName: 't',
        semver: '0.1.0',
        logsDir: join(tmp, 'logs'),
      })).rejects.toThrow(/escapes skill dir/);
    });

    it('throws when the script does not exist', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });

      await expect(runPostInstall({
        installDir,
        scriptPath: 'missing.sh',
        skillName: 't',
        semver: '0.1.0',
        logsDir: join(tmp, 'logs'),
      })).rejects.toThrow();
    });
  });

  describe('runPostInstall — install log', () => {
    it('writes stdout + stderr + exit summary to logsDir/install.log', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'install.sh');
      await writeFile(scriptPath, '#!/bin/sh\necho hello\nexit 0\n');
      await chmod(scriptPath, 0o755);

      const logsDir = join(tmp, 'logs');
      await runPostInstall({
        installDir,
        scriptPath: 'install.sh',
        skillName: 'log-test',
        semver: '0.1.0',
        logsDir,
      });

      const log = await readFile(join(logsDir, 'install.log'), 'utf-8');
      expect(log).toContain('log-test@0.1.0');
      expect(log).toContain('hello');
      expect(log).toContain('exit 0');
    });

    it('appends across runs without losing prior history', async () => {
      const installDir = join(tmp, 'skill');
      await mkdir(installDir, { recursive: true });
      const scriptPath = join(installDir, 'install.sh');
      await writeFile(scriptPath, '#!/bin/sh\necho run\nexit 0\n');
      await chmod(scriptPath, 0o755);

      const logsDir = join(tmp, 'logs');
      const input = {
        installDir,
        scriptPath: 'install.sh',
        skillName: 't',
        semver: '0.1.0',
        logsDir,
      };
      await runPostInstall(input);
      await runPostInstall(input);

      const log = await readFile(join(logsDir, 'install.log'), 'utf-8');
      // Two run headers separated by `===`.
      const headers = (log.match(/=== /g) ?? []).length;
      expect(headers).toBeGreaterThanOrEqual(2);
    });
  });
});
