import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, openSync, writeSync, closeSync, unlinkSync, rmSync } from 'fs';
import { join, extname, basename, dirname } from 'path';
import AdmZip from 'adm-zip';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, isPathEscapeError, isNotFoundError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders, isProtectedWorkspaceDir, collectZipFiles } from './local-api-helpers.js';
import { logRoot } from '../spaces/runtime-paths.js';
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';

/**
 * セクションのファイルツリー root を解決する（計画5）。
 * `logs` は logRoot(task)（runtime_dir フォールバック）、`workspace` は
 * ワークスペース直下、それ以外（input/output）は workspace_path/section。
 * runtime_dir=NULL の既存タスクは従来どおり workspace_path/logs を読む。
 * 呼出側は事前に `task?.workspacePath` を 404 ガード済み（非 null 保証）。
 */
function sectionRoot(task: { workspacePath: string | null; runtimeDir?: string | null }, section: string): string {
  const workspacePath = task.workspacePath as string;
  if (section === 'logs') return logRoot({ workspacePath, runtimeDir: task.runtimeDir });
  if (section === 'workspace') return workspacePath;
  return join(workspacePath, section);
}

export interface LocalFilesApiOptions {
  /** Whether the auth subsystem is wired. When false (no-auth single-user
   *  deployment) there is no req.user, so the sole local operator owns every
   *  task and is allowed to open their own generated HTML with trusted=1.
   *  Defaults to true (owner identity comes from req.user). */
  authActive?: boolean;
}

