import { type Request, type Response } from 'express';
import { join, resolve, sep, dirname, relative, basename, extname } from 'path';
import * as fs from 'fs';
import { existsSync, readFileSync } from 'fs';
import { parseEventLine, type EventBase } from '../progress/event-log.js';
import type { Job, Repository } from '../db/repository.js';

/**
 * ダウンロード zip の安全なエントリ名を、封じ込め済みの実パス `abs` から組み立てる。
 *
 * リクエスト由来の相対パスをそのまま zip entry 名に使うと zip-slip になり得る。
 * 特に POSIX 上では `a\..\evil.txt` や `C:\evil.txt` が「1個の正当なファイル名」として
 * `ensurePathWithin` を通過するが、Windows で展開すると区切り/ドライブとして解釈され
 * 展開先を脱出し得る。`relative(rootDir, abs)` から作り直したうえで:
 *  - `\` と `/` の両方で分割（Windows 区切りを無効化）
 *  - `.` / `..` セグメントを除去（相対脱出を無効化）
 *  - 各セグメントの `:` を `_` に置換（ドライブレター / NTFS ADS を無効化）
 * 空になった場合は空文字を返す（呼出側が skip する）。返り値は常に相対 POSIX パス。
 */
export function safeZipEntryName(rootDir: string, abs: string): string {
  return relative(rootDir, abs)
    .split(/[\\/]+/)
    .filter(seg => seg && seg !== '.' && seg !== '..')
    .map(seg => seg.replace(/:/g, '_'))
    .join('/');
}

/**
 * ワークスペース直下の「構造フォルダ」名。ここにある top-level ディレクトリは足場なので
 * 削除・移動・リネームを禁止する（UI の workspaceDirRole / scaffold と整合させること）。
 */
export const RESERVED_WORKSPACE_DIRS = new Set([
  'input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills', 'subtasks',
]);

/**
 * `abs` がワークスペース直下の構造フォルダ（input/output/... のいずれか）なら true。
 * ネスト下（output/sub 等）やユーザー作成の通常フォルダは false。
 * delete の前段ガードに使う（UI ガードだけだと API 直叩きで足場を消せてしまうため、
 * サーバ側でも拒否する二層防御）。
 */
export function isProtectedWorkspaceDir(workspaceRoot: string, abs: string): boolean {
  const rel = relative(workspaceRoot, abs);
  if (!rel || rel.startsWith('..') || rel.includes(sep)) return false;
  return RESERVED_WORKSPACE_DIRS.has(rel);
}

/**
 * `absTarget`（ファイル or ディレクトリ。ensurePathWithin で rootDir 配下に封じ込め済み）
 * から zip に入れるファイル一覧を返す。ディレクトリは再帰展開し、entry 名は rootDir 相対で
 * フォルダ構造を保つ（safeZipEntryName で zip-slip 対策）。symlink は脱出防止のため展開せず
 * スキップ。深さ上限 64 でループ保護。
 */
export function collectZipFiles(
  rootDir: string,
  absTarget: string,
  depth = 0,
): { abs: string; entryName: string }[] {
  if (depth > 64) return [];
  let lst;
  try { lst = fs.lstatSync(absTarget); } catch { return []; }
  if (lst.isSymbolicLink()) return []; // symlink は展開しない（封じ込め脱出を防ぐ）
  if (lst.isFile()) {
    const entryName = safeZipEntryName(rootDir, absTarget);
    return entryName ? [{ abs: absTarget, entryName }] : [];
  }
  if (lst.isDirectory()) {
    const out: { abs: string; entryName: string }[] = [];
    let entries: string[];
    try { entries = fs.readdirSync(absTarget); } catch { return []; }
    for (const e of entries) {
      out.push(...collectZipFiles(rootDir, join(absTarget, e), depth + 1));
    }
    return out;
  }
  return [];
}

export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string {
  const base = worktreeDir ?? '/tmp/maestro/workspaces';
  return join(base, 'local', String(taskId));
}

