import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync, readFileSync } from 'fs';
import { createHash, randomUUID } from 'crypto';
import { join, isAbsolute, resolve, relative } from 'path';
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { VALID_SKILL_NAME } from '../skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../skills-scanner.js';
import { logger } from '../../logger.js';
import matter from 'gray-matter';

/**
 * Extract the frontmatter `name` from SKILL.md text ('' when missing or
 * unparseable). The skill catalog lists skills by this name, so installs must
 * keep it in sync with the folder name (see the mismatch guard in InstallSkill).
 */
function frontmatterNameOf(raw: string): string {
  try {
    const { data } = matter(raw);
    return typeof data?.name === 'string' ? data.name : '';
  } catch {
    return '';
  }
}

// ── Injected deps (server.ts / worker.ts call setSkillToolDeps) ─────────────

export interface SkillToolDeps {
  auditLog?: (action: string, detail: object, jobId?: string | null) => void;
  userFolderRoot: string;
}

let _deps: SkillToolDeps | null = null;

export function setSkillToolDeps(deps: SkillToolDeps | null): void {
  _deps = deps;
}

/**
 * Resolves the folder `(root, leaf)` that user-scope skills should read/write,
 * mirroring AGENTS.md / memory (user-folder.ts folderTarget). When the job
 * belongs to a (case) workspace, the worker wires `ctx.folderContext`
 * (`{rootDir, leafId}`) → skills land in `data/spaces/{id}/skills/` and are
 * shared with members. Without folderContext (gitea-issue / legacy / personal)
 * we fall back to the caller's personal folder — byte-identical to before
 * (the personal root is taken from the catalog so it matches getUserSkillDir).
 *
 * `system` scope is unaffected: that path uses catalog.getSystemDir() directly.
 */
function skillFolderTarget(ctx: ToolContext): { root: string; leaf: string } {
  if (ctx.folderContext) {
    return { root: ctx.folderContext.rootDir, leaf: ctx.folderContext.leafId };
  }
  const personalRoot = ctx.skillCatalog?.getUserRoot() ?? _deps?.userFolderRoot ?? './data/users';
  return { root: personalRoot, leaf: ctx.userId ?? 'local' };
}

/**
 * `sourcePath` の中に workspace 実パスの末尾セグメント列（2 つ以上）が現れたら、
 * その直後からを workspace 相対パスとして返す。見つからなければ null。
 * 例: workspace=…/space/{id}/files, sourcePath="data/space/{id}/files/output/x"
 * → "output/x"。1 セグメントだけの一致（例: "files"）は誤アンカーしやすいので
 * 対象外。".." を含むパスは再解釈しない（通常の解決に任せて拒否させる）。
 */
function reanchorToWorkspace(workspacePath: string, sourcePath: string): string | null {
  const wsSegs = resolve(workspacePath).split('/').filter(Boolean);
  const srcSegs = sourcePath.split(/[\\/]+/).filter(s => s && s !== '.');
  if (srcSegs.includes('..')) return null;
  for (let len = Math.min(wsSegs.length, srcSegs.length); len >= 2; len--) {
    const tail = wsSegs.slice(wsSegs.length - len);
    for (let i = 0; i + len <= srcSegs.length; i++) {
      if (tail.every((seg, j) => seg === srcSegs[i + j])) {
        const rest = srcSegs.slice(i + len);
        return rest.length > 0 ? rest.join('/') : null;
      }
    }
  }
  return null;
}

// ── Skill materialization ───────────────────────────────────────────────────
// When the agent ReadSkill's a directory-based skill, copy its files into the
// task workspace (`{workspace}/skills/{name}/`) so its scripts are usable in
// every Bash sandbox mode (the skill store lives outside the workspace and is
// only bind-mounted in the bwrap path). The copy is rw. A signature of the
// source dir is stored alongside the copy; when the source changes (e.g. the
// skill was updated by another task) the next ReadSkill replaces the copy —
// including any local edits. An unchanged source leaves the copy untouched.

const SKILL_MATERIALIZE_MAX_BYTES = 50 * 1024 * 1024; // 50MB
const SKILL_SIGNATURE_SUFFIX = '.skill-src-signature';

