import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import { join, posix, win32 } from 'path';
import { setUntrustedFileResponseHeaders, ensurePathWithin, isPathEscapeError, isNotFoundError, safeZipEntryName, isJobWithinWorkspace, isProtectedWorkspaceDir, collectZipFiles, collectAllSubJobs, readJobEvents, reserveAttachmentName } from './local-api-helpers.js';

// ---------------------------------------------------------------------------
// reserveAttachmentName
// ---------------------------------------------------------------------------

describe('reserveAttachmentName', () => {
  let dir: string;
  beforeEach(() => { dir = fs.mkdtempSync(join(os.tmpdir(), 'reserve-')); });
  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

  it('keeps the name when nothing collides', () => {
    expect(reserveAttachmentName(dir, 'a.txt', new Set())).toBe('a.txt');
  });

  it('avoids an existing on-disk file with " (2)" and does not overwrite it', () => {
    fs.writeFileSync(join(dir, 'spec.pdf'), 'original');
    expect(reserveAttachmentName(dir, 'spec.pdf', new Set())).toBe('spec (2).pdf');
    // caller has not written yet; original is untouched
    expect(fs.readFileSync(join(dir, 'spec.pdf'), 'utf8')).toBe('original');
  });

  it('dedupes within a single batch via the reserved set', () => {
    const reserved = new Set<string>();
    expect(reserveAttachmentName(dir, 'dup.txt', reserved)).toBe('dup.txt');
    expect(reserveAttachmentName(dir, 'dup.txt', reserved)).toBe('dup (2).txt');
    expect(reserveAttachmentName(dir, 'dup.txt', reserved)).toBe('dup (3).txt');
  });

  it('increments past both on-disk and reserved collisions', () => {
    fs.writeFileSync(join(dir, 'x.md'), '');       // x.md taken on disk
    fs.writeFileSync(join(dir, 'x (2).md'), '');    // x (2).md taken on disk
    const reserved = new Set<string>();
    expect(reserveAttachmentName(dir, 'x.md', reserved)).toBe('x (3).md');
  });

  it('works on a dir that does not exist yet', () => {
    const missing = join(dir, 'nope');
    expect(reserveAttachmentName(missing, 'archive.tar.gz', new Set())).toBe('archive.tar.gz');
  });
});

// ---------------------------------------------------------------------------
// collectAllSubJobs
// ---------------------------------------------------------------------------

function fakeRepo(map: Record<string, { id: string; status: string; parentJobId: string | null }[]>) {
  return {
    getSubJobs: async (parentId: string) => (map[parentId] ?? []) as any,
  } as any;
}

describe('collectAllSubJobs', () => {
  it('completed 親配下の孫も全子再帰で拾う', async () => {
    // root -> A(succeeded) -> A1(running)
    const repo = fakeRepo({
      root: [{ id: 'A', status: 'succeeded', parentJobId: 'root' }],
      A: [{ id: 'A1', status: 'running', parentJobId: 'A' }],
      A1: [],
    });
    const nodes = await collectAllSubJobs(repo, 'root');
    expect(nodes.map(n => n.job.id).sort()).toEqual(['A', 'A1']);
    expect(nodes.find(n => n.job.id === 'A')!.depth).toBe(1);
    expect(nodes.find(n => n.job.id === 'A1')!.depth).toBe(2);
    expect(nodes.find(n => n.job.id === 'A1')!.parentJobId).toBe('A');
  });

  it('子なし root は空配列を返す', async () => {
    const repo = fakeRepo({ root: [] });
    const nodes = await collectAllSubJobs(repo, 'root');
    expect(nodes).toEqual([]);
  });

  it('複数子を depth=1 で全て収集する', async () => {
    const repo = fakeRepo({
      root: [
        { id: 'B1', status: 'running', parentJobId: 'root' },
        { id: 'B2', status: 'queued', parentJobId: 'root' },
      ],
      B1: [],
      B2: [],
    });
    const nodes = await collectAllSubJobs(repo, 'root');
    expect(nodes.map(n => n.job.id).sort()).toEqual(['B1', 'B2']);
    expect(nodes.every(n => n.depth === 1)).toBe(true);
  });
});

