import { type Application, type Request, type Response } from 'express';
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { parse, stringify } from 'yaml';
import { patchYaml } from './yaml-patch.js';
import { detectDrift, type DriftStatus } from '../engine/reflection/drift-detect.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { logger } from '../logger.js';

export type PieceSource = 'builtin' | 'global-custom' | 'user-custom';

interface PieceSummary {
  name: string;
  description: string;
  triggers?: { keywords: string[] };
  drift?: DriftStatus;
  requiredMcp?: string[];
  /** Backward-compat: true for any non-builtin (global-custom OR user-custom). */
  custom: boolean;
  source: PieceSource;
  /** Set only when source === 'user-custom'. */
  ownerId?: string;
}

function loadPieceFile(filePath: string): any {
  const raw = readFileSync(filePath, 'utf-8');
  return parse(raw);
}

function listPieceFiles(piecesDir: string): string[] {
  return readdirSync(piecesDir)
    .filter(f => f.endsWith('.yaml'))
    .map(f => join(piecesDir, f));
}

// Phase 4 (SSH): movements using these tools must declare allowed_ssh_connections.
// Kept inline (not imported from engine/) so this API module stays decoupled
// from SSH internals — pieces can be validated even when SSH is disabled.
const SSH_TOOL_NAMES = new Set(['SshExec', 'SshUpload', 'SshDownload']);
const ALLOWED_SSH_ID = /^[a-f0-9-]{8,}$/;

function validatePiece(piece: any): string | null {
  if (!piece.name || !/^[a-z0-9-]+$/.test(piece.name)) return 'name must be lowercase alphanumeric with hyphens';
  if (!Array.isArray(piece.movements) || piece.movements.length === 0) return 'movements must be non-empty array';
  // Required so `while (steps < piece.max_movements)` actually iterates;
  // otherwise the run aborts with "Exceeded max movements (undefined)".
  if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
    return 'max_movements is required (positive integer)';
  }
  const names = new Set(piece.movements.map((m: any) => m.name));
  if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
  // Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
  // Terminal moves (COMPLETE/ABORT/ASK) go through the `complete` tool now.
  // default_next is engine-internal (context overflow / ASK limit / SpawnSubTask
  // unavailable fallback) and still accepts COMPLETE/ABORT/ASK.
  const validRuleNexts = new Set([...names, 'WAIT_SUBTASKS']);
  const validDefaultNexts = new Set([...names, 'COMPLETE', 'ABORT', 'ASK', 'WAIT_SUBTASKS']);
  for (const m of piece.movements) {
    if (m.default_next && !validDefaultNexts.has(m.default_next)) {
      return `movement "${m.name}": default_next "${m.default_next}" is invalid`;
    }
    if (Array.isArray(m.rules)) {
      for (const r of m.rules) {
        if (!validRuleNexts.has(r.next)) {
          if (r.next === 'COMPLETE' || r.next === 'ABORT' || r.next === 'ASK') {
            return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves)`;
          }
          return `movement "${m.name}": rule next "${r.next}" is invalid`;
        }
      }
    }
    // Phase 4: allowed_ssh_connections consistency + format
    const list = m.allowed_ssh_connections;
    const tools = Array.isArray(m.allowed_tools) ? m.allowed_tools : [];
    const hasSshTool = tools.some((t: unknown) => typeof t === 'string' && SSH_TOOL_NAMES.has(t));
    if (list === undefined) {
      if (hasSshTool) {
        return `movement "${m.name}": allowed_ssh_connections is required when allowed_tools contains SSH tool(s)`;
      }
    } else if (!Array.isArray(list)) {
      return `movement "${m.name}": allowed_ssh_connections must be an array`;
    } else {
      for (let i = 0; i < list.length; i++) {
        const entry = list[i];
        if (typeof entry !== 'string') {
          return `movement "${m.name}": allowed_ssh_connections[${i}] must be a string`;
        }
        if (entry !== '*' && !ALLOWED_SSH_ID.test(entry)) {
          return `movement "${m.name}": allowed_ssh_connections[${i}]="${entry}" must be '*' or a lowercase hex/hyphen id (8+ chars)`;
        }
      }
    }
  }
  return null;
}

const VALID_PIECE_NAME = /^[a-z0-9-]+$/;

function validateName(name: string): boolean {
  return VALID_PIECE_NAME.test(name);
}

const VALID_SOURCES = new Set<string>(['builtin', 'user-custom', 'global-custom']);

/**
 * Returns true when `source` is absent (caller wants priority resolution).
 * Returns false when `source` is a known valid value.
 * Returns a 400 error string when `source` is present but unrecognised.
 */
function parseSourceParam(source: string | undefined): { valid: true; value: PieceSource | undefined } | { valid: false; error: string } {
  if (source === undefined) return { valid: true, value: undefined };
  if (VALID_SOURCES.has(source)) return { valid: true, value: source as PieceSource };
  return { valid: false, error: 'Invalid source' };
}

export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
  if (customPiecesDir) {
    const customPath = join(customPiecesDir, `${name}.yaml`);
    if (existsSync(customPath)) return { path: customPath, custom: true };
  }
  const builtinPath = join(piecesDir, `${name}.yaml`);
  if (existsSync(builtinPath)) return { path: builtinPath, custom: false };
  return null;
}

export interface PiecesApiOptions {
  piecesDir: string;
  /** Optional admin-managed shared custom dir (global to all users). */
  customPiecesDir?: string;
  /**
   * Root of per-user data (typically `./data/users`). When set, each authenticated
   * user can read/create/update/delete pieces under `{userPiecesRootDir}/{userId}/pieces/`.
   * When unset, per-user piece support is disabled and non-admin POST returns 503.
   */
  userPiecesRootDir?: string;
}

/** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */
const LOCAL_OWNER = 'local';

type AuthedUser = { id: string; role?: string };

function getUser(req: Request): AuthedUser | undefined {
  return (req as any).user as AuthedUser | undefined;
}

function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
  // No req.user → legacy (auth disabled or test/internal). Treat as admin so
  // existing callers without auth middleware continue to work.
  return !user || user.role === 'admin';
}

/**
 * Lookup priority for a given caller:
 *   1. Caller's own user-custom dir (overrides everything below).
 *      No-auth callers use the 'local' owner id.
 *   2. Global custom dir (admin-managed, all users see).
 *   3. Built-in dir.
 */
function findPieceForCaller(
  opts: PiecesApiOptions,
  user: AuthedUser | undefined,
  name: string,
): { path: string; source: PieceSource; ownerId?: string } | null {
  if (opts.userPiecesRootDir) {
    const ownerId = user?.id ?? LOCAL_OWNER;
    const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${name}.yaml`);
    if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId };
  }
  if (opts.customPiecesDir) {
    const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
    if (existsSync(gcPath)) return { path: gcPath, source: 'global-custom' };
  }
  const biPath = join(opts.piecesDir, `${name}.yaml`);
  if (existsSync(biPath)) return { path: biPath, source: 'builtin' };
  return null;
}