/** Total size of `dir` (skipping symlinks), or null once it exceeds `cap`. */
function dirSizeCapped(dir: string, cap: number): number | null {
  let total = 0;
  const stack = [dir];
  while (stack.length > 0) {
    const d = stack.pop()!;
    let entries: string[];
    try { entries = readdirSync(d); } catch { continue; }
    for (const e of entries) {
      const p = join(d, e);
      let st;
      try { st = lstatSync(p); } catch { continue; }
      if (st.isSymbolicLink()) continue;
      if (st.isDirectory()) { stack.push(p); continue; }
      total += st.size;
      if (total > cap) return null;
    }
  }
  return total;
}

interface MaterializeResult { ok: boolean; relPath: string; note?: string }

/**
 * Signature of a skill source dir: sha1 over each file's relative path and
 * content bytes, symlinks skipped — the same file set `materializeSkill`
 * copies. Content-based (not size/mtime) so a re-synced store with identical
 * bytes does not count as a change. Source dirs are capped at 50MB and
 * ReadSkill is infrequent, so hashing the bytes is cheap enough.
 */
function skillSourceSignature(srcDir: string): string {
  const files: string[] = [];
  const stack = [srcDir];
  while (stack.length > 0) {
    const d = stack.pop()!;
    let entries: string[];
    try { entries = readdirSync(d); } catch { continue; }
    for (const e of entries) {
      const p = join(d, e);
      let st;
      try { st = lstatSync(p); } catch { continue; }
      if (st.isSymbolicLink()) continue;
      if (st.isDirectory()) { stack.push(p); continue; }
      files.push(relative(srcDir, p));
    }
  }
  files.sort();
  const hash = createHash('sha1');
  for (const rel of files) {
    // Length-prefix each field so path/content boundaries stay unambiguous.
    hash.update(`${rel.length}:${rel}:`);
    let content: Buffer;
    try { content = readFileSync(join(srcDir, rel)); } catch { content = Buffer.from('<unreadable>'); }
    hash.update(`${content.length}:`);
    hash.update(content);
  }
  return hash.digest('hex');
}

/**
 * Copy a skill's source dir into `{workspace}/skills/{name}/`. A stale copy
 * (source changed since it was made, or made before signature tracking) is
 * replaced wholesale; an up-to-date copy is left untouched. The copy is staged
 * in a temp dir first so a failed refresh keeps the previous copy usable, and
 * the signature lives in a sidecar (`skills/.{name}.skill-src-signature`) so
 * it can never clobber a file the skill itself owns. Skill names are
 * `[a-z0-9_-]` only, so the dot-prefixed sidecar/tmp names cannot collide
 * with a real skill dir.
 */
export function materializeSkill(srcDir: string, workspacePath: string, skillName: string): MaterializeResult {
  const relPath = `skills/${skillName}`;
  const skillsRoot = join(workspacePath, 'skills');
  const dest = join(skillsRoot, skillName);
  const sigPath = join(skillsRoot, `.${skillName}${SKILL_SIGNATURE_SUFFIX}`);
  // Cap check first: the signature reads every source byte, so an oversized
  // dir must be rejected before it gets loaded into memory.
  if (dirSizeCapped(srcDir, SKILL_MATERIALIZE_MAX_BYTES) === null) {
    return { ok: false, relPath, note: 'skill exceeds 50MB copy limit' };
  }
  const signature = skillSourceSignature(srcDir);
  if (existsSync(dest)) {
    let existing: string | null = null;
    try { existing = readFileSync(sigPath, 'utf-8').trim(); } catch { /* legacy copy without signature → refresh */ }
    if (existing === signature) return { ok: true, relPath }; // copy is up to date
  }
  // Per-call unique tmp: within one process ReadSkill runs synchronously, but
  // another process sharing the workspace (dev + prod, multi-instance) may be
  // materializing the same skill concurrently — never touch its tmp.
  const tmp = join(skillsRoot, `.${skillName}.tmp-${randomUUID()}`);
  try {
    mkdirSync(skillsRoot, { recursive: true });
    sweepStaleTmpDirs(skillsRoot, skillName);
    cpSync(srcDir, tmp, {
      recursive: true,
      dereference: false,
      // Skip symlinks so a link inside the skill cannot point outside the workspace.
      filter: (src) => { try { return !lstatSync(src).isSymbolicLink(); } catch { return false; } },
    });
    // Swap in only after the whole copy succeeded. The old copy is moved
    // aside (not deleted) so a failed swap can put it back.
    const backup = join(skillsRoot, `.${skillName}.tmp-bak-${randomUUID()}`);
    let haveBackup = false;
    if (existsSync(dest)) { renameSync(dest, backup); haveBackup = true; }
    try {
      renameSync(tmp, dest);
    } catch (e) {
      if (haveBackup && !existsSync(dest)) {
        try { renameSync(backup, dest); } catch { /* leave the backup for the sweep */ }
      }
      throw e;
    }
    if (haveBackup) { try { rmSync(backup, { recursive: true, force: true }); } catch { /* sweep will get it */ } }
    writeFileSync(sigPath, `${signature}\n`, 'utf-8');
    return { ok: true, relPath };
  } catch (e) {
    try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
    // A concurrent materialize may have won the swap — then the copy is fine.
    try {
      if (existsSync(dest) && readFileSync(sigPath, 'utf-8').trim() === signature) {
        return { ok: true, relPath };
      }
    } catch { /* fall through to the failure result */ }
    return { ok: false, relPath, note: `copy failed: ${(e as Error).message}` };
  }
}

