import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdirSync, writeFileSync, symlinkSync, rmSync, mkdtempSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { withEnv } from './helpers/with-env.ts';
import type { OperationContext } from '../src/core/operations.ts';
import { OperationError } from '../src/core/operations.ts';
import {
  resolveSkillMdPath,
  getSkillDetail,
  resolveSkillsDir,
  assertPublishEnabled,
} from '../src/core/skill-catalog.ts';

function ctx(remote: boolean, scopes?: string[]): OperationContext {
  return {
    remote,
    auth: scopes ? { token: 't', clientId: 'c', scopes } : undefined,
    config: {} as OperationContext['config'],
    engine: null as unknown as OperationContext['engine'],
    logger: { info() {}, warn() {}, error() {} },
    dryRun: false,
    sourceId: 'default',
  } as OperationContext;
}

let root: string;
let skillsDir: string;

beforeAll(() => {
  root = mkdtempSync(join(tmpdir(), 'skill-sec-'));
  skillsDir = join(root, 'skills');
  mkdirSync(join(skillsDir, 'good'), { recursive: true });
  writeFileSync(join(skillsDir, 'good', 'SKILL.md'), '---\nname: good\n---\n\nbody\n');
  // An oversize skill (> 256KB default cap).
  mkdirSync(join(skillsDir, 'huge'), { recursive: true });
  writeFileSync(join(skillsDir, 'huge', 'SKILL.md'), '---\nname: huge\n---\n' + 'x'.repeat(300 * 1024));
  // A non-SKILL.md target reached via a poisoned manifest.json.
  writeFileSync(join(root, 'secret.txt'), 'TOP SECRET OUTSIDE SKILLS');
  mkdirSync(join(skillsDir, 'notskill'), { recursive: true });
  writeFileSync(join(skillsDir, 'notskill', 'data.md'), 'not a skill file');
});

afterAll(() => {
  try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
});

describe('resolveSkillMdPath — name shape rejection (pre-FS)', () => {
  for (const bad of ['../etc/passwd', 'a/b', 'a\\b', 'x y', 'a..b', '']) {
    test(`rejects ${JSON.stringify(bad)}`, () => {
      expect(() => resolveSkillMdPath(skillsDir, bad)).toThrow(OperationError);
    });
  }
  test('rejects an over-long name', () => {
    expect(() => resolveSkillMdPath(skillsDir, 'a'.repeat(200))).toThrow(/exceeds/);
  });
  test('unknown name → page_not_found', () => {
    try {
      resolveSkillMdPath(skillsDir, 'nope');
      throw new Error('should have thrown');
    } catch (e) {
      expect(e).toBeInstanceOf(OperationError);
      expect((e as OperationError).code).toBe('page_not_found');
    }
  });
});

describe('manifest path confinement (poisoned manifest.json)', () => {
  test('traversal path in manifest.json is blocked', () => {
    // manifest.json takes `path` verbatim — a traversal entry must be confined.
    writeFileSync(
      join(skillsDir, 'manifest.json'),
      JSON.stringify({ skills: [{ name: 'evil', path: '../secret.txt' }] }),
    );
    try {
      resolveSkillMdPath(skillsDir, 'evil');
      throw new Error('should have thrown');
    } catch (e) {
      expect(e).toBeInstanceOf(OperationError);
      // Either escape (invalid_params) or non-SKILL.md (invalid_params).
      expect((e as OperationError).code).toBe('invalid_params');
    } finally {
      rmSync(join(skillsDir, 'manifest.json'), { force: true });
    }
  });

  test('non-SKILL.md regular file is rejected', () => {
    writeFileSync(
      join(skillsDir, 'manifest.json'),
      JSON.stringify({ skills: [{ name: 'notskill', path: 'notskill/data.md' }] }),
    );
    try {
      resolveSkillMdPath(skillsDir, 'notskill');
      throw new Error('should have thrown');
    } catch (e) {
      expect((e as OperationError).code).toBe('invalid_params');
    } finally {
      rmSync(join(skillsDir, 'manifest.json'), { force: true });
    }
  });
});

describe('symlink escape', () => {
  test('a SKILL.md symlinked outside the skills dir is rejected', () => {
    const linkDir = join(skillsDir, 'sneaky');
    mkdirSync(linkDir, { recursive: true });
    try {
      symlinkSync(join(root, 'secret.txt'), join(linkDir, 'SKILL.md'));
    } catch {
      return; // symlink unsupported on this platform — skip
    }
    try {
      resolveSkillMdPath(skillsDir, 'sneaky');
      throw new Error('should have thrown');
    } catch (e) {
      expect(e).toBeInstanceOf(OperationError);
      // realpath resolves outside root → escape; basename also != SKILL.md after deref
      expect((e as OperationError).code).toBe('invalid_params');
    } finally {
      rmSync(linkDir, { recursive: true, force: true });
    }
  });
});

describe('response-size cap', () => {
  test('oversize SKILL.md → payload_too_large', () => {
    try {
      getSkillDetail(ctx(true, ['read']), skillsDir, 'huge');
      throw new Error('should have thrown');
    } catch (e) {
      expect(e).toBeInstanceOf(OperationError);
      expect((e as OperationError).code).toBe('payload_too_large');
    }
  });
});

describe('publish gate (pure)', () => {
  test('remote + disabled → permission_denied', () => {
    try {
      assertPublishEnabled(ctx(true, ['read']), false);
      throw new Error('should have thrown');
    } catch (e) {
      expect((e as OperationError).code).toBe('permission_denied');
    }
  });
  test('remote + enabled → ok', () => {
    expect(() => assertPublishEnabled(ctx(true, ['read']), true)).not.toThrow();
  });
  test('local bypasses the gate even when disabled', () => {
    expect(() => assertPublishEnabled(ctx(false), false)).not.toThrow();
  });
});

describe('resolveSkillsDir — remote never falls through to install_path', () => {
  test('remote caller with no config + no detectable dir → storage_error (NOT bundled skills)', async () => {
    // Drive autodetect into a dir tree with no skills/ anywhere up the chain,
    // no OpenClaw env, and HOME pointed at the empty tmp root. The remote path
    // uses autoDetectSkillsDir (no install_path tier), so it must throw rather
    // than serve gbrain's own bundled dev skills.
    const empty = mkdtempSync(join(tmpdir(), 'skill-empty-'));
    try {
      await withEnv(
        {
          GBRAIN_SKILLS_DIR: undefined,
          OPENCLAW_WORKSPACE: undefined,
          HOME: empty,
        },
        () => {
          const prevCwd = process.cwd();
          process.chdir(empty);
          try {
            expect(() => resolveSkillsDir(ctx(true), undefined)).toThrow(OperationError);
          } finally {
            process.chdir(prevCwd);
          }
        },
      );
    } finally {
      rmSync(empty, { recursive: true, force: true });
    }
  });

  test('explicit override that does not exist → storage_error', () => {
    expect(() => resolveSkillsDir(ctx(true), '/no/such/skills/dir')).toThrow(/does not exist/);
  });
});