export function ensurePathWithin(baseDir: string, requestedPath: string): string {
  const resolvedBase = resolve(baseDir);
  const resolvedPath = resolve(baseDir, requestedPath);
  // 1) 字句的な封じ込めチェック（従来動作）
  if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
    throw new Error('Path escapes workspace');
  }
  // 2) symlink 脱出チェック。root が実在するときだけ行う
  //    （未作成 root を渡す既存呼び出し・テストの挙動を保つため）。
  if (fs.existsSync(resolvedBase)) {
    const realRoot = fs.realpathSync(resolvedBase);
    // resolvedPath から上に辿り、実在する最も深い祖先を見つける
    let probe = resolvedPath;
    while (!fs.existsSync(probe) && dirname(probe) !== probe) {
      probe = dirname(probe);
    }
    const realProbe = fs.realpathSync(probe);
    if (realProbe !== realRoot && !realProbe.startsWith(realRoot + sep)) {
      throw new Error('Path escapes workspace');
    }
  }
  return resolvedPath;
}

/**
 * Harden a response that serves user/agent-authored workspace bytes.
 *
 * Workspace files can hold attacker-influenced HTML/SVG (an agent driven by a
 * poisoned web page, or another user's shared task). Without these headers the
 * browser renders such a file inline on the app origin and any embedded
 * `<script>` runs with the viewer's session — stored XSS, and via the
 * unauthenticated share endpoint it crosses to other users. `Content-Security-Policy:
 * sandbox` forces an opaque origin and disables script execution while still
 * letting the in-app preview render the markup; `nosniff` stops the browser from
 * sniffing a text/* file back into executable HTML.
 */
export function setUntrustedFileResponseHeaders(res: Response): void {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Content-Security-Policy', 'sandbox');
}

/** True when the error came from ensurePathWithin's traversal guard. */
export function isPathEscapeError(err: unknown): boolean {
  return err instanceof Error && err.message === 'Path escapes workspace';
}

/**
 * 子ジョブの worktree が、親タスクの workspace 配下にあるかを区切り境界つきで判定する。
 *
 * 素の `worktreePath.startsWith(workspacePath)` だと、`.../local/12` が `.../local/123`
 * の接頭辞に一致してしまい、別タスク（id=123）のサブジョブへ token を流用した越境が通る。
 * `resolve` で正規化し、末尾セパレータ（または完全一致）を要求して prefix 衝突を塞ぐ。
 * 共有 (`/api/shared/.../subtasks/...`) と認証付き subtask API の双方で唯一の封じ込め壁。
 */
export function isJobWithinWorkspace(
  workspacePath: string | null | undefined,
  worktreePath: string | null | undefined,
): boolean {
  if (!workspacePath || !worktreePath) return false;
  const base = resolve(workspacePath);
  const wt = resolve(worktreePath);
  return wt === base || wt.startsWith(base + sep);
}

/**
 * A filesystem "this path does not exist" error: a missing file (ENOENT) or a
 * non-directory in the path (ENOTDIR). Callers map these to 404, not 500 — a
 * requested file that simply hasn't been created yet (e.g. a Source Library
 * index for a space with no sources) is "not found", not a server failure, and
 * must not be logged at ERROR.
 */
export function isNotFoundError(err: unknown): boolean {
  const code = (err as NodeJS.ErrnoException | null)?.code;
  return code === 'ENOENT' || code === 'ENOTDIR';
}

export function serializeLocalFileEntry(relativePath: string, name: string, isDirectory: boolean, size: number, mtime: Date) {
  return {
    name,
    path: relativePath ? `${relativePath}/${name}` : name,
    kind: isDirectory ? 'directory' : 'file',
    size,
    modifiedAt: mtime.toISOString(),
  };
}

/**
 * 添付を `dir`（通常は input/）へ保存するときの衝突回避ファイル名を決める。
 * `desiredName`（区切り文字はサニタイズ済みの単一ファイル名）が既存、または同一
 * バッチ内で既に予約済みなら `{stem} (2){ext}` `{stem} (3){ext}` … と避ける。
 *
 * ファイルタブのアップロード（`local-files-api` / `space-files-api`）は `O_EXCL`
 * で「予約＝書き込み」を原子的に行うが、添付はツール要求ゲート通過まで実書き込みを
 * 遅延する（かつ確定名を instruction / コメントへ先に載せる）必要があるため、ここでは
 * 生成はせず名前だけを予約して返す。`reserved` に決めた名前を積むので、同一リクエストで
 * 同名の添付が複数あっても衝突しない。dir が未作成でも呼べる（existsSync は false を返す）。
 */
export function reserveAttachmentName(dir: string, desiredName: string, reserved: Set<string>): string {
  const ext = extname(desiredName);
  const stem = basename(desiredName, ext);
  let candidate = desiredName;
  for (let n = 2; ; n++) {
    if (!reserved.has(candidate) && !existsSync(join(dir, candidate))) {
      reserved.add(candidate);
      return candidate;
    }
    candidate = `${stem} (${n})${ext}`;
  }
}

