import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, lstatSync, renameSync, rmSync } from 'fs';
import { join, relative, dirname } from 'path';
import { randomBytes } from 'crypto';
import type { SkillCatalog, SkillEntry } from '../engine/skills.js';
import { VALID_SKILL_NAME, scanSkillsDir } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js';
import matter from 'gray-matter';
import { logger } from '../logger.js';
import { handleInstallFromUrl } from './skills-git-install.js';

const MAX_CONTENT_SIZE = 64 * 1024; // 64 KB

export interface MountSkillsApiOptions {
  skillCatalog: SkillCatalog;
  requireAuth?: RequestHandler;
  requireAdmin?: RequestHandler;
  authActive?: boolean;
  auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
  /**
   * Spaces foundation: optional resolver mapping a `spaceId` (from the request
   * query/body) to the `{ rootDir, leafId }` folder whose `skills/` dir holds
   * that space's custom skills (`{rootDir}/{leafId}/skills`). Returns `null`
   * when the space is not visible to the viewer (treated as 404 — never
   * silently falls back to the user folder). When omitted (or no spaceId on the
   * request), skill resolution uses the legacy per-user folder — byte-identical
   * to today.
   */
  resolveSpaceFolder?: (
    spaceId: string,
    viewer?: Express.User,
  ) => Promise<{ rootDir: string; leafId: string } | null>;
}

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

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

function getUserId(req: Request): string {
  const user = getUser(req);
  return user?.id ?? 'local';
}

/** Extract a spaceId from the request (query param wins over body). */
function getSpaceId(req: Request): string | undefined {
  const fromQuery = typeof req.query?.['spaceId'] === 'string' ? (req.query['spaceId'] as string) : undefined;
  const fromBody =
    req.body && typeof (req.body as Record<string, unknown>)['spaceId'] === 'string'
      ? ((req.body as Record<string, unknown>)['spaceId'] as string)
      : undefined;
  return fromQuery ?? fromBody ?? undefined;
}

/**
 * A resolved custom-skill scope for one request. `forbidden` means a spaceId was
 * supplied but is not visible (callers return 404). Otherwise `space` is set
 * when a space folder applies, or undefined for the legacy per-user folder.
 */
type SkillScope =
  | { forbidden: true }
  | { forbidden?: false; space?: { rootDir: string; leafId: string } };

function isAdmin(req: Request): boolean {
  const user = getUser(req);
  return user?.role === 'admin';
}

/**
 * Locate a skill on disk by its catalog (display) name. The catalog lists
 * skills by their SKILL.md frontmatter `name`, which can differ from the
 * folder/file name (e.g. installed under a different name). Path-by-name is
 * tried first (also covers folders without parseable frontmatter), then the
 * directory is scanned for an entry whose frontmatter name matches. All
 * returned paths are direct children of `baseDir` by construction.
 */
function resolveSkillPaths(
  baseDir: string,
  name: string,
  source: 'system' | 'user',
): { dirPath: string | null; filePath: string } | null {
  const dirPath = join(baseDir, name);
  if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) {
    return { dirPath, filePath: join(dirPath, 'SKILL.md') };
  }
  const flatPath = join(baseDir, `${name}.md`);
  if (existsSync(flatPath) && lstatSync(flatPath).isFile()) {
    return { dirPath: null, filePath: flatPath };
  }
  const entry = scanSkillsDir(baseDir, source).find(e => e.name === name);
  if (entry) {
    return { dirPath: entry.dirPath, filePath: entry.filePath };
  }
  return null;
}

/**
 * Recursively list files in a directory, skipping symlinks.
 * Returns paths relative to `baseDir`.
 */
function listDirFiles(baseDir: string, maxDepth: number = 5): string[] {
  const results: string[] = [];

  function walk(dir: string, depth: number): void {
    if (depth > maxDepth) return;
    let entries: string[];
    try {
      entries = readdirSync(dir);
    } catch {
      return;
    }
    for (const entry of entries) {
      const fullPath = join(dir, entry);
      let stat;
      try {
        stat = lstatSync(fullPath);
      } catch {
        continue;
      }
      if (stat.isSymbolicLink()) continue;
      if (stat.isDirectory()) {
        walk(fullPath, depth + 1);
      } else if (stat.isFile()) {
        results.push(relative(baseDir, fullPath));
      }
    }
  }

  walk(baseDir, 0);
  return results;
}