const SKILL_TMP_STALE_MS = 15 * 60_000;

/** Remove `.{name}.tmp-*` leftovers from crashed runs; keep recent ones (a copy may be in flight). */
function sweepStaleTmpDirs(skillsRoot: string, skillName: string): void {
  let entries: string[];
  try { entries = readdirSync(skillsRoot); } catch { return; }
  const prefix = `.${skillName}.tmp-`;
  for (const e of entries) {
    if (!e.startsWith(prefix)) continue;
    const p = join(skillsRoot, e);
    try {
      if (Date.now() - lstatSync(p).mtimeMs > SKILL_TMP_STALE_MS) {
        rmSync(p, { recursive: true, force: true });
      }
    } catch { /* best effort */ }
  }
}

// ---------------------------------------------------------------------------
// Tool definitions
// ---------------------------------------------------------------------------
export const TOOL_DEFS: Record<string, ToolDef> = {
  InstallSkill: {
    type: 'function',
    function: {
      name: 'InstallSkill',
      description: 'スキル（参照知識: 手順書・ガイド）をインストール。Piece（実行テンプレート）とは異なる。通常は content に SKILL.md 全文を渡し、workspace 内にディレクトリを構築済みの場合のみ sourcePath を使う。詳細は ReadToolDoc({ name: "InstallSkill" })。',
      parameters: {
        type: 'object',
        properties: {
          name: { type: 'string', description: 'スキル名 ([a-z0-9_-] のみ)。SKILL.md frontmatter の name: と完全一致必須' },
          content: { type: 'string', description: 'SKILL.md の全文 (YAML frontmatter + 本文)。通常はこちらを使用' },
          sourcePath: { type: 'string', description: 'workspace 相対のスキルディレクトリパス（例: "output/my-skill"。SKILL.md + scripts/ 等を含むフォルダごと登録する場合のみ）。workspace 外のパスは拒否される' },
          scope: { type: 'string', enum: ['system', 'user'], description: 'system=全ユーザー共有 (admin only), user=個人' },
        },
        required: ['name', 'scope'],
      },
    },
  },
  ReadSkill: {
    type: 'function',
    function: {
      name: 'ReadSkill',
      description: 'スキル（参照知識: 手順書・ガイド・規約）の全文を取得する（ディレクトリ型は scripts 等を workspace の skills/ にコピーして実行可能にする）。一覧はシステムプロンプトの Skills Index を参照。詳細は ReadToolDoc({ name: "ReadSkill" })。',
      parameters: {
        type: 'object',
        properties: {
          name: { type: 'string', description: 'スキル名 (Skills Index に表示されている名前)' },
        },
        required: ['name'],
      },
    },
  },
  ListSkills: {
    type: 'function',
    function: {
      name: 'ListSkills',
      description: 'インストール済みスキル（参照知識）の一覧を返す。Piece（実行テンプレート）の一覧は ListPieces を使うこと。詳細は ReadToolDoc({ name: "ListSkills" })。',
      parameters: {
        type: 'object',
        properties: {},
        required: [],
      },
    },
  },
};

// ── InstallSkill implementation ──────────────────────────────────────────────

const MAX_FILE_COUNT = 100;
const MAX_DEPTH = 3;
const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5MB

