import express, { Router } from 'express';
import { dirname, join, extname, basename, relative } from 'node:path';
import {
  mkdirSync, readdirSync, statSync, readFileSync, openSync, writeSync,
  closeSync, unlinkSync, rmSync, writeFileSync, renameSync, existsSync,
} from 'node:fs';
import AdmZip from 'adm-zip';
import { spaceFilesDir } from '../spaces/paths.js';
import { canEditInSpace } from './visibility.js';
import {
  ensurePathWithin,
  isPathEscapeError,
  isNotFoundError,
  serializeLocalFileEntry,
  setUntrustedFileResponseHeaders,
  isProtectedWorkspaceDir,
  collectZipFiles,
} from './local-api-helpers.js';
import { logger } from '../logger.js';
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';
import { viewerOf } from './space-viewer.js';
import type { SpaceApiDeps } from './space-api.js';

/**
 * Space ファイル窓ルート（/:id/files/*）を router に登録する。
 * createSpaceApi（space-api.ts）から分離した（巨大関数分割）。挙動は移設のみで等価。
 * 閉じ込めは spaceFilesDir(worktreeDir, id) を root にした ensurePathWithin で行う
 * （task 版 local-files-api.ts と同一）。
 */
export function registerSpaceFilesRoutes(router: Router, deps: SpaceApiDeps): void {
  const { repo, worktreeDir } = deps;
  const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });

  // ─── ファイル窓（スペースの永続ワークスペース {worktreeDir}/space/{id}/files）────────
  //
  // タスク版（local-files-api.ts）の section='space' に相当する経路を、スペース id で
  // キーした形で提供する。閉じ込めは spaceFilesDir(worktreeDir, id) を root にした
  // ensurePathWithin（realpath ではなく resolve ベースの正規化ガード。task の files API と同一）で行う。
  // 可視性は getSpace({viewer}) が null を返したら 404（owner/org/public の判定を repo に委譲）。

  // 一覧
  router.get('/:id/files', async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const rootDir = spaceFilesDir(worktreeDir, space.id);
      const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      mkdirSync(rootDir, { recursive: true });
      const dirPath = ensurePathWithin(rootDir, relativeDir);
      const entries = readdirSync(dirPath, { withFileTypes: true })
        // 計画5 (Phase C): 成果物だけの綺麗なツリーにするため、実行ログ (logs)・
        // 競合台帳 (.conflict)・全ドットファイルをファイル窓から除外する。
        .filter((entry) => entry.name !== 'logs' && entry.name !== '.conflict' && !entry.name.startsWith('.'))
        .map((entry) => {
          const stat = statSync(join(dirPath, entry.name));
          return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
        });
      res.json({ basePath: 'space', path: relativeDir, entries });
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      if (isNotFoundError(err)) { logger.debug(`[space-api] files list not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
      logger.error(`[space-api] files list error: ${err}`);
      res.status(500).json({ error: 'Failed to list files' });
    }
  });

  // テキスト本文（プレビュー用）
  router.get('/:id/files/content', async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      const rootDir = spaceFilesDir(worktreeDir, space.id);
      const filePath = ensurePathWithin(rootDir, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
      setUntrustedFileResponseHeaders(res);
      res.setHeader('Content-Type', 'text/plain; charset=utf-8');
      res.send(readFileSync(filePath, 'utf-8'));
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      if (isNotFoundError(err)) { logger.debug(`[space-api] file content not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
      logger.error(`[space-api] file content error: ${err}`);
      res.status(500).json({ error: 'Failed to read file' });
    }
  });

  // バイナリ配信 / HTML アプリ実行（trusted=1）
  router.get('/:id/files/raw', async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      const rootDir = spaceFilesDir(worktreeDir, space.id);
      const filePath = ensurePathWithin(rootDir, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
      // trusted=1 で CSP sandbox を外し、生成 HTML をアプリ origin 上でアプリとして実行させる。
      // 信頼モデルは「スペースのオーナーは信頼できる同僚」（社内 Gitea-org ツール）。
      // 手前の getSpace({viewer}) で可視性（private=owner+admin / org / public）を通過した
      // 閲覧者には trusted 版を配る。受容するトレードオフ: オーナーの無サンドボックス HTML は
      // 閲覧者のセッションで動くため、悪意あるオーナーは閲覧者になりすませる（オーナーは信頼前提）。
      // ハードフロア: 未認証（実 req.user なし）には trusted を渡さない＝共有リンクはまず
      // ログインを経由する。viewerOf は authActive 時に synthetic admin を返しうるので、
      // ここは viewer ではなく実認証ユーザー (req.user) の有無で判定する。
      // 認証 OFF の単一運用者モードでは唯一の主体が全スペースの owner（self-XSS のみ）。
      const trustedAllowed = deps.authActive ? !!(req as { user?: Express.User }).user : true;
      const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
      if (!trustedHtml) setUntrustedFileResponseHeaders(res);
      res.type(extname(filePath) || 'application/octet-stream');
      res.send(readFileSync(filePath));
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      if (isNotFoundError(err)) { logger.debug(`[space-api] file raw not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
      logger.error(`[space-api] file raw error: ${err}`);
      res.status(500).json({ error: 'Failed to read raw file' });
    }
  });

  // Excel / PowerPoint プレビュー（変換して JSON で返す）。閲覧操作なので getSpace 可視性ゲートのみ。
  // Excel→シートのセル配列、PPTX→スライド画像(PNG data URL)。soffice 未導入なら 503。
  router.get('/:id/files/office-preview', async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) return res.status(400).json({ error: 'path is required' });
      const rootDir = spaceFilesDir(worktreeDir, space.id);
      const filePath = ensurePathWithin(rootDir, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
      await sendOfficePreview(res, filePath, basename(filePath));
    } catch (err) {
      handleOfficePreviewError(res, err);
    }
  });

  // アップロード（owner / admin / member.role∈{owner,editor}）。共有スペースは協働モデルなので
  // 行 owner 限定にはしないが、viewer ロールのメンバーは read のみで書き込み不可。
  // base64-JSON 方式（添付と同形、multipart dep 無し）。同名衝突は `{stem} (N){ext}` に
  // O_EXCL で確保してリネーム（上書き禁止）。path は ensurePathWithin で spaceFilesDir に封じ込め。
  router.post('/:id/files/upload', jsonParser, async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
      if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
        return res.status(403).json({ error: 'forbidden' });
      }

      const files = req.body?.files;
      if (!Array.isArray(files) || files.length === 0) {
        return res.status(400).json({ error: 'files must be a non-empty array' });
      }
      const reqPath = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      const filesDir = spaceFilesDir(worktreeDir, space.id);

      const uploaded: { name: string; path: string }[] = [];
      for (const file of files) {
        const safeName = String(file?.name ?? '').replace(/[\\/]/g, '_');
        if (!safeName) return res.status(400).json({ error: 'file name is required' });
        const rel = join(reqPath, safeName);
        const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
        mkdirSync(dirname(abs), { recursive: true });

        const buf = Buffer.from(String(file?.contentBase64 ?? ''), 'base64');

        // 衝突回避: 既存なら `{stem} (N){ext}` を O_EXCL で予約して書く（上書き禁止）。
        const ext = extname(safeName);
        const stem = basename(safeName, ext);
        let candidateName = safeName;
        let candidateAbs = abs;
        let fd: number | undefined;
        for (let n = 1; ; n++) {
          if (n > 1) {
            candidateName = `${stem} (${n})${ext}`;
            candidateAbs = ensurePathWithin(filesDir, join(reqPath, candidateName));
          }
          try {
            fd = openSync(candidateAbs, 'wx'); // O_EXCL: 既存なら EEXIST
            break;
          } catch (e) {
            if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
            throw e;
          }
        }
        try {
          writeSync(fd, buf);
        } finally {
          closeSync(fd);
        }

        const writtenRel = reqPath ? `${reqPath}/${candidateName}` : candidateName;
        uploaded.push({ name: candidateName, path: writtenRel });
      }

      res.json({ uploaded });
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      logger.error(`[space-api] file upload error: ${err}`);
      res.status(500).json({ error: 'Failed to upload files' });
    }
  });

  // 削除（owner / admin / member.role∈{owner,editor}）。viewer ロールは read のみで削除不可。
  // body は { paths: string[] }（複数選択）または単一 { path: string }。各パスは
  // ensurePathWithin で spaceFilesDir に封じ込め（traversal は throw → 400）、ファイルのみ
  // 対象（ディレクトリはスキップ＝skipped に記録、ファイル窓を壊さない）。存在しないパスは
  // 黙ってスキップ（冪等）。削除結果は { deleted, skipped } で返す。
  router.post('/:id/files/delete', jsonParser, async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
      if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
        return res.status(403).json({ error: 'forbidden' });
      }

      const rawPaths: unknown =
        Array.isArray(req.body?.paths) ? req.body.paths
        : req.body?.path != null ? [req.body.path]
        : null;
      if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
        return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
      }

      const filesDir = spaceFilesDir(worktreeDir, space.id);
      const deleted: string[] = [];
      const skipped: string[] = [];
      for (const raw of rawPaths) {
        const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
        if (!rel) { skipped.push(String(raw ?? '')); continue; }
        const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
        let stat;
        try {
          stat = statSync(abs);
        } catch {
          skipped.push(rel); // 存在しない → 冪等にスキップ
          continue;
        }
        if (stat.isFile()) {
          unlinkSync(abs);
          deleted.push(rel);
        } else if (stat.isDirectory()) {
          // 構造フォルダ（input/output/logs/apps/readonly/source/skills/subtasks）は足場なので
          // 削除禁止（UI ガードだけだと API 直叩きで消せるためサーバ側でも拒否）。
          if (isProtectedWorkspaceDir(filesDir, abs)) { skipped.push(rel); continue; }
          rmSync(abs, { recursive: true, force: true }); // フォルダごと（中身含む）削除
          deleted.push(rel);
        } else {
          skipped.push(rel); // 特殊ファイル等は対象外
        }
      }

      res.json({ deleted, skipped });
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      logger.error(`[space-api] file delete error: ${err}`);
      res.status(500).json({ error: 'Failed to delete files' });
    }
  });

  // --- フォルダ作成 / リネーム・移動（Box ライクなファイル整理。owner/editor のみ） ---
  // 構造ディレクトリと隠し（.git/.conflict 等）はリネーム・移動の対象外にして、
  // 誤操作でワークスペースを壊さないようにする。source/ は出典ライブラリ、skills/ は
  // スキルの作業コピーで、いずれもエージェントが自動生成・参照する構造フォルダ。
  const PROTECTED_TOP_DIRS = new Set(['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills']);
  function isProtectedTopLevel(rel: string): boolean {
    if (!rel || rel.includes('/')) return false; // トップレベルのみ保護対象
    return PROTECTED_TOP_DIRS.has(rel) || rel.startsWith('.');
  }
  /** dest が既存なら `{stem} (N){ext}` に退避した非衝突パス（abs と rel）を返す。 */
  function freeDestination(filesDir: string, destRel: string): { abs: string; rel: string } {
    let rel = destRel;
    let abs = ensurePathWithin(filesDir, rel);
    if (!existsSync(abs)) return { abs, rel };
    const dir = dirname(destRel);
    const ext = extname(destRel);
    const stem = basename(destRel, ext);
    for (let n = 2; ; n++) {
      const name = `${stem} (${n})${ext}`;
      rel = (dir === '.' ? name : `${dir}/${name}`);
      abs = ensurePathWithin(filesDir, rel);
      if (!existsSync(abs)) return { abs, rel };
    }
  }

  // フォルダ作成: 現在パス配下に空フォルダを作る。readonly/ を既存スペースに後付けする
  // backfill 用途も兼ねる。reserved 名でも作成は許可（冪等・無害）。
  router.post('/:id/files/mkdir', jsonParser, async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
      if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
        return res.status(403).json({ error: 'forbidden' });
      }
      const rel = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      if (!rel) return res.status(400).json({ error: 'path is required' });
      const filesDir = spaceFilesDir(worktreeDir, space.id);
      const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
      if (existsSync(abs) && !statSync(abs).isDirectory()) {
        return res.status(409).json({ error: '同名のファイルが既にあります' });
      }
      mkdirSync(abs, { recursive: true });
      res.json({ created: rel });
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      logger.error(`[space-api] mkdir error: ${err}`);
      res.status(500).json({ error: 'Failed to create folder' });
    }
  });

  // リネーム/移動: from を to へ。to は UI が算出した完全パス（リネーム=親同じ+新名、
  // 移動=別フォルダ+同名）。構造ディレクトリ/隠しの from は不可。衝突は自動リネーム。
  router.post('/:id/files/move', jsonParser, async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
      if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
        return res.status(403).json({ error: 'forbidden' });
      }
      const from = String(req.body?.from ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      const to = String(req.body?.to ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
      const filesDir = spaceFilesDir(worktreeDir, space.id);
      // 先に正規化（resolve + traversal ガード）してから保護判定する。
      // 生文字列で isProtectedTopLevel を判定すると `./input` などの dot 形で
      // 構造ディレクトリ保護を迂回できてしまう（resolve 後の実体は input/ なのに、
      // 生文字列は "/" を含むため top-level 扱いされず素通りする）ため。
      const fromAbs = ensurePathWithin(filesDir, from); // escape は throw → 下で 400 化
      const toAbsRaw = ensurePathWithin(filesDir, to);
      const fromRelNorm = relative(filesDir, fromAbs).split(/[\\/]/).join('/');
      const toRelNorm = relative(filesDir, toAbsRaw).split(/[\\/]/).join('/');
      // from が構造ディレクトリ「そのもの」のときだけ拒否する。isProtectedTopLevel は
      // `/` を含むパスを false にするので、正規化済みの完全相対パスで判定すれば
      // 構造フォルダ自体（`./input` 等の dot 形含む）は弾きつつ、配下のファイル
      // （output/x.txt 等）の移動・リネームは許可できる（to 側の判定とも対称）。
      if (isProtectedTopLevel(fromRelNorm)) {
        return res.status(400).json({ error: 'このフォルダは移動・リネームできません' });
      }
      // to が root 直下の予約名（input/output/logs/apps/readonly・隠し）そのものになるのも防ぐ
      // （偽の構造ディレクトリを作る/置き換えるのを防止）。配下への移動（to=output/x）は許可。
      if (!toRelNorm.includes('/') && isProtectedTopLevel(toRelNorm)) {
        return res.status(400).json({ error: 'その名前は使えません（予約フォルダです）' });
      }
      if (!existsSync(fromAbs)) return res.status(404).json({ error: 'source not found' });
      // ディレクトリを自分自身（または配下）へ移動するのを防ぐ。
      const within = relative(fromAbs, toAbsRaw);
      if (within === '' || (!within.startsWith('..') && !within.startsWith('/'))) {
        return res.status(400).json({ error: 'フォルダを自分自身の中へは移動できません' });
      }
      mkdirSync(dirname(toAbsRaw), { recursive: true });
      // freeDestination は正規化済み相対パスで呼ぶ（`./` 等が結果 rel に残らないように）。
      const { abs: toAbs, rel: toRel } = freeDestination(filesDir, toRelNorm);
      renameSync(fromAbs, toAbs);
      res.json({ from, to: toRel });
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      logger.error(`[space-api] move error: ${err}`);
      res.status(500).json({ error: 'Failed to move' });
    }
  });

  // 複数ファイルを zip でダウンロード。ダウンロードは閲覧操作なので read ゲート
  // （getSpace が viewer で読めれば可。canEditInSpace は不要）。各 path は spaceFilesDir に
  // 封じ込め（traversal は 400）。ファイルのみ、ディレクトリ/不在はスキップ。zip エントリ名は
  // 相対パス（絶対パスを漏らさない）。
  router.post('/:id/files/download-zip', jsonParser, async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });

      const rawPaths: unknown =
        Array.isArray(req.body?.paths) ? req.body.paths
        : req.body?.path != null ? [req.body.path]
        : null;
      if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
        return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
      }

      const filesDir = spaceFilesDir(worktreeDir, space.id);
      const zip = new AdmZip();
      let added = 0;
      for (const raw of rawPaths) {
        const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
        if (!rel) continue;
        const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
        // ファイルはそのまま、ディレクトリは配下を再帰収集（entry 名は filesDir 相対で
        // フォルダ構造を保持。zip-slip 対策・symlink 除外は collectZipFiles 内）。
        for (const { abs: fileAbs, entryName } of collectZipFiles(filesDir, abs)) {
          zip.addFile(entryName, readFileSync(fileAbs));
          added++;
        }
      }
      if (added === 0) return res.status(404).json({ error: 'no downloadable files' });

      res.setHeader('Content-Type', 'application/zip');
      res.setHeader('Content-Disposition', 'attachment; filename="files.zip"');
      res.setHeader('X-Content-Type-Options', 'nosniff');
      res.send(zip.toBuffer());
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      logger.error(`[space-api] file zip error: ${err}`);
      res.status(500).json({ error: 'Failed to build zip' });
    }
  });

  // プログラム書込（1 ファイルを path 指定で上書き）。upload が O_EXCL で衝突回避リネーム
  // するのに対し、こちらは「同じパスを更新する」用途（ワークスペース・アプリの postMessage
  // ブリッジ writeFile 等）のため**上書き許可**。本文は base64（任意バイナリ）または content
  // （UTF-8 テキスト）で受ける。書込ゲートは upload/delete と同じ canEditInSpace（owner /
  // admin / member.role∈{owner,editor}）。path は ensurePathWithin で spaceFilesDir に封じ込め
  // （traversal は throw → 400）。親ディレクトリは mkdir -p。
  router.post('/:id/files/write', jsonParser, async (req, res) => {
    try {
      const viewer = viewerOf(req, deps.authActive);
      const space = await repo.getSpace(req.params.id, { viewer });
      if (!space) return res.status(404).json({ error: 'not found' });
      const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
      if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
        return res.status(403).json({ error: 'forbidden' });
      }

      const rel = String(req.body?.path ?? '').replace(/^\/+/, '');
      if (!rel) return res.status(400).json({ error: 'path is required' });
      const hasBase64 = typeof req.body?.contentBase64 === 'string';
      const hasText = typeof req.body?.content === 'string';
      if (!hasBase64 && !hasText) {
        return res.status(400).json({ error: 'content or contentBase64 is required' });
      }
      const buf = hasBase64
        ? Buffer.from(String(req.body.contentBase64), 'base64')
        : Buffer.from(String(req.body.content), 'utf-8');
      // サイズ上限（DoS / ディスク枯渇対策）。
      const MAX_WRITE_BYTES = 10 * 1024 * 1024;
      if (buf.length > MAX_WRITE_BYTES) {
        return res.status(400).json({ error: `content exceeds ${MAX_WRITE_BYTES} bytes` });
      }

      const filesDir = spaceFilesDir(worktreeDir, space.id);
      const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
      // 書込先は apps/ と output/ サブツリーに限定する。任意 .html や AGENTS.md /
      // source/index.jsonl 等を上書きされ、trusted-html 無サンドボックス配信経路と
      // 組み合わさって stored XSS になるのを防ぐ（ブリッジ writeFile の正当な用途は
      // アプリの出力 = output/ と アプリ自身のデータ = apps/）。escape は ensurePathWithin
      // が既に弾く。正規化後の相対先頭セグメントで判定する。
      const normRel = relative(filesDir, abs).split(/[\\/]/);
      if (normRel[0] !== 'apps' && normRel[0] !== 'output') {
        return res.status(403).json({ error: 'write target must be under apps/ or output/' });
      }
      // 既存ディレクトリを潰さないガード（ファイルのみ上書き対象）。
      try {
        if (statSync(abs).isDirectory()) {
          return res.status(400).json({ error: 'path points to a directory' });
        }
      } catch { /* 非存在は新規作成 */ }
      mkdirSync(dirname(abs), { recursive: true });
      writeFileSync(abs, buf);
      res.json({ path: rel, bytes: buf.length });
    } catch (err) {
      if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
      logger.error(`[space-api] file write error: ${err}`);
      res.status(500).json({ error: 'Failed to write file' });
    }
  });
}