export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void {
  const { skillCatalog } = opts;

  /**
   * Resolve the custom-skill scope for a request, honoring an optional `spaceId`.
   * - No spaceId → legacy per-user folder (`space` undefined).
   * - spaceId + resolver → `space` set when visible; `{ forbidden: true }` when
   *   the resolver returns null. Never falls back to the user folder for an
   *   unresolvable spaceId.
   */
  async function resolveSkillScope(req: Request): Promise<SkillScope> {
    const spaceId = getSpaceId(req);
    if (spaceId && opts.resolveSpaceFolder) {
      const fc = await opts.resolveSpaceFolder(spaceId, getUser(req) as Express.User | undefined);
      if (!fc) return { forbidden: true };
      return { space: { rootDir: fc.rootDir, leafId: fc.leafId } };
    }
    return {};
  }

  /** Merged catalog (system + custom) for the resolved scope. */
  function catalogFor(scope: SkillScope, userId: string): SkillEntry[] {
    if (!('forbidden' in scope) && scope.space) {
      return skillCatalog.getForFolder(scope.space.rootDir, scope.space.leafId);
    }
    return skillCatalog.getForUser(userId);
  }

  /** Body-only content for a named skill within the resolved scope. */
  function contentFor(scope: SkillScope, name: string, userId: string): { content: string; dirPath: string | null } | null {
    if (!('forbidden' in scope) && scope.space) {
      return skillCatalog.getSkillContentForFolder(name, scope.space.rootDir, scope.space.leafId);
    }
    return skillCatalog.getSkillContent(name, userId);
  }

  /** Custom-skill base dir for the resolved scope (user-scope writes). */
  function userSkillDirFor(scope: SkillScope, userId: string): string {
    if (!('forbidden' in scope) && scope.space) {
      return skillCatalog.getSkillDirForFolder(scope.space.rootDir, scope.space.leafId);
    }
    return skillCatalog.getUserSkillDir(userId);
  }

  /** Invalidate the user-scope cache for the resolved scope. */
  function invalidateUserScope(scope: SkillScope, userId: string): void {
    if (!('forbidden' in scope) && scope.space) {
      skillCatalog.invalidateFolder(scope.space.rootDir, scope.space.leafId);
    } else {
      skillCatalog.invalidate(userId);
    }
  }

  // JSON body parser for skills endpoints
  app.use('/api/skills', express.json());

  // Auth gating
  if (opts.authActive && opts.requireAuth) {
    app.use('/api/skills', opts.requireAuth);
  }

  // ── GET /api/skills ── list skills ──────────────────────────────
  app.get('/api/skills', async (req: Request, res: Response) => {
    try {
      const userId = getUserId(req);
      const scope = (req.query.scope as string) ?? 'all';

      if (!['all', 'system', 'user'].includes(scope)) {
        res.status(400).json({ error: 'scope must be one of: all, system, user' });
        return;
      }

      const skillScope = await resolveSkillScope(req);
      if ('forbidden' in skillScope && skillScope.forbidden) {
        res.status(404).json({ error: 'Space not found' });
        return;
      }

      const entries = catalogFor(skillScope, userId);
      const filtered = scope === 'all'
        ? entries
        : entries.filter(e => e.source === scope);

      const skills = filtered.map(e => ({
        name: e.name,
        description: e.description,
        triggers: e.triggers,
        source: e.source,
        hasDir: e.dirPath !== null,
      }));

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

  // ── POST /api/skills/install-from-url ── Git URL install ────────
  // Must be before /:name routes to avoid Express matching 'install-from-url' as :name
  app.post('/api/skills/install-from-url', handleInstallFromUrl({
    skillCatalog: opts.skillCatalog,
    auditLog: opts.auditLog,
  }));

  // ── GET /api/skills/:name ── skill detail ──────────────────────
  app.get('/api/skills/:name', async (req: Request, res: Response) => {
    const { name } = req.params;
    if (!VALID_SKILL_NAME.test(name)) {
      res.status(400).json({ error: 'Invalid skill name' });
      return;
    }

    try {
      const userId = getUserId(req);
      const scopeHint = req.query.scope as string | undefined;

      const skillScope = await resolveSkillScope(req);
      if ('forbidden' in skillScope && skillScope.forbidden) {
        res.status(404).json({ error: 'Space not found' });
        return;
      }

      // Find the entry matching name (and optional scope filter)
      const entries = catalogFor(skillScope, userId);
      let entry: SkillEntry | undefined;
      if (scopeHint && ['system', 'user'].includes(scopeHint)) {
        entry = entries.find(e => e.name === name && e.source === scopeHint);
      }
      if (!entry) {
        entry = entries.find(e => e.name === name);
      }
      if (!entry) {
        res.status(404).json({ error: 'Skill not found' });
        return;
      }

      // Read content via catalog
      const contentResult = contentFor(skillScope, name, userId);
      const content = contentResult?.content ?? '';

      // Read raw file for frontmatter
      let raw = '';
      try {
        raw = readFileSync(entry.filePath, 'utf-8');
      } catch { /* skip */ }

      // File listing for directory skills
      let files: string[] | undefined;
      if (entry.dirPath) {
        files = listDirFiles(entry.dirPath);
      }

      // Security scan
      let findings;
      if (entry.dirPath) {
        findings = scanSkillDirectory(entry.dirPath);
      } else {
        findings = scanSkillContent(raw);
      }

      res.json({
        name: entry.name,
        description: entry.description,
        triggers: entry.triggers,
        source: entry.source,
        hasDir: entry.dirPath !== null,
        content,
        // Full file incl. frontmatter — what the editor must load and save back.
        // Editing/saving the body-only `content` would drop the frontmatter and
        // make the skill unreadable (it vanishes from the catalog).
        raw,
        files,
        findings,
        maxSeverity: maxSeverity(findings),
      });
    } catch (e) {
      res.status(500).json({ error: `Failed to read skill: ${e}` });
    }
  });

  // ── POST /api/skills ── create single-file skill ──────────────
  app.post('/api/skills', async (req: Request, res: Response) => {
    try {
      const { name, content, scope } = req.body ?? {};

      // Validate name
      if (!name || typeof name !== 'string' || !VALID_SKILL_NAME.test(name)) {
        res.status(400).json({ error: 'Invalid skill name (lowercase alphanumeric, hyphens, underscores)' });
        return;
      }

      // Validate scope
      if (!scope || !['system', 'user'].includes(scope)) {
        res.status(400).json({ error: 'scope must be one of: system, user' });
        return;
      }

      // Validate content
      if (!content || typeof content !== 'string') {
        res.status(400).json({ error: 'content is required' });
        return;
      }

      if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
        res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
        return;
      }

      // System scope requires admin
      if (scope === 'system' && !isAdmin(req)) {
        logger.warn(`[skills-api] create rejected skill=${name} scope=system actor=${getUserId(req)} reason=not_admin`);
        res.status(403).json({ error: 'Only admins can create system skills' });
        return;
      }

      const userId = getUserId(req);

      const skillScope = await resolveSkillScope(req);
      if ('forbidden' in skillScope && skillScope.forbidden) {
        logger.warn(`[skills-api] create rejected skill=${name} scope=${scope} actor=${userId} reason=space_not_found`);
        res.status(404).json({ error: 'Space not found' });
        return;
      }

      // Determine destination directory
      const destDir = scope === 'system'
        ? skillCatalog.getSystemDir()
        : userSkillDirFor(skillScope, userId);

      // Check for existing skill (directory or flat file)
      const destDirPath = join(destDir, name);
      const destFlatPath = join(destDir, `${name}.md`);
      if (existsSync(destDirPath) || existsSync(destFlatPath)) {
        logger.warn(`[skills-api] create rejected skill=${name} scope=${scope} actor=${userId} reason=conflict`);
        res.status(409).json({ error: 'Skill already exists' });
        return;
      }

      // The catalog lists skills by their frontmatter `name`. Require valid
      // frontmatter matching the requested name — otherwise the skill would be
      // stored under one name but listed under another (or not listed at all),
      // making it impossible to manage from the UI.
      let fmName = '';
      try {
        const parsed = matter(content);
        fmName = typeof parsed.data?.name === 'string' ? parsed.data.name : '';
      } catch {
        fmName = '';
      }
      if (!fmName || !VALID_SKILL_NAME.test(fmName)) {
        res.status(400).json({
          error: 'Content must begin with YAML frontmatter containing a valid "name" (lowercase alphanumeric, hyphens, underscores) — otherwise the skill will not appear in the skill list.',
        });
        return;
      }
      if (fmName !== name) {
        res.status(400).json({
          error: `Frontmatter name "${fmName}" must match the skill name "${name}".`,
        });
        return;
      }

      // Scan content before writing
      const findings = scanSkillContent(content);
      const severity = maxSeverity(findings);

      // Always create directory format: {name}/SKILL.md
      const tmpDir = join(destDir, `.tmp-${randomBytes(8).toString('hex')}`);
      mkdirSync(tmpDir, { recursive: true });
      writeFileSync(join(tmpDir, 'SKILL.md'), content, 'utf-8');
      renameSync(tmpDir, destDirPath);

      // Invalidate cache
      if (scope === 'system') {
        skillCatalog.refreshSystem();
      } else {
        invalidateUserScope(skillScope, userId);
      }

      // Audit log
      const actor = userId;
      if (opts.auditLog) {
        await opts.auditLog(null, 'skill.create', actor, { name, scope, severity });
      }

      logger.info(`[skills-api] created skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
      res.status(201).json({ name, scope, severity, findings });
    } catch (e) {
      logger.error(`[skills-api] create failed skill=${req.body?.name} actor=${getUserId(req)} error=${e}`);
      res.status(500).json({ error: `Failed to create skill: ${e}` });
    }
  });

  // ── PUT /api/skills/:name ── edit skill content ───────────────
  app.put('/api/skills/:name', async (req: Request, res: Response) => {
    const { name } = req.params;
    if (!VALID_SKILL_NAME.test(name)) {
      res.status(400).json({ error: 'Invalid skill name' });
      return;
    }

    const scope = req.query.scope as string | undefined;
    if (!scope) {
      res.status(400).json({ error: 'scope query parameter is required' });
      return;
    }
    if (!['system', 'user'].includes(scope)) {
      res.status(400).json({ error: 'scope must be one of: system, user' });
      return;
    }

    // System scope requires admin
    if (scope === 'system' && !isAdmin(req)) {
      logger.warn(`[skills-api] update rejected skill=${name} scope=system actor=${getUserId(req)} reason=not_admin`);
      res.status(403).json({ error: 'Only admins can edit system skills' });
      return;
    }

    try {
      const { content } = req.body ?? {};
      if (!content || typeof content !== 'string') {
        res.status(400).json({ error: 'content is required' });
        return;
      }

      if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
        res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
        return;
      }

      const userId = getUserId(req);

      const skillScope = await resolveSkillScope(req);
      if ('forbidden' in skillScope && skillScope.forbidden) {
        logger.warn(`[skills-api] update rejected skill=${name} scope=${scope} actor=${userId} reason=space_not_found`);
        res.status(404).json({ error: 'Space not found' });
        return;
      }

      const baseDir = scope === 'system'
        ? skillCatalog.getSystemDir()
        : userSkillDirFor(skillScope, userId);

      // Find the SKILL.md to update: by folder/file name, falling back to a
      // frontmatter-name scan (folder and display name can differ).
      const resolved = resolveSkillPaths(baseDir, name, scope as 'system' | 'user');
      const targetPath = resolved && existsSync(resolved.filePath) ? resolved.filePath : null;

      if (!targetPath) {
        logger.warn(`[skills-api] update rejected skill=${name} scope=${scope} actor=${userId} reason=not_found`);
        res.status(404).json({ error: 'Skill not found' });
        return;
      }

      // Guard against destroying the skill: the catalog identifies a skill by
      // its frontmatter `name`. Saving content without a valid `name` (e.g. a
      // body-only edit that dropped the frontmatter) would make it unreadable
      // and vanish from the list. Require valid frontmatter matching the skill.
      let fmName = '';
      try {
        const parsed = matter(content);
        fmName = typeof parsed.data?.name === 'string' ? parsed.data.name : '';
      } catch {
        fmName = '';
      }
      if (!fmName || !VALID_SKILL_NAME.test(fmName)) {
        res.status(400).json({
          error: 'Content must begin with YAML frontmatter containing a valid "name" (otherwise the skill becomes unreadable). Edit the full SKILL.md, including the --- frontmatter --- block.',
        });
        return;
      }
      if (fmName !== name) {
        res.status(400).json({
          error: `Frontmatter name "${fmName}" must match the skill name "${name}". Renaming via edit is not supported.`,
        });
        return;
      }

      // Scan new content
      const findings = scanSkillContent(content);
      const severity = maxSeverity(findings);

      // Atomic write: tmpfile in same directory as target → rename
      const targetDir = dirname(targetPath);
      const tmpPath = join(targetDir, `.tmp-${randomBytes(8).toString('hex')}.md`);
      writeFileSync(tmpPath, content, 'utf-8');
      renameSync(tmpPath, targetPath);

      // Invalidate cache
      if (scope === 'system') {
        skillCatalog.refreshSystem();
      } else {
        invalidateUserScope(skillScope, userId);
      }

      // Audit log
      const actor = userId;
      if (opts.auditLog) {
        await opts.auditLog(null, 'skill.update', actor, { name, scope, severity });
      }

      logger.info(`[skills-api] updated skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
      res.json({ ok: true, severity, findings });
    } catch (e) {
      logger.error(`[skills-api] update failed skill=${name} actor=${getUserId(req)} error=${e}`);
      res.status(500).json({ error: `Failed to update skill: ${e}` });
    }
  });

  // ── DELETE /api/skills/:name ── delete skill ──────────────────
  app.delete('/api/skills/:name', async (req: Request, res: Response) => {
    const { name } = req.params;
    if (!VALID_SKILL_NAME.test(name)) {
      res.status(400).json({ error: 'Invalid skill name' });
      return;
    }

    const scope = req.query.scope as string | undefined;
    if (!scope) {
      res.status(400).json({ error: 'scope query parameter is required' });
      return;
    }
    if (!['system', 'user'].includes(scope)) {
      res.status(400).json({ error: 'scope must be one of: system, user' });
      return;
    }

    // System scope requires admin
    if (scope === 'system' && !isAdmin(req)) {
      logger.warn(`[skills-api] delete rejected skill=${name} scope=system actor=${getUserId(req)} reason=not_admin`);
      res.status(403).json({ error: 'Only admins can delete system skills' });
      return;
    }

    try {
      const userId = getUserId(req);

      const skillScope = await resolveSkillScope(req);
      if ('forbidden' in skillScope && skillScope.forbidden) {
        logger.warn(`[skills-api] delete rejected skill=${name} scope=${scope} actor=${userId} reason=space_not_found`);
        res.status(404).json({ error: 'Space not found' });
        return;
      }

      const baseDir = scope === 'system'
        ? skillCatalog.getSystemDir()
        : userSkillDirFor(skillScope, userId);

      // Find the skill: by folder/file name, falling back to a frontmatter-name
      // scan (folder and display name can differ — see resolveSkillPaths).
      const resolved = resolveSkillPaths(baseDir, name, scope as 'system' | 'user');
      if (!resolved) {
        logger.warn(`[skills-api] delete rejected skill=${name} scope=${scope} actor=${userId} reason=not_found`);
        res.status(404).json({ error: 'Skill not found' });
        return;
      }

      if (resolved.dirPath) {
        rmSync(resolved.dirPath, { recursive: true, force: true });
      } else {
        unlinkSync(resolved.filePath);
      }

      // Invalidate cache
      if (scope === 'system') {
        skillCatalog.refreshSystem();
      } else {
        invalidateUserScope(skillScope, userId);
      }

      // Audit log
      const actor = userId;
      if (opts.auditLog) {
        await opts.auditLog(null, 'skill.delete', actor, { name, scope });
      }

      logger.info(`[skills-api] deleted skill=${name} scope=${scope} actor=${actor}`);
      res.json({ ok: true });
    } catch (e) {
      logger.error(`[skills-api] delete failed skill=${name} actor=${getUserId(req)} error=${e}`);
      res.status(500).json({ error: `Failed to delete skill: ${e}` });
    }
  });
}
