import express, { Request, Response } from 'express';
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
import type { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import {
  ensurePathWithin,
  isPathEscapeError,
  serializeLocalFileEntry,
  setUntrustedFileResponseHeaders,
} from './local-api-helpers.js';
import { logger } from '../logger.js';

/**
 * 公開アプリ共有 API（read-only・認証なし）。
 *
 * スペース内の 1 ワークスペースアプリ（apps/{appName}/）を、ログイン不要の
 * 公開トークンで read-only 配信する。`/api/shared/*`（share-api.ts）と同じく
 * 認証ミドルウェアの外側に mount する。
 *
 * 封じ込め: token → (spaceId, appName) を解決し、リクエスト path を許可ルート
 * `apps/{appName}/` と `output/` の 2 プレフィックスのみに制限する。許可ルート
 * 外は 403、失効/不正トークンは 404。クライアントのパス検証には依存せず、
 * サーバ側で token に紐付けて強制する。write/delete は実装しない（GET のみ）。
 */

class ForbiddenPathError extends Error {
  constructor() {
    super('Path not within an allowed app-share root');
    this.name = 'ForbiddenPathError';
  }
}

/**
 * リクエスト path を、許可ルート（apps/{appName}/ と output/）のいずれか配下に
 * 解決する。どちらにも収まらなければ ForbiddenPathError、ensurePathWithin の
 * 字句的/symlink 脱出ガードに引っかかれば Path escapes workspace を投げる。
 *
 * 戦略: 許可ルートごとに ensurePathWithin(allowedRoot, relSuffix) を試す。
 * relSuffix は要求 path から許可プレフィックスを剥がしたもの。ensurePathWithin が
 * realpath ベースで symlink 脱出も塞ぐので、二重防御はそこに集約される。
 */
function resolveWithinAllowedRoots(filesRoot: string, appName: string, requestedPath: string): string {
  const rel = requestedPath.replace(/^\/+/, '').replace(/\\/g, '/');
  // 許可プレフィックス（filesRoot 相対、末尾スラッシュ付き）
  const appPrefix = `apps/${appName}/`;
  const outputPrefix = 'output/';

  let allowedRoot: string;
  let suffix: string;
  if (rel === `apps/${appName}` || rel.startsWith(appPrefix)) {
    allowedRoot = join(filesRoot, 'apps', appName);
    suffix = rel === `apps/${appName}` ? '' : rel.slice(appPrefix.length);
  } else if (rel === 'output' || rel.startsWith(outputPrefix)) {
    allowedRoot = join(filesRoot, 'output');
    suffix = rel === 'output' ? '' : rel.slice(outputPrefix.length);
  } else {
    throw new ForbiddenPathError();
  }
  // ensurePathWithin が字句的封じ込め + realpath による symlink 脱出ガードを行う。
  // suffix に .. が混ざっていてもここで弾かれる（Path escapes workspace）。
  return ensurePathWithin(allowedRoot, suffix);
}

/** 共有アプリのエントリ HTML を探す。apps/{appName}/index.html を優先、無ければ最初の .html。 */
function findEntryPath(filesRoot: string, appName: string): string | null {
  const appDir = join(filesRoot, 'apps', appName);
  const indexRel = `apps/${appName}/index.html`;
  if (existsSync(join(filesRoot, 'apps', appName, 'index.html'))) return indexRel;
  let entries: import('node:fs').Dirent[];
  try {
    entries = readdirSync(appDir, { withFileTypes: true });
  } catch {
    return null;
  }
  const html = entries.find((e) => e.isFile() && /\.html?$/i.test(e.name));
  return html ? `apps/${appName}/${html.name}` : null;
}

export function mountAppShareApi(app: express.Application, repo: Repository, worktreeDir: string): void {
  // token → (spaceId, appName)。失効/不正は null（呼び出し側で 404）。
  const resolveToken = (token: string): { spaceId: string; appName: string } | null =>
    repo.resolveAppShareToken(token);

  // アプリのメタ（appName / entryPath）
  app.get('/api/app-share/:token', (req: Request, res: Response) => {
    try {
      const resolved = resolveToken(req.params.token);
      if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
      const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
      const entryPath = findEntryPath(filesRoot, resolved.appName);
      res.json({ app: { appName: resolved.appName, entryPath } });
    } catch (err) {
      logger.error(`[app-share] meta error: ${err}`);
      res.status(500).json({ error: 'Failed to fetch app share' });
    }
  });

  // テキスト読み取り
  app.get('/api/app-share/:token/files/content', (req: Request, res: Response) => {
    try {
      const resolved = resolveToken(req.params.token);
      if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
      const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
      const filePath = resolveWithinAllowedRoots(filesRoot, resolved.appName, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
      setUntrustedFileResponseHeaders(res);
      res.setHeader('Referrer-Policy', 'no-referrer');
      res.setHeader('Content-Type', 'text/plain; charset=utf-8');
      res.send(readFileSync(filePath, 'utf-8'));
    } catch (err) {
      if (err instanceof ForbiddenPathError) { res.status(403).json({ error: 'Path not allowed' }); return; }
      if (isPathEscapeError(err)) { res.status(403).json({ error: 'Path escapes workspace' }); return; }
      logger.error(`[app-share] content error: ${err}`);
      res.status(500).json({ error: 'Failed to read file' });
    }
  });

  // バイナリ / アセット配信
  app.get('/api/app-share/:token/files/raw', (req: Request, res: Response) => {
    try {
      const resolved = resolveToken(req.params.token);
      if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
      const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
      if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
      const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
      const filePath = resolveWithinAllowedRoots(filesRoot, resolved.appName, relativePath);
      const stat = statSync(filePath);
      if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
      // 公開・非信頼配信。CSP sandbox + nosniff + Referer 漏洩低減。
      setUntrustedFileResponseHeaders(res);
      res.setHeader('Referrer-Policy', 'no-referrer');
      res.type(extname(filePath) || 'application/octet-stream');
      res.send(readFileSync(filePath));
    } catch (err) {
      if (err instanceof ForbiddenPathError) { res.status(403).json({ error: 'Path not allowed' }); return; }
      if (isPathEscapeError(err)) { res.status(403).json({ error: 'Path escapes workspace' }); return; }
      logger.error(`[app-share] raw error: ${err}`);
      res.status(500).json({ error: 'Failed to read file' });
    }
  });

  // ディレクトリ一覧（許可ルート内のみ）
  app.get('/api/app-share/:token/files/list', (req: Request, res: Response) => {
    try {
      const resolved = resolveToken(req.params.token);
      if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
      const relativeDir = String(req.query.dir ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
      if (!relativeDir) { res.status(400).json({ error: 'dir is required' }); return; }
      const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
      const dirPath = resolveWithinAllowedRoots(filesRoot, resolved.appName, relativeDir);
      const stat = statSync(dirPath);
      if (!stat.isDirectory()) { res.status(400).json({ error: 'dir must point to a directory' }); return; }
      const entries = readdirSync(dirPath, { withFileTypes: true })
        .filter((entry) => !entry.name.startsWith('.'))
        .map((entry) => {
          const s = statSync(join(dirPath, entry.name));
          return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), s.size, s.mtime);
        });
      res.json({ basePath: relativeDir.split('/')[0], path: relativeDir, entries });
    } catch (err) {
      if (err instanceof ForbiddenPathError) { res.status(403).json({ error: 'Path not allowed' }); return; }
      if (isPathEscapeError(err)) { res.status(403).json({ error: 'Path escapes workspace' }); return; }
      logger.error(`[app-share] list error: ${err}`);
      res.status(500).json({ error: 'Failed to list files' });
    }
  });
}