export function checkTaskOwnership(req: Request, res: Response, task: { ownerId?: string | null } | null): boolean {
  if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
  if (req.user && req.user.role !== 'admin' && task.ownerId !== req.user?.id) {
    res.status(404).json({ error: 'Task not found' });
    return false;
  }
  return true;
}

type TaskLike = {
  ownerId?: string | null;
  visibility?: 'private' | 'org' | 'public' | null;
  visibilityScopeOrgId?: string | null;
  spaceId?: string | null;
};

// Read-side permission check honoring the full visibility model.
// Writes should continue to use checkTaskOwnership (owner-or-admin only).
//
// スペースタスク（task.spaceId が非 null）の閲覧は owner/org/public だけでなく
// スペースメンバーシップでも許可する。メンバーシップ判定は DB を要するため、
// 呼び出し側が `repo.userCanViewSpace(task.spaceId, user)` で算出して
// `spaceAccessible` に渡す（この関数は DB を触らない）。spaceId が null の
// 非スペースタスクは従来通り owner/org/public のみ（spaceAccessible 無視）。
export function canViewTask(
  req: Request,
  res: Response,
  task: TaskLike | null,
  spaceAccessible?: boolean,
): boolean {
  if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
  const user = req.user as Express.User | undefined;
  if (!user) return true;
  if (user.role === 'admin') return true;
  if (task.ownerId && task.ownerId === user.id) return true;
  if (task.visibility === 'public') return true;
  if (task.visibility === 'org' && task.visibilityScopeOrgId && user.orgIds?.includes(task.visibilityScopeOrgId)) return true;
  if (task.spaceId && spaceAccessible === true) return true;
  res.status(404).json({ error: 'Task not found' });
  return false;
}

/** Minimal repo surface needed to resolve space membership (avoids a hard Repository import here). */
export interface SpaceAccessRepo {
  userCanViewSpace(spaceId: string, user: { id: string; role: string }): boolean;
}

/**
 * Resolve the `spaceAccessible` flag for a task's secondary view gate.
 * Returns true only when the task belongs to a space AND the viewer is the
 * space root owner or a member (admin handled inside userCanViewSpace).
 * Non-space tasks (spaceId null) and unauthenticated requests → false.
 */
export function resolveSpaceAccess(
  repo: SpaceAccessRepo,
  task: { spaceId?: string | null } | null,
  user: Express.User | undefined,
): boolean {
  if (!task?.spaceId || !user) return false;
  return repo.userCanViewSpace(task.spaceId, user);
}

// ---------------------------------------------------------------------------
// Sub-job tree traversal helpers
// ---------------------------------------------------------------------------

const MAX_EVENTS_BYTES = 32 * 1024 * 1024;

export interface SubJobNode { job: Job; depth: number; parentJobId: string }

/** root ジョブ配下の全子孫サブジョブを status 条件なしで再帰収集する。 */
export async function collectAllSubJobs(repo: Pick<Repository, 'getSubJobs'>, rootJobId: string): Promise<SubJobNode[]> {
  const out: SubJobNode[] = [];
  const walk = async (parentId: string, depth: number): Promise<void> => {
    const children = await repo.getSubJobs(parentId);
    for (const job of children) {
      out.push({ job, depth, parentJobId: parentId });
      await walk(job.id, depth + 1);
    }
  };
  await walk(rootJobId, 1);
  return out;
}

/** ジョブ worktree の logs/events.jsonl を末尾 MAX_EVENTS_BYTES だけ読み EventBase[] にする。 */
export function readJobEvents(worktreePath: string | null): EventBase[] {
  if (!worktreePath) return [];
  const path = join(worktreePath, 'logs', 'events.jsonl');
  if (!existsSync(path)) return [];
  let raw: string;
  try { raw = readFileSync(path, 'utf-8'); } catch { return []; }
  if (raw.length > MAX_EVENTS_BYTES) raw = raw.slice(raw.length - MAX_EVENTS_BYTES);
  const out: EventBase[] = [];
  for (const line of raw.split('\n')) {
    if (!line.trim()) continue;
    const r = parseEventLine(line);
    if (r.kind === 'ok') out.push(r.event);
  }
  return out;
}