function executeInstallSkill(
  input: Record<string, unknown>,
  ctx: ToolContext,
): ToolResult {
  const skillName = input['name'] as string | undefined;
  const content = input['content'] as string | undefined;
  const sourcePath = input['sourcePath'] as string | undefined;
  const scope = input['scope'] as string | undefined;

  if (!skillName || typeof skillName !== 'string') {
    return { output: 'InstallSkill: "name" parameter is required', isError: true };
  }
  if (!content && !sourcePath) {
    return { output: 'InstallSkill: either "content" or "sourcePath" is required', isError: true };
  }
  if (content && sourcePath) {
    return { output: 'InstallSkill: specify either "content" or "sourcePath", not both', isError: true };
  }
  if (scope !== 'system' && scope !== 'user') {
    return { output: 'InstallSkill: "scope" must be "system" or "user"', isError: true };
  }
  if (!VALID_SKILL_NAME.test(skillName)) {
    return { output: `InstallSkill: invalid skill name "${skillName}". Only [a-z0-9_-] allowed.`, isError: true };
  }
  if (scope === 'system' && ctx.userRole !== 'admin') {
    return { output: 'InstallSkill: system-scope install requires admin role', isError: true };
  }

  const catalog = ctx.skillCatalog;
  if (!catalog) {
    return { output: 'InstallSkill: skill catalog not available', isError: true };
  }
  const userId = ctx.userId ?? 'local';
  // user-scope skills resolve to the job's folder (space → shared, else personal).
  const { root: folderRoot, leaf: folderLeaf } = skillFolderTarget(ctx);

  // Count check (user scope only) — counts skills in the resolved folder.
  if (scope === 'user') {
    const userSkillCount = catalog.getForFolder(folderRoot, folderLeaf).filter(s => s.source === 'user').length;
    if (userSkillCount >= 50) {
      return { output: `InstallSkill: user skill limit reached (${userSkillCount}/50)`, isError: true };
    }
  }

  // --- sourcePath mode: copy directory from workspace ---
  if (sourcePath) {
    if (typeof sourcePath !== 'string') {
      return { output: 'InstallSkill: "sourcePath" must be a string', isError: true };
    }
    let realWorkspace: string;
    try {
      realWorkspace = realpathSync(ctx.workspacePath);
    } catch (e) {
      return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true };
    }
    let realSource: string;
    try {
      // Resolve relative paths against the workspace root, mirroring
      // resolveAndGuard() used by Read/Write/Edit. Without this, a natural
      // workspace-relative path (e.g. "output/my-skill") would resolve against
      // the orchestrator's process cwd and get wrongly rejected as outside the
      // workspace. Kept inside try so any path-resolution error returns a
      // friendly result instead of crashing the task.
      const absSource = isAbsolute(sourcePath) ? sourcePath : resolve(ctx.workspacePath, sourcePath);
      realSource = realpathSync(absSource);
    } catch (e) {
      // Agents don't know the workspace's host-side absolute path, so they
      // often reconstruct a plausible-looking one (e.g. "data/space/{id}/files/
      // output/x"). If the given path embeds the workspace's trailing segments,
      // re-anchor: treat everything after that run as workspace-relative.
      const reanchored = reanchorToWorkspace(ctx.workspacePath, sourcePath);
      try {
        if (!reanchored) throw new Error('no re-anchor candidate');
        realSource = realpathSync(resolve(ctx.workspacePath, reanchored));
      } catch {
        return { output: `InstallSkill: sourcePath does not exist or is not accessible. Specify a workspace-relative path (e.g. "output/my-skill"), or use "content" to pass SKILL.md text directly.`, isError: true };
      }
    }
    if (!realSource.startsWith(realWorkspace + '/')) {
      return { output: 'InstallSkill: sourcePath must be inside the task workspace. Use "content" parameter to pass SKILL.md text directly.', isError: true };
    }
    if (!existsSync(join(realSource, 'SKILL.md'))) {
      return { output: `InstallSkill: SKILL.md not found in ${sourcePath}`, isError: true };
    }

    // The catalog lists skills by their SKILL.md frontmatter `name`. If it
    // differs from the install name the skill gets stored under one name but
    // listed under another — the UI can then never edit or delete it.
    let srcFmName = '';
    try {
      srcFmName = frontmatterNameOf(readFileSync(join(realSource, 'SKILL.md'), 'utf-8'));
    } catch {
      srcFmName = '';
    }
    if (!srcFmName || !VALID_SKILL_NAME.test(srcFmName)) {
      return { output: 'InstallSkill: SKILL.md must start with YAML frontmatter containing a valid "name:" ([a-z0-9_-]). Without it the skill will not appear in the skill list.', isError: true };
    }
    if (srcFmName !== skillName) {
      return { output: `InstallSkill: SKILL.md frontmatter name "${srcFmName}" does not match the install name "${skillName}". Pass name: "${srcFmName}" (or fix the frontmatter) so they match.`, isError: true };
    }

    const stats = getDirStats(realSource);
    if (stats.fileCount > MAX_FILE_COUNT) {
      return { output: `InstallSkill: directory contains ${stats.fileCount} files (max ${MAX_FILE_COUNT})`, isError: true };
    }
    if (stats.maxDepth > MAX_DEPTH) {
      return { output: `InstallSkill: directory depth ${stats.maxDepth} exceeds max ${MAX_DEPTH}`, isError: true };
    }
    if (stats.totalBytes > MAX_TOTAL_BYTES) {
      return { output: `InstallSkill: directory size ${(stats.totalBytes / 1024 / 1024).toFixed(1)}MB exceeds max 5MB`, isError: true };
    }

    const findings = scanSkillDirectory(realSource);
    const severity = maxSeverity(findings);
    if (severity === 'high') {
      const details = findings.filter(f => f.severity === 'high').slice(0, 5)
        .map(f => `  - [${f.pattern}] ${f.match} (line ${f.line})`).join('\n');
      return { output: `InstallSkill: blocked by security scan:\n${details}`, isError: true };
    }

    const targetBase = scope === 'system' ? catalog.getSystemDir() : catalog.getSkillDirForFolder(folderRoot, folderLeaf);
    const target = join(targetBase, skillName);
    const tmpTarget = target + '.tmp-' + Date.now();

    try {
      mkdirSync(targetBase, { recursive: true });
      cpSync(realSource, tmpTarget, { recursive: true });
      if (existsSync(target)) rmSync(target, { recursive: true, force: true });
      const flatPath = join(targetBase, `${skillName}.md`);
      if (existsSync(flatPath)) unlinkSync(flatPath);
      renameSync(tmpTarget, target);
    } catch (e) {
      try { rmSync(tmpTarget, { recursive: true, force: true }); } catch {}
      return { output: `InstallSkill: install failed: ${e}`, isError: true };
    }

    if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidateFolder(folderRoot, folderLeaf); }
    _deps?.auditLog?.('skill_installed', { skillName, scope, userId, folderRoot, folderLeaf, format: 'directory', fileCount: stats.fileCount }, ctx.taskId ?? null);

    const mediumFindings = findings.filter(f => f.severity === 'medium');
    let msg = `InstallSkill: installed "${skillName}" to ${scope} scope (${stats.fileCount} files)`;
    if (mediumFindings.length > 0) {
      msg += `\n\nWarnings: ${mediumFindings.length} medium-severity findings`;
    }
    return { output: msg, isError: false };
  }

  // --- content mode: create {name}/SKILL.md ---
  const MAX_BYTES = 64 * 1024;
  if (Buffer.byteLength(content!, 'utf-8') > MAX_BYTES) {
    return { output: `InstallSkill: content exceeds 64 KB limit`, isError: true };
  }

  // Same mismatch guard as sourcePath mode: the frontmatter name must exist
  // and match the install name, or the skill becomes unmanageable from the UI.
  const contentFmName = frontmatterNameOf(content!);
  if (!contentFmName || !VALID_SKILL_NAME.test(contentFmName)) {
    return { output: 'InstallSkill: content must start with YAML frontmatter containing a valid "name:" ([a-z0-9_-]). Without it the skill will not appear in the skill list.', isError: true };
  }
  if (contentFmName !== skillName) {
    return { output: `InstallSkill: content frontmatter name "${contentFmName}" does not match the install name "${skillName}". Pass name: "${contentFmName}" (or fix the frontmatter) so they match.`, isError: true };
  }

  const findings = scanSkillContent(content!);
  const severity = maxSeverity(findings);
  if (severity === 'high') {
    const details = findings.filter(f => f.severity === 'high')
      .map(f => `  - [high] ${f.pattern}: "${f.match}" (line ${f.line})`).join('\n');
    return { output: `InstallSkill: blocked by security scan:\n${details}`, isError: true };
  }

  const targetDir = scope === 'system' ? catalog.getSystemDir() : catalog.getSkillDirForFolder(folderRoot, folderLeaf);
  const skillDir = join(targetDir, skillName);
  const tmpDir = join(targetDir, `.${skillName}.tmp.${Date.now()}`);

  try {
    mkdirSync(tmpDir, { recursive: true });
    writeFileSync(join(tmpDir, 'SKILL.md'), content!, { encoding: 'utf-8', mode: 0o600 });
    if (existsSync(skillDir)) rmSync(skillDir, { recursive: true, force: true });
    const flatPath = join(targetDir, `${skillName}.md`);
    if (existsSync(flatPath)) unlinkSync(flatPath);
    renameSync(tmpDir, skillDir);
  } catch (err) {
    try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
    return { output: `InstallSkill: write failed: ${(err as Error).message}`, isError: true };
  }

  if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidateFolder(folderRoot, folderLeaf); }
  _deps?.auditLog?.('skill_installed', { skillName, scope, userId, folderRoot, folderLeaf, scanSeverity: severity, findingsCount: findings.length }, ctx.taskId ?? null);

  const mediumFindings = findings.filter(f => f.severity === 'medium');
  let msg = `InstallSkill: installed "${skillName}" to ${scope} scope`;
  if (mediumFindings.length > 0) {
    const warnings = mediumFindings.map(f => `  - [medium] ${f.pattern}: "${f.match}" (line ${f.line})`).join('\n');
    msg += `\n\nWarnings (medium severity):\n${warnings}`;
  }
  return { output: msg, isError: false };
}