export function mountLocalFilesApi(
  app: Application,
  repo: Repository,
  opts: LocalFilesApiOptions = {},
): void {
  const authActive = opts.authActive ?? true;

  app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }

      const section = String(req.query.section ?? 'input');
      if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
        res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
        return;
      }

      const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      const rootDir = sectionRoot(task, section);
      mkdirSync(rootDir, { recursive: true });
      const dirPath = ensurePathWithin(rootDir, relativeDir);
      const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
        const stat = statSync(join(dirPath, entry.name));
        return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
      });
      res.json({ basePath: section, path: relativeDir, entries });
    } catch (err) {
      if (isPathEscapeError(err)) {
        res.status(400).json({ error: 'Path escapes workspace' });
        return;
      }
      if (isNotFoundError(err)) {
        logger.debug(`Local files list not found: ${err}`);
        res.status(404).json({ error: 'not found' });
        return;
      }
      logger.error(`Local files list API error: ${err}`);
      res.status(500).json({ error: 'Failed to list files' });
    }
  });

  app.get('/api/local/tasks/:taskId/files/content', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }

      const section = String(req.query.section ?? 'input');
      if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
        res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
        return;
      }
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) {
        res.status(400).json({ error: 'path is required' });
        return;
      }
      const rootDir = sectionRoot(task, section);
      const filePath = ensurePathWithin(rootDir, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) {
        res.status(400).json({ error: 'path must point to a file' });
        return;
      }
      setUntrustedFileResponseHeaders(res);
      res.setHeader('Content-Type', 'text/plain; charset=utf-8');
      res.send(readFileSync(filePath, 'utf-8'));
    } catch (err) {
      if (isPathEscapeError(err)) {
        res.status(400).json({ error: 'Path escapes workspace' });
        return;
      }
      if (isNotFoundError(err)) {
        logger.debug(`Local file content not found: ${err}`);
        res.status(404).json({ error: 'not found' });
        return;
      }
      logger.error(`Local file content API error: ${err}`);
      res.status(500).json({ error: 'Failed to read file' });
    }
  });

  // ファイル来歴（provenance）を1件返す。読み取り可能なユーザーなら誰でも。
  // 返すのはタスク ID / 種別 / 日時のみ（タイトルやユーザー ID は返さない）。
  app.get('/api/local/tasks/:taskId/files/provenance', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }
      const section = String(req.query.section ?? 'input');
      if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
        res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
        return;
      }
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) {
        res.status(400).json({ error: 'path is required' });
        return;
      }
      // rel_path は台帳と同じ workspace 相対（section 接頭辞つき）に揃える。
      const relForLedger = section === 'workspace' ? relativePath : `${section}/${relativePath}`;
      const rec = repo.getProvenance(task.workspacePath, relForLedger);
      // Adversarial-review D4: project to the SAME safe subset the agent tool
      // exposes — task IDs, source kind, piece/movement, timestamps. Never job
      // UUIDs, checksum, or the free-text `note`, so a future note writer can't
      // leak PII to every space viewer through this shared-space read.
      const provenance = rec
        ? {
            relPath: rec.relPath,
            sourceKind: rec.sourceKind,
            createdByTaskId: rec.createdByTaskId,
            createdByPiece: rec.createdByPiece,
            createdByMovement: rec.createdByMovement,
            firstSeenAt: rec.firstSeenAt,
            lastModifiedByTaskId: rec.lastModifiedByTaskId,
            lastModifiedAt: rec.lastModifiedAt,
          }
        : null;
      res.json({ provenance });
    } catch (err) {
      logger.error(`Local file provenance API error: ${err}`);
      res.status(500).json({ error: 'Failed to load provenance' });
    }
  });

  app.get('/api/local/tasks/:taskId/files/raw', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }

      const section = String(req.query.section ?? 'input');
      if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
        res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
        return;
      }
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) {
        res.status(400).json({ error: 'path is required' });
        return;
      }
      const rootDir = sectionRoot(task, section);
      const filePath = ensurePathWithin(rootDir, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) {
        res.status(400).json({ error: 'path must point to a file' });
        return;
      }
      // trusted=1 drops the CSP sandbox so generated HTML can run on the app
      // origin as an app. Trust model: task owners are trusted colleagues
      // (internal Gitea-org tool). Any viewer who already passed the visibility
      // gate (canViewTask above — private=owner+admin, org, public) gets the
      // trusted version. Accepted tradeoff: an owner's unsandboxed HTML runs
      // with the viewer's session, so a malicious owner could act as a viewer —
      // owners are trusted, so this is allowed.
      // Hard floor: an UNAUTHENTICATED request (no viewer) never gets trusted,
      // so a shared trusted link bounces through login (requireAuth) first.
      // No-auth single-user mode has no req.user; the sole operator owns every
      // task (self-XSS only — there is no second principal to attack).
      const trustedAllowed = authActive ? !!viewer : 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)) {
        res.status(400).json({ error: 'Path escapes workspace' });
        return;
      }
      if (isNotFoundError(err)) {
        logger.debug(`Local file raw not found: ${err}`);
        res.status(404).json({ error: 'not found' });
        return;
      }
      logger.error(`Local file raw API error: ${err}`);
      res.status(500).json({ error: 'Failed to read raw file' });
    }
  });

  // Excel / PowerPoint をプレビュー用に変換して返す。閲覧操作なので canViewTask ゲート。
  // Excel→シートのセル配列(JSON)、PPTX→スライド画像(PNG data URL)。soffice 未導入なら 503。
  app.get('/api/local/tasks/:taskId/files/office-preview', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }
      const section = String(req.query.section ?? 'input');
      if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
        res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
        return;
      }
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) {
        res.status(400).json({ error: 'path is required' });
        return;
      }
      const rootDir = sectionRoot(task, section);
      const filePath = ensurePathWithin(rootDir, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) {
        res.status(400).json({ error: 'path must point to a file' });
        return;
      }
      await sendOfficePreview(res, filePath, basename(filePath));
    } catch (err) {
      handleOfficePreviewError(res, err);
    }
  });

  app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!checkTaskOwnership(req, res, task)) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }
      const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
      if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
        res.status(409).json({ error: 'Cannot edit files while job is running' });
        return;
      }
      const section = String(req.body?.section ?? '');
      if (section !== 'output') {
        res.status(400).json({ error: 'Only output files can be edited' });
        return;
      }
      const relativePath = String(req.body?.path ?? '').replace(/^\/+/, '');
      if (!relativePath) {
        res.status(400).json({ error: 'path is required' });
        return;
      }
      const content = req.body?.content;
      if (typeof content !== 'string') {
        res.status(400).json({ error: 'content is required' });
        return;
      }
      // PUT (inline edit) is output-only; section is narrowed to 'output' above.
      const rootDir = join(task.workspacePath, section);
      const filePath = ensurePathWithin(rootDir, relativePath);
      writeFileSync(filePath, content, 'utf-8');
      res.json({ ok: true });
    } catch (err) {
      if (isPathEscapeError(err)) {
        res.status(400).json({ error: 'Path escapes workspace' });
        return;
      }
      logger.error(`Local file update API error: ${err}`);
      res.status(500).json({ error: 'Failed to update file' });
    }
  });

  // upload / delete が書き込みを許す区分。logs（実行ログ）と workspace（作業ディレクトリ）は
  // エージェント管理領域なので人手の書き込み対象から外す（編集 PUT が output 限定なのと同方針）。
  const WRITABLE_SECTIONS = ['input', 'output'] as const;

  /**
   * upload / delete 共通のゲート。owner/admin かつ実行中でないことを確認し、書き込み可能な
   * task を返す。失敗時はレスポンスを送って null を返す（呼出側は早期 return する）。
   */
  async function loadWritableTask(req: Request, res: Response, section: string): Promise<{ taskId: number; workspacePath: string; runtimeDir?: string | null; spaceId?: string | null } | null> {
    const taskId = parseTaskId(req.params.taskId);
    if (taskId === null) {
      res.status(400).json({ error: 'Invalid task ID' });
      return null;
    }
    const viewer = req.user as Express.User | undefined;
    const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
    if (!checkTaskOwnership(req, res, task)) return null;
    if (!task?.workspacePath) {
      res.status(404).json({ error: 'Workspace not found' });
      return null;
    }
    if (!WRITABLE_SECTIONS.includes(section as typeof WRITABLE_SECTIONS[number])) {
      res.status(400).json({ error: `section must be one of: ${WRITABLE_SECTIONS.join(', ')}` });
      return null;
    }
    const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
    if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
      res.status(409).json({ error: 'Cannot modify files while job is running' });
      return null;
    }
    return { taskId, workspacePath: task.workspacePath, runtimeDir: task.runtimeDir, spaceId: task.spaceId };
  }

  // input/output へファイルをアップロード（複数可）。space-api の /files/upload と同型:
  // O_EXCL で衝突回避リネーム（上書き禁止）、ensurePathWithin で section root に封じ込め。
  app.post('/api/local/tasks/:taskId/files/upload', express.json({ limit: '60mb' }), async (req: Request, res: Response) => {
    try {
      const section = String(req.body?.section ?? '');
      const task = await loadWritableTask(req, res, section);
      if (!task) return;

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

      const uploaded: { name: string; path: string }[] = [];
      for (const file of files) {
        const safeName = String(file?.name ?? '').replace(/[\\/]/g, '_');
        if (!safeName) {
          res.status(400).json({ error: 'file name is required' });
          return;
        }
        const abs = ensurePathWithin(rootDir, join(reqPath, safeName));
        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(rootDir, 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 });

        // Provenance: a UI upload is user_input. rel_path is workspace-relative
        // (section-prefixed), matching what the agent tools query. Best-effort:
        // never fail the upload if the ledger write throws.
        try {
          const relForLedger = section === 'workspace' ? writtenRel : `${section}/${writtenRel}`;
          repo.recordProvenance({
            workspacePath: task.workspacePath,
            relPath: relForLedger,
            event: 'create',
            sourceKind: 'user_input',
            taskId: task.taskId,
            spaceId: task.spaceId ?? null,
          });
        } catch (e) {
          logger.debug(`[provenance] upload record skipped: ${(e as Error).message}`);
        }
      }

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

  // input/output のファイルを削除。body は { section, paths: string[] }（単一 { path } も許容）。
  // ファイルのみ対象（ディレクトリ/不在は skipped）、冪等。
  app.post('/api/local/tasks/:taskId/files/delete', express.json(), async (req: Request, res: Response) => {
    try {
      const section = String(req.body?.section ?? '');
      const task = await loadWritableTask(req, res, section);
      if (!task) return;

      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) {
        res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
        return;
      }

      const rootDir = sectionRoot(task, section);
      const deleted: string[] = [];
      const skipped: string[] = [];
      // まず全 path を封じ込め検証してから削除する（2パス）。後続 path が traversal で
      // 400 になっても、先行ファイルだけ削除済みになる部分適用を避ける。
      const resolved: { rel: string; abs: string }[] = [];
      for (const raw of rawPaths) {
        const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
        if (!rel) { skipped.push(String(raw ?? '')); continue; }
        const abs = ensurePathWithin(rootDir, rel); // escape は throw → 下で 400 化（削除前）
        resolved.push({ rel, abs });
      }
      const workspaceRoot = sectionRoot(task, 'workspace');
      for (const { rel, abs } of resolved) {
        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(workspaceRoot, 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)) {
        res.status(400).json({ error: 'Path escapes workspace' });
        return;
      }
      logger.error(`Local file delete API error: ${err}`);
      res.status(500).json({ error: 'Failed to delete files' });
    }
  });

  // 複数ファイルを zip にまとめてダウンロード。ダウンロードは閲覧操作なので read ゲート
  // （canViewTask）で、全 section（workspace/input/output/logs）から取れる。各 path は
  // ensurePathWithin で section root に封じ込め（traversal は 400）。ファイルのみ対象、
  // ディレクトリ/不在はスキップ。zip エントリ名は相対パス（絶対パスを漏らさない）。
  app.post('/api/local/tasks/:taskId/files/download-zip', express.json(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
      if (!task?.workspacePath) {
        res.status(404).json({ error: 'Workspace not found' });
        return;
      }
      const section = String(req.body?.section ?? 'output');
      if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
        res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
        return;
      }
      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) {
        res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
        return;
      }
      const rootDir = sectionRoot(task, section);
      const zip = new AdmZip();
      let added = 0;
      for (const raw of rawPaths) {
        const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
        if (!rel) continue;
        const abs = ensurePathWithin(rootDir, rel); // escape は throw → 下で 400 化
        // ファイルはそのまま、ディレクトリは配下を再帰収集（entry 名は rootDir 相対で
        // フォルダ構造を保持。zip-slip 対策・symlink 除外は collectZipFiles 内で実施）。
        for (const { abs: fileAbs, entryName } of collectZipFiles(rootDir, abs)) {
          zip.addFile(entryName, readFileSync(fileAbs));
          added++;
        }
      }
      if (added === 0) {
        res.status(404).json({ error: 'no downloadable files' });
        return;
      }
      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)) {
        res.status(400).json({ error: 'Path escapes workspace' });
        return;
      }
      logger.error(`Local file zip API error: ${err}`);
      res.status(500).json({ error: 'Failed to build zip' });
    }
  });
}