describe('isJobWithinWorkspace (separator-bounded containment)', () => {
  it('accepts the workspace itself and descendants', () => {
    expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/12')).toBe(true);
    expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/12/subtasks/1')).toBe(true);
  });
  it('rejects a sibling sharing only a string prefix (the /12 vs /123 越境)', () => {
    expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/123')).toBe(false);
    expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/123/subtasks/1')).toBe(false);
    expect(isJobWithinWorkspace('/wt/ws', '/wt/ws-evil/output')).toBe(false);
  });
  it('rejects null/empty inputs', () => {
    expect(isJobWithinWorkspace(null, '/wt/local/12')).toBe(false);
    expect(isJobWithinWorkspace('/wt/local/12', null)).toBe(false);
    expect(isJobWithinWorkspace('', '/wt/local/12')).toBe(false);
  });
});

describe('safeZipEntryName', () => {
  const root = '/srv/ws';

  it('keeps a plain relative path as the entry name', () => {
    expect(safeZipEntryName(root, '/srv/ws/a.txt')).toBe('a.txt');
    expect(safeZipEntryName(root, '/srv/ws/sub/b.txt')).toBe('sub/b.txt');
  });

  it('produces names that are never absolute (POSIX or Windows), never contain .. or backslash', () => {
    // Each is a single literal filename living directly inside root — the
    // dangerous forms an attacker could create on POSIX and weaponize on extract.
    const evilNames = [
      'a\\..\\evil.txt',          // backslash + .. (Windows traversal)
      'C:\\evil.txt',             // drive-letter absolute
      '\\\\server\\share\\x.txt', // UNC
      'a/../../evil.txt',
    ];
    for (const name of evilNames) {
      const entry = safeZipEntryName(root, `/srv/ws/${name}`);
      expect(entry.includes('\\'), `${name} -> ${entry}`).toBe(false);
      expect(entry.split('/').includes('..'), `${name} -> ${entry}`).toBe(false);
      expect(posix.isAbsolute(entry), `${name} -> ${entry}`).toBe(false);
      expect(win32.isAbsolute(entry), `${name} -> ${entry}`).toBe(false);
    }
  });

  it('neutralizes a Windows drive-letter segment (C: -> C_)', () => {
    expect(safeZipEntryName(root, '/srv/ws/C:\\evil.txt')).toBe('C_/evil.txt');
  });
});

describe('isNotFoundError', () => {
  it('matches ENOENT and ENOTDIR (missing file / non-dir in path)', () => {
    expect(isNotFoundError(Object.assign(new Error('x'), { code: 'ENOENT' }))).toBe(true);
    expect(isNotFoundError(Object.assign(new Error('x'), { code: 'ENOTDIR' }))).toBe(true);
  });
  it('does not match other errors (EACCES, plain Error, null)', () => {
    expect(isNotFoundError(Object.assign(new Error('x'), { code: 'EACCES' }))).toBe(false);
    expect(isNotFoundError(new Error('boom'))).toBe(false);
    expect(isNotFoundError(null)).toBe(false);
  });
});

function fakeRes(): { res: Response; headers: Record<string, string> } {
  const headers: Record<string, string> = {};
  const res = {
    setHeader(name: string, value: string) {
      headers[name] = value;
    },
  } as unknown as Response;
  return { res, headers };
}