// ---------------------------------------------------------------------------
// Helpers for InstallSkillFromDir
// ---------------------------------------------------------------------------

interface DirStats {
  fileCount: number;
  totalBytes: number;
  maxDepth: number;
}

function getDirStats(dir: string, depth: number = 0): DirStats {
  const stats: DirStats = { fileCount: 0, totalBytes: 0, maxDepth: depth };
  let entries: string[];
  try {
    entries = readdirSync(dir);
  } catch {
    return stats;
  }
  for (const entry of entries) {
    const fullPath = join(dir, entry);
    let st;
    try {
      st = lstatSync(fullPath);
    } catch {
      continue;
    }
    if (st.isSymbolicLink()) continue;
    if (st.isDirectory()) {
      const sub = getDirStats(fullPath, depth + 1);
      stats.fileCount += sub.fileCount;
      stats.totalBytes += sub.totalBytes;
      if (sub.maxDepth > stats.maxDepth) stats.maxDepth = sub.maxDepth;
    } else if (st.isFile()) {
      stats.fileCount++;
      stats.totalBytes += st.size;
    }
  }
  return stats;
}

// ---------------------------------------------------------------------------
// Tool execution
// ---------------------------------------------------------------------------
export function executeSkillTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): ToolResult | null {
  if (name === 'InstallSkill') {
    return executeInstallSkill(input, ctx);
  }

  if (name === 'ListSkills') {
    const catalog = ctx.skillCatalog;
    if (!catalog) return { output: 'Error: skill catalog not available', isError: true };
    const { root, leaf } = skillFolderTarget(ctx);
    const entries = catalog.getForFolder(root, leaf);
    if (entries.length === 0) return { output: 'No skills installed.', isError: false };
    const lines = entries.map(e => {
      const dirSuffix = 'dirPath' in e && e.dirPath ? ' (has scripts/)' : '';
      return `- **${e.name}** [${e.source}]: ${e.description}${dirSuffix}`;
    });
    return { output: lines.join('\n'), isError: false };
  }

  if (name !== 'ReadSkill') return null;

  const skillName = input['name'] as string;
  if (!skillName) {
    return { output: 'Error: name is required', isError: true };
  }

  const catalog = ctx.skillCatalog;
  if (!catalog) {
    return { output: 'Error: skill catalog not available', isError: true };
  }

  const { root, leaf } = skillFolderTarget(ctx);
  const result = catalog.getSkillContentForFolder(skillName, root, leaf);
  if (result === null) {
    const available = catalog.getForFolder(root, leaf).map(s => s.name).join(', ');
    return {
      output: `Skill "${skillName}" not found. Available skills: ${available || '(none)'}`,
      isError: true,
    };
  }

  if (result.dirPath) {
    const m = materializeSkill(result.dirPath, ctx.workspacePath, skillName);
    const loc = m.ok
      ? `このスキルのファイルは workspace の \`${m.relPath}/\` に配置しました（例: \`${m.relPath}/scripts/...\`）。スクリプトはこの相対パスで実行できます。`
      : `（注: スキルのファイルを workspace にコピーできませんでした: ${m.note}。SKILL.md の手順は以下を参照）`;
    return { output: `${loc}\n\n${result.content}`, isError: false };
  }
  return { output: result.content, isError: false };
}

