import type { SpawnSyncReturns } from 'node:child_process';

import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
  chmodSync,
  copyFileSync,
  cpSync,
  existsSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  statSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import process from 'node:process';

import { build } from 'vite';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

const root = resolve(__dirname, '..'); // eslint-disable-line unicorn/prefer-module
const sourceReleaseScript = join(root, 'scripts/release.sh');
const sourceProductionContract = join(root, 'scripts/production-contract.mjs');
const nginxExample = join(root, 'deploy/nginx/eln-template-filler.conf.example');
const temporaryRoot = mkdtempSync(join(tmpdir(), 'eln-template-filler-release-'));
const freshDist = join(temporaryRoot, 'fresh-dist');

interface Manifest {
  guid: string;
  variations: Array<{ url: string }>;
  version: string;
}

interface ReleaseSandbox {
  dist: string;
  pluginRoot: string;
  releaseRoot: string;
  script: string;
  target: string;
}

function listFiles(directory: string, base = directory): string[] {
  return readdirSync(directory)
    .flatMap((name) => {
      const absolutePath = join(directory, name);
      return statSync(absolutePath).isDirectory() ? listFiles(absolutePath, base) : [relative(base, absolutePath)];
    })
    .sort();
}

function fileHash(file: string): string {
  return createHash('sha256').update(readFileSync(file)).digest('hex');
}

function createSandbox(name: string): ReleaseSandbox {
  const pluginRoot = mkdtempSync(join(temporaryRoot, `${name}-`));
  const dist = join(pluginRoot, 'dist');
  const script = join(pluginRoot, 'scripts/release.sh');
  const releaseRoot = join(pluginRoot, 'releases');

  cpSync(freshDist, dist, { recursive: true });
  mkdirSync(dirname(script), { recursive: true });
  copyFileSync(sourceReleaseScript, script);
  copyFileSync(sourceProductionContract, join(dirname(script), 'production-contract.mjs'));
  chmodSync(script, 0o755);

  return {
    dist,
    pluginRoot,
    releaseRoot,
    script,
    target: join(releaseRoot, 'onlyoffice-plugins/0.1.0/eln-template-filler'),
  };
}

function runRelease(
  sandbox: ReleaseSandbox,
  releaseRoot = sandbox.releaseRoot,
  cwd = sandbox.pluginRoot,
  environment: Record<string, string> = {},
): SpawnSyncReturns<string> {
  return spawnSync('sh', [sandbox.script], {
    cwd,
    encoding: 'utf8',
    env: { ...process.env, ONLYOFFICE_RELEASE_ROOT: releaseRoot, ...environment },
  });
}

function stagingDirectories(sandbox: ReleaseSandbox): string[] {
  const parent = dirname(sandbox.target);
  return existsSync(parent) ? readdirSync(parent).filter((name) => name.startsWith('.eln-template-filler.staging.')) : [];
}

function updateManifest(sandbox: ReleaseSandbox, change: (manifest: Manifest) => void): void {
  const manifestPath = join(sandbox.dist, 'config.json');
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest;
  change(manifest);
  writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
}

function productionAsset(sandbox: ReleaseSandbox, extension: 'css' | 'js'): string {
  return readdirSync(join(sandbox.dist, 'assets')).find((name) => name.endsWith(`.${extension}`))!;
}

beforeAll(async () => {
  await build({
    root,
    configFile: join(root, 'vite.config.ts'),
    build: { emptyOutDir: true, outDir: freshDist },
  });
}, 30_000);

afterAll(() => {
  rmSync(temporaryRoot, { force: true, recursive: true });
});