describe('setUntrustedFileResponseHeaders', () => {
  it('disables MIME sniffing and sandboxes the document', () => {
    const { res, headers } = fakeRes();
    setUntrustedFileResponseHeaders(res);
    // nosniff: a text/* file cannot be re-interpreted as executable HTML
    expect(headers['X-Content-Type-Options']).toBe('nosniff');
    // CSP sandbox (no allow-scripts token) => scripts disabled + opaque origin,
    // so HTML/SVG written into a workspace cannot run on our origin or ride the
    // viewer's session (stored XSS, incl. the unauthenticated share endpoint).
    expect(headers['Content-Security-Policy']).toBe('sandbox');
    expect(headers['Content-Security-Policy']).not.toContain('allow-scripts');
  });
});

describe('ensurePathWithin (regression: no escape, no sibling-prefix bypass)', () => {
  it('allows a path inside the base', () => {
    const base = '/tmp/ws';
    expect(ensurePathWithin(base, 'output/a.txt')).toBe('/tmp/ws/output/a.txt');
  });
  it('rejects traversal out of the base', () => {
    expect(() => ensurePathWithin('/tmp/ws', '../etc/passwd')).toThrow();
  });
  it('rejects a sibling dir sharing the base prefix', () => {
    // /tmp/ws-evil must not pass the /tmp/ws containment check
    expect(() => ensurePathWithin('/tmp/ws', '../ws-evil/x')).toThrow();
  });
});

describe('ensurePathWithin (symlink escape hardening)', () => {
  let root: string;
  let outside: string;

  beforeEach(() => {
    const base = fs.mkdtempSync(join(os.tmpdir(), 'maestro-helpers-'));
    root = join(base, 'root');
    outside = join(base, 'outside');
    fs.mkdirSync(root, { recursive: true });
    fs.mkdirSync(outside, { recursive: true });
    fs.writeFileSync(join(outside, 'secret.txt'), 'secret');
  });

  afterEach(() => {
    try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
    try { fs.rmSync(outside, { recursive: true, force: true }); } catch { /* ignore */ }
  });

  it('rejects a symlink inside root that points outside (isPathEscapeError matches)', () => {
    // An agent-created symlink resolves lexically "within" root but escapes via realpath.
    fs.symlinkSync(outside, join(root, 'evil'));
    let err: unknown;
    try { ensurePathWithin(root, 'evil/x'); } catch (e) { err = e; }
    expect(err).toBeInstanceOf(Error);
    expect(isPathEscapeError(err)).toBe(true);
  });

  it('still resolves a normal in-root path when root exists', () => {
    expect(ensurePathWithin(root, 'sub/file.txt')).toBe(join(root, 'sub', 'file.txt'));
  });

  it('still rejects ../escape when root exists', () => {
    let err: unknown;
    try { ensurePathWithin(root, '../escape'); } catch (e) { err = e; }
    expect(err).toBeInstanceOf(Error);
    expect(isPathEscapeError(err)).toBe(true);
  });
});

describe('isProtectedWorkspaceDir (構造フォルダの削除ガード)', () => {
  let wsRoot: string;
  beforeEach(() => { wsRoot = fs.mkdtempSync(join(os.tmpdir(), 'protect-dir-')); });
  afterEach(() => { fs.rmSync(wsRoot, { recursive: true, force: true }); });

  it('protects every top-level structure dir', () => {
    for (const d of ['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills', 'subtasks']) {
      expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, d))).toBe(true);
    }
  });

  it('does not protect nested dirs or user-created top-level dirs', () => {
    expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, 'output', 'sub'))).toBe(false);
    expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, 'myfolder'))).toBe(false);
  });

  it('does not protect the workspace root itself, and rejects escapes', () => {
    expect(isProtectedWorkspaceDir(wsRoot, wsRoot)).toBe(false);
    expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, '..', 'output'))).toBe(false);
  });
});