/**
 * Mount the pieces REST API. Read endpoints (GET) require only authentication
 * (any logged-in user can list/read pieces visible to them). Write endpoints
 * (POST/PUT/DELETE) enforce per-piece authorization:
 *  - built-in / global-custom: admin only
 *  - user-custom: owner or admin
 */
export function mountPiecesApi(
  app: Application,
  optsOrPiecesDir: PiecesApiOptions | string,
  legacyCustomPiecesDir?: string,
): void {
  // Backwards-compatible signature: mountPiecesApi(app, piecesDir, customPiecesDir?)
  const opts: PiecesApiOptions = typeof optsOrPiecesDir === 'string'
    ? { piecesDir: optsOrPiecesDir, customPiecesDir: legacyCustomPiecesDir }
    : optsOrPiecesDir;

  app.get('/api/pieces', (req: Request, res: Response) => {
    try {
      const user = getUser(req);
      const pieces: PieceSummary[] = [];

      // Build custom sources (user-custom first, then global-custom).
      // Within custom umbrella: dedup by name so user-custom wins over global-custom.
      // Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom.
      const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
      if (opts.userPiecesRootDir) {
        // No-auth callers use the 'local' owner id, mirroring worker and piece-catalog defaults.
        const ownerId = user?.id ?? LOCAL_OWNER;
        const ucDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
        if (existsSync(ucDir)) customSources.push({ dir: ucDir, source: 'user-custom', ownerId });
      }
      if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
        customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
      }

      // Emit custom pieces (dedup within custom umbrella only).
      const seenCustom = new Set<string>();
      for (const { dir, source, ownerId } of customSources) {
        for (const f of listPieceFiles(dir)) {
          try {
            const p = loadPieceFile(f);
            const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
            if (seenCustom.has(name)) continue;
            seenCustom.add(name);
            let drift: DriftStatus | undefined;
            if (source === 'global-custom' && existsSync(opts.piecesDir)) {
              const builtinPath = join(opts.piecesDir, `${name}.yaml`);
              drift = detectDrift(f, builtinPath);
            }
            pieces.push({
              name,
              description: p.description,
              triggers: p.triggers,
              requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
              custom: true,
              source,
              ownerId,
              drift,
            });
          } catch {
            // skip malformed piece files
          }
        }
      }

      // Always emit ALL built-ins (never hidden by custom pieces of the same name).
      if (existsSync(opts.piecesDir)) {
        for (const f of listPieceFiles(opts.piecesDir)) {
          try {
            const p = loadPieceFile(f);
            const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
            pieces.push({
              name,
              description: p.description,
              triggers: p.triggers,
              requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
              custom: false,
              source: 'builtin',
            });
          } catch {
            // skip malformed piece files
          }
        }
      }

      res.json({ pieces });
    } catch (e) {
      res.status(500).json({ error: `Failed to list pieces: ${e}` });
    }
  });

  app.get('/api/pieces/:name', (req: Request, res: Response) => {
    if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
    try {
      const user = getUser(req);
      const sourceParsed = parseSourceParam(req.query.source as string | undefined);
      if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
      const requestedSource = sourceParsed.value;

      let found: { path: string; source: PieceSource; ownerId?: string } | null = null;

      if (requestedSource === 'builtin') {
        const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
        if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
      } else if (requestedSource === 'user-custom') {
        if (opts.userPiecesRootDir) {
          const ownerId = user?.id ?? LOCAL_OWNER;
          const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
          if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
        }
      } else if (requestedSource === 'global-custom') {
        if (opts.customPiecesDir) {
          const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
          if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
        }
      } else {
        // No source param: priority resolution (user-custom > global-custom > builtin).
        found = findPieceForCaller(opts, user, req.params.name);
      }

      if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
      const piece = loadPieceFile(found.path);
      res.json({
        piece: {
          ...piece,
          requiredMcp: Array.isArray(piece.required_mcp) ? piece.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
        },
        custom: found.source !== 'builtin',
        source: found.source,
        ownerId: found.ownerId,
      });
    } catch (e) {
      res.status(500).json({ error: `Failed to read piece: ${e}` });
    }
  });

  app.put('/api/pieces/:name', (req: Request, res: Response) => {
    if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
    try {
      const user = getUser(req);
      const sourceParsed = parseSourceParam(req.query.source as string | undefined);
      if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
      const requestedSource = sourceParsed.value;

      let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
      if (requestedSource === 'builtin') {
        const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
        if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
      } else if (requestedSource === 'user-custom') {
        if (opts.userPiecesRootDir) {
          const ownerId = user?.id ?? LOCAL_OWNER;
          const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
          if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
        }
      } else if (requestedSource === 'global-custom') {
        if (opts.customPiecesDir) {
          const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
          if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
        }
      } else {
        found = findPieceForCaller(opts, user, req.params.name);
      }

      if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }

      // Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
      if (found.source !== 'user-custom') {
        if (!isAdminOrLegacy(user)) {
          res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
          return;
        }
      } else if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
        // Different user's user-custom — and not admin. Should be unreachable since
        // findPieceForCaller scopes user-custom to the caller, but guard anyway.
        res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
        return;
      }

      const error = validatePiece(req.body);
      if (error) { res.status(400).json({ ok: false, error }); return; }
      if (req.body.name !== req.params.name) {
        res.status(400).json({ ok: false, error: 'Body name must match URL parameter' }); return;
      }
      // Use parseDocument + setIn so untouched regions keep their original
      // formatting (block styles, inline arrays, blank lines, comments).
      // Full re-serialization via stringify would e.g. convert `instruction: |`
      // to `instruction: >`, changing runtime prompt behavior. See #151.
      const originalText = readFileSync(found.path, 'utf-8');
      const patched = patchYaml(originalText, req.body);
      writeFileSync(found.path, patched, 'utf-8');
      res.json({ ok: true });
    } catch (e) {
      res.status(500).json({ error: `Failed to update piece: ${e}` });
    }
  });

  app.post('/api/pieces', (req: Request, res: Response) => {
    try {
      const error = validatePiece(req.body);
      if (error) { res.status(400).json({ ok: false, error }); return; }

      const user = getUser(req);

      // POST always creates in user-custom dir. "+" is always Create Custom.
      // Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins.
      // No-auth / legacy with userPiecesRootDir: use the 'local' owner id so pieces are
      // user-custom (deletable) and never pollute piecesDir.
      // No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir.
      let destDir: string;
      let createdSource: PieceSource;
      if (opts.userPiecesRootDir) {
        // Both authenticated and no-auth go to user-custom dir when userPiecesRootDir is set.
        // Authenticated users: 503 guard below is only needed when root is NOT set.
        const ownerId = user?.id ?? LOCAL_OWNER;
        destDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
        mkdirSync(destDir, { recursive: true });
        createdSource = 'user-custom';
      } else if (user) {
        // Authenticated caller but userPiecesRootDir is not configured — cannot safely write.
        res.status(503).json({ ok: false, error: 'User piece storage not configured' });
        return;
      } else if (opts.customPiecesDir) {
        // Legacy (no-auth) with global custom dir configured.
        destDir = opts.customPiecesDir;
        mkdirSync(destDir, { recursive: true });
        createdSource = 'global-custom';
      } else {
        // Pure legacy single-user: fall back to piecesDir (existing behavior).
        destDir = opts.piecesDir;
        createdSource = 'builtin';
      }

      // Always reject if the name collides with a built-in — Custom and Default are
      // separate namespaces. (This also covers the admin case, since admins should
      // use PUT to update an existing built-in, not POST to create a duplicate.)
      const builtinPath = join(opts.piecesDir, `${req.body.name}.yaml`);
      if (existsSync(builtinPath) && destDir !== opts.piecesDir) {
        res.status(409).json({ ok: false, error: `"${req.body.name}" は組み込み (built-in) Piece と同名です。Custom Piece には別名を付けてください。` });
        return;
      }

      // Reject if the caller's visible piece with this name already exists
      // (global-custom or caller's own user-custom).
      if (findPieceForCaller(opts, user, req.body.name)) {
        res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
      }

      const filePath = join(destDir, `${req.body.name}.yaml`);
      writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
      logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} source=${createdSource} actor=${user?.id ?? 'legacy'}`);
      res.status(201).json({ ok: true, source: createdSource });
    } catch (e) {
      res.status(500).json({ error: `Failed to create piece: ${e}` });
    }
  });

  app.delete('/api/pieces/:name', (req: Request, res: Response) => {
    if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
    try {
      const user = getUser(req);
      const sourceParsed = parseSourceParam(req.query.source as string | undefined);
      if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
      const requestedSource = sourceParsed.value;

      let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
      if (requestedSource === 'builtin') {
        const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
        if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
      } else if (requestedSource === 'user-custom') {
        if (opts.userPiecesRootDir) {
          const ownerId = user?.id ?? LOCAL_OWNER;
          const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
          if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
        }
      } else if (requestedSource === 'global-custom') {
        if (opts.customPiecesDir) {
          const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
          if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
        }
      } else {
        found = findPieceForCaller(opts, user, req.params.name);
      }

      if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }

      // Built-in (Default) pieces are non-deletable for everyone — including admins.
      // This covers general/chat and all other built-ins. Admins may still EDIT
      // built-ins via PUT; only deletion is prohibited.
      if (found.source === 'builtin') {
        res.status(403).json({ ok: false, error: 'Cannot delete a built-in (Default) piece' }); return;
      }

      // Authz for non-builtin sources: global-custom → admin; user-custom → owner.
      if (found.source === 'global-custom') {
        if (!isAdminOrLegacy(user)) {
          res.status(403).json({ ok: false, error: 'Only admins can delete global-custom pieces' });
          return;
        }
      } else if (found.source === 'user-custom') {
        if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
          res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
          return;
        }
      }

      unlinkSync(found.path);
      logger.info(`[pieces-api] deleted piece=${req.params.name} source=${found.source} actor=${user?.id ?? 'legacy'}`);
      res.json({ ok: true });
    } catch (e) {
      res.status(500).json({ error: `Failed to delete piece: ${e}` });
    }
  });
}