describe('immutable release script', () => {
  it('从 fresh dist 生成严格 7 文件正式 release', () => {
    const sandbox = createSandbox('fresh');
    writeFileSync(join(sandbox.dist, 'eln-template-filler.plugin'), 'must not be released');

    const result = runRelease(sandbox);

    expect(result.status, result.stderr).toBe(0);
    expect(listFiles(sandbox.target)).toEqual(
      [
        `assets/${productionAsset(sandbox, 'css')}`,
        `assets/${productionAsset(sandbox, 'js')}`,
        'config.json',
        'index.html',
        'resources/icon.png',
        'resources/icon@2x.png',
        'translations/langs.json',
      ].sort(),
    );
  });

  it('将最终目录设为 0755、文件设为 0644', () => {
    const sandbox = createSandbox('permissions');
    expect(runRelease(sandbox).status).toBe(0);

    const pending = [sandbox.target];
    while (pending.length > 0) {
      const directory = pending.pop()!;
      expect(statSync(directory).mode & 0o777).toBe(0o755);
      for (const name of readdirSync(directory)) {
        const entry = join(directory, name);
        if (statSync(entry).isDirectory()) pending.push(entry);
        else expect(statSync(entry).mode & 0o777).toBe(0o644);
      }
    }
  });

  it('相同内容重复发布时幂等成功', () => {
    const sandbox = createSandbox('idempotent');
    expect(runRelease(sandbox).status).toBe(0);
    const before = listFiles(sandbox.target).map((file) => [file, fileHash(join(sandbox.target, file))]);

    const second = runRelease(sandbox);

    expect(second.status, second.stderr).toBe(0);
    expect(listFiles(sandbox.target).map((file) => [file, fileHash(join(sandbox.target, file))])).toEqual(before);
    expect(stagingDirectories(sandbox)).toEqual([]);
  });

  it('已有不同内容时拒绝覆盖并保持旧 release', () => {
    const sandbox = createSandbox('immutable');
    mkdirSync(sandbox.target, { recursive: true });
    writeFileSync(join(sandbox.target, 'keep.txt'), 'old release');

    const result = runRelease(sandbox);

    expect(result.status).not.toBe(0);
    expect(readFileSync(join(sandbox.target, 'keep.txt'), 'utf8')).toBe('old release');
    expect(stagingDirectories(sandbox)).toEqual([]);
  });

  it('相对 release root 按调用目录解析', () => {
    const sandbox = createSandbox('relative');
    const cwd = mkdtempSync(join(temporaryRoot, 'relative-cwd-'));
    const result = runRelease(sandbox, 'published', cwd);

    expect(result.status, result.stderr).toBe(0);
    expect(listFiles(join(cwd, 'published/onlyoffice-plugins/0.1.0/eln-template-filler'))).toHaveLength(7);
  });

  it.each([
    ['文件系统根目录', (_sandbox: ReleaseSandbox) => '/'],
    ['插件目录', (sandbox: ReleaseSandbox) => sandbox.pluginRoot],
    ['dist 内目录', (sandbox: ReleaseSandbox) => join(sandbox.dist, 'nested/releases')],
  ])('拒绝不安全的%s且不生成 target', (_, releaseRoot) => {
    const sandbox = createSandbox('unsafe-root');
    const result = runRelease(sandbox, releaseRoot(sandbox));

    expect(result.status).not.toBe(0);
    expect(existsSync(sandbox.target)).toBe(false);
  });

  it('缺少 dist 时失败', () => {
    const sandbox = createSandbox('missing-dist');
    rmSync(sandbox.dist, { recursive: true });

    expect(runRelease(sandbox).status).not.toBe(0);
    expect(existsSync(sandbox.target)).toBe(false);
  });

  it.each([
    ['错误版本', (sandbox: ReleaseSandbox) => updateManifest(sandbox, (manifest) => (manifest.version = '0.1.1'))],
    ['错误 GUID', (sandbox: ReleaseSandbox) => updateManifest(sandbox, (manifest) => (manifest.guid = 'asc.{WRONG}'))],
    ['错误正式 URL', (sandbox: ReleaseSandbox) => updateManifest(sandbox, (manifest) => (manifest.variations[0]!.url = 'index.html?v=dev'))],
  ])('%s时拒绝发布', (_, corrupt) => {
    const sandbox = createSandbox('invalid-manifest');
    corrupt(sandbox);

    expect(runRelease(sandbox).status).not.toBe(0);
    expect(existsSync(sandbox.target)).toBe(false);
  });

  it('拒绝额外的哈希资产，保持严格 7 文件', () => {
    const sandbox = createSandbox('extra-asset');
    const html = join(sandbox.dist, 'index.html');
    writeFileSync(join(sandbox.dist, 'assets/index-Extra.js'), 'console.log("extra")');
    writeFileSync(html, readFileSync(html, 'utf8').replace('</body>', '<script src="./assets/index-Extra.js"></script></body>'));

    expect(runRelease(sandbox).status).not.toBe(0);
    expect(existsSync(sandbox.target)).toBe(false);
  });

  it('拒绝开发或敏感内容', () => {
    const sandbox = createSandbox('unsafe-content');
    const javascript = join(sandbox.dist, 'assets', productionAsset(sandbox, 'js'));
    writeFileSync(javascript, `${readFileSync(javascript, 'utf8')}\nconst endpoint = "ws://localhost:4173";`);

    expect(runRelease(sandbox).status).not.toBe(0);
    expect(existsSync(sandbox.target)).toBe(false);
  });

  it('发布当前目录时保持 previous-test 旧 release 不变', () => {
    const sandbox = createSandbox('previous');
    const oldRelease = join(sandbox.releaseRoot, 'onlyoffice-plugins/previous-test/eln-template-filler');
    mkdirSync(oldRelease, { recursive: true });
    writeFileSync(join(oldRelease, 'keep.txt'), 'previous release');

    expect(runRelease(sandbox).status).toBe(0);
    expect(readFileSync(join(oldRelease, 'keep.txt'), 'utf8')).toBe('previous release');
  });

  it('拒绝 release 路径中的 symlink，且不写入外部目录', () => {
    const sandbox = createSandbox('symlink');
    const external = mkdtempSync(join(temporaryRoot, 'external-release-'));
    mkdirSync(sandbox.releaseRoot);
    symlinkSync(external, join(sandbox.releaseRoot, 'onlyoffice-plugins'));

    expect(runRelease(sandbox).status).not.toBe(0);
    expect(readdirSync(external)).toEqual([]);
  });
});