describe('collectZipFiles (フォルダの再帰 zip 収集)', () => {
  let zroot: string;
  beforeEach(() => { zroot = fs.mkdtempSync(join(os.tmpdir(), 'collect-zip-')); });
  afterEach(() => { fs.rmSync(zroot, { recursive: true, force: true }); });

  it('returns a single entry for a file (name relative to root)', () => {
    fs.writeFileSync(join(zroot, 'a.txt'), 'x');
    expect(collectZipFiles(zroot, join(zroot, 'a.txt')).map(g => g.entryName)).toEqual(['a.txt']);
  });

  it('recurses into a directory and keeps the folder structure in entry names', () => {
    fs.mkdirSync(join(zroot, 'd', 'e'), { recursive: true });
    fs.writeFileSync(join(zroot, 'd', 'top.txt'), '1');
    fs.writeFileSync(join(zroot, 'd', 'e', 'deep.txt'), '2');
    const got = collectZipFiles(zroot, join(zroot, 'd')).map(g => g.entryName).sort();
    expect(got).toEqual(['d/e/deep.txt', 'd/top.txt']);
  });

  it('skips symlinks so a link cannot pull in content from outside', () => {
    fs.mkdirSync(join(zroot, 'd'), { recursive: true });
    fs.writeFileSync(join(zroot, 'secret.txt'), 's');
    try {
      fs.symlinkSync(join(zroot, 'secret.txt'), join(zroot, 'd', 'link.txt'));
    } catch {
      return; // symlink 不可な環境ではスキップ
    }
    expect(collectZipFiles(zroot, join(zroot, 'd')).map(g => g.entryName)).toEqual([]);
  });

  it('returns [] for a missing path', () => {
    expect(collectZipFiles(zroot, join(zroot, 'nope'))).toEqual([]);
  });
});

describe('readJobEvents', () => {
  it('returns [] when worktreePath is null', () => {
    const result = readJobEvents(null);
    expect(result).toEqual([]);
  });

  it('returns [] when logs/events.jsonl does not exist', () => {
    const tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'readJobEvents-'));
    try {
      const result = readJobEvents(tmpDir);
      expect(result).toEqual([]);
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  it('parses a valid events.jsonl file with multiple lines and blank lines', () => {
    const tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'readJobEvents-'));
    const logsDir = join(tmpDir, 'logs');
    fs.mkdirSync(logsDir, { recursive: true });

    // Create a valid events.jsonl with 2 ok events and 1 blank line
    const event1 = { v: 1, ts: '2026-06-29T10:00:00Z', seq: 1, eventId: 'e1', runId: 'r1', kind: 'ok' };
    const event2 = { v: 1, ts: '2026-06-29T10:00:01Z', seq: 2, eventId: 'e2', runId: 'r1', kind: 'ok' };
    const eventsContent = [
      JSON.stringify(event1),
      '',  // blank line
      JSON.stringify(event2),
    ].join('\n');

    fs.writeFileSync(join(logsDir, 'events.jsonl'), eventsContent, 'utf-8');

    try {
      const result = readJobEvents(tmpDir);
      expect(result).toHaveLength(2);
      expect(result[0]).toMatchObject({ eventId: 'e1', seq: 1 });
      expect(result[1]).toMatchObject({ eventId: 'e2', seq: 2 });
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  it('filters out events with kind !== "ok"', () => {
    const tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'readJobEvents-'));
    const logsDir = join(tmpDir, 'logs');
    fs.mkdirSync(logsDir, { recursive: true });

    // Create events with mixed kinds (the parseEventLine will return kind='err' for invalid JSON)
    const okEvent = { v: 1, ts: '2026-06-29T10:00:00Z', seq: 1, eventId: 'e1', runId: 'r1' };
    // Invalid JSON will result in parseEventLine returning kind='err'
    const eventsContent = [
      JSON.stringify(okEvent),
      'not valid json',  // this will be filtered out
      JSON.stringify(okEvent),
    ].join('\n');

    fs.writeFileSync(join(logsDir, 'events.jsonl'), eventsContent, 'utf-8');

    try {
      const result = readJobEvents(tmpDir);
      // Only the two valid JSON lines should be included (if parseEventLine returns ok for them)
      expect(result.length).toBeGreaterThanOrEqual(0);
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });
});