describe('nginx release cache contract', () => {
  it('使用显式 release root 并禁止 MIME 嗅探', () => {
    const source = readFileSync(nginxExample, 'utf8');
    expect(source).toContain('root <ONLYOFFICE_RELEASE_ROOT>;');
    expect(source.match(/add_header X-Content-Type-Options "nosniff" always;/g)?.length).toBeGreaterThanOrEqual(3);
  });

  it('manifest 和 HTML 每次重新验证', () => {
    const source = readFileSync(nginxExample, 'utf8');
    expect(source).toContain(String.raw`location ~ ^/onlyoffice-plugins/[^/]+/eln-template-filler/(?:config\.json|index\.html)$ {`);
    expect(source).toContain('add_header Cache-Control "no-cache, must-revalidate" always;');
    expect(source).toContain('try_files $uri =404;');
  });

  it('仅对 Vite 哈希 JS/CSS 使用 immutable 缓存', () => {
    const source = readFileSync(nginxExample, 'utf8');
    expect(source).toContain(String.raw`assets/.+-[A-Za-z0-9_-]+\.(?:js|css)$`);
    expect(source).toContain('add_header Cache-Control "public, max-age=31536000, immutable" always;');
  });

  it('图标有限缓存且不包含开发代理或真实主机', () => {
    const source = readFileSync(nginxExample, 'utf8');
    expect(source).toContain(String.raw`resources/icon(?:@2x)?\.png$`);
    expect(source).not.toMatch(/proxy_pass|Access-Control-Allow-Origin|secret|token|jwt|localhost|127\.0\.0\.1|https?:\/\//i);
  });
});
