// file: libs/sdk/src/skill/skill-directory-loader.ts

/**
 * Skill directory loader.
 *
 * Loads a complete skill directory containing SKILL.md and optional
 * scripts/, references/, assets/ subdirectories per the Agent Skills spec.
 *
 * @module skill/skill-directory-loader
 */

import { basename, fileExists, joinPath, readdir, readFile, stat } from '@frontmcp/utils';

import { type FrontMcpLogger } from '../common';
import { skillMetadataSchema, type SkillMetadata, type SkillResources } from '../common/metadata/skill.metadata';
import { SkillKind, type SkillFileRecord } from '../common/records/skill.record';
import { InvalidSkillError } from '../errors/sdk.errors';
import { parseSkillMdFrontmatter, skillMdFrontmatterToMetadata } from './skill-md-parser';

/**
 * Result of scanning a skill directory for resource subdirectories.
 */
export interface ScanResult {
  /** Detected resource directories */
  resources: SkillResources;
  /** Whether SKILL.md exists in the directory */
  hasSkillMd: boolean;
}

/**
 * Scan a directory for Agent Skills spec resource subdirectories.
 *
 * Checks for existence of scripts/, references/, and assets/ directories.
 *
 * @param dirPath - Path to the skill directory
 * @returns Scan result with detected resources
 */
export async function scanSkillResources(dirPath: string): Promise<ScanResult> {
  const resources: SkillResources = {};

  const scriptsPath = joinPath(dirPath, 'scripts');
  const referencesPath = joinPath(dirPath, 'references');
  const assetsPath = joinPath(dirPath, 'assets');
  const examplesPath = joinPath(dirPath, 'examples');

  const checks = await Promise.all([
    checkDirectory(scriptsPath),
    checkDirectory(referencesPath),
    checkDirectory(assetsPath),
    checkDirectory(examplesPath),
    fileExists(joinPath(dirPath, 'SKILL.md')),
  ]);

  if (checks[0]) resources.scripts = scriptsPath;
  if (checks[1]) resources.references = referencesPath;
  if (checks[2]) resources.assets = assetsPath;
  if (checks[3]) resources.examples = examplesPath;

  return {
    resources,
    hasSkillMd: checks[4],
  };
}

/**
 * Check if a path exists and is a directory.
 */
async function checkDirectory(path: string): Promise<boolean> {
  try {
    const s = await stat(path);
    return s.isDirectory();
  } catch (err: unknown) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
    throw err;
  }
}

/**
 * Walk a skill directory and reject any nested SKILL.md.
 *
 * Per SEP-2640 §Resource Mapping: "A `SKILL.md` MUST NOT appear in any
 * descendant directory of a skill. The skill directory is the boundary;
 * skills do not nest inside other skills."
 *
 * Returns the relative paths (from `dirPath`) of any nested SKILL.md
 * files found. The root SKILL.md at `dirPath/SKILL.md` is excluded.
 */
export async function findNestedSkillMd(
  dirPath: string,
  maxDepth: number = Number.POSITIVE_INFINITY,
): Promise<string[]> {
  const found: string[] = [];

  async function walk(current: string, depth: number, relPath: string): Promise<void> {
    if (depth > maxDepth) return;
    let entries: string[];
    try {
      entries = await readdir(current);
    } catch (err: unknown) {
      // Missing directory is fine (race with removal); any other error
      // (EACCES, EIO, etc.) signals a real problem and MUST surface so
      // the SEP-2640 no-nesting invariant isn't silently bypassed.
      if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
      throw err;
    }
    for (const entry of entries) {
      const full = joinPath(current, entry);
      let entryStat;
      try {
        entryStat = await stat(full);
      } catch (err: unknown) {
        if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
        throw err;
      }
      if (entryStat.isDirectory()) {
        await walk(full, depth + 1, relPath ? `${relPath}/${entry}` : entry);
      } else if (entryStat.isFile() && entry === 'SKILL.md' && depth > 0) {
        // Skip the root SKILL.md (depth 0) — only descendants are violations
        found.push(relPath ? `${relPath}/SKILL.md` : 'SKILL.md');
      }
    }
  }

  await walk(dirPath, 0, '');
  // Sort for deterministic output across filesystems / Node versions —
  // `readdir` ordering is not guaranteed.
  return found.sort();
}

/**
 * Load a full skill directory into a SkillFileRecord.
 *
 * Reads SKILL.md, parses frontmatter, detects resource directories,
 * and validates the result.
 *
 * @param dirPath - Path to the skill directory
 * @param logger - Optional SDK logger for structured warnings
 * @returns A SkillFileRecord ready for registration
 * @throws Error if SKILL.md is not found or metadata is invalid
 */
export async function loadSkillDirectory(dirPath: string, logger?: FrontMcpLogger): Promise<SkillFileRecord> {
  const skillMdPath = joinPath(dirPath, 'SKILL.md');

  // Verify SKILL.md exists
  const exists = await fileExists(skillMdPath);
  if (!exists) {
    throw new InvalidSkillError(basename(dirPath), 'SKILL.md not found in directory');
  }

  // Read and parse SKILL.md
  let content: string;
  let partialMetadata: Partial<SkillMetadata>;
  try {
    content = await readFile(skillMdPath, 'utf-8');
    const { frontmatter, body } = parseSkillMdFrontmatter(content);
    partialMetadata = skillMdFrontmatterToMetadata(frontmatter, body);
  } catch (err) {
    const name = basename(dirPath);
    const message = err instanceof Error ? err.message : String(err);
    throw new InvalidSkillError(name, `Failed to read or parse SKILL.md: ${message}`);
  }

  // Scan for resource directories
  const { resources } = await scanSkillResources(dirPath);
  const hasResources = resources.scripts || resources.references || resources.assets || resources.examples;
  if (hasResources) {
    partialMetadata.resources = resources;
  }

  // SEP-2640 §Resource Mapping: skills MUST NOT contain nested SKILL.md
  // files in descendant directories. Loading a directory that violates
  // this would produce ambiguous URIs under `skill://`.
  const nested = await findNestedSkillMd(dirPath);
  if (nested.length > 0) {
    const skillName = partialMetadata.name ?? basename(dirPath);
    throw new InvalidSkillError(
      skillName,
      `Skill directory contains nested SKILL.md files (forbidden by SEP-2640 §Resource Mapping): ${nested.join(', ')}`,
    );
  }

  // Validate name matches directory name (per spec recommendation)
  const dirName = basename(dirPath);
  if (partialMetadata.name && dirName && partialMetadata.name !== dirName) {
    const msg = `Skill name "${partialMetadata.name}" does not match directory name "${dirName}"`;
    if (logger) {
      logger.warn(msg, { skillName: partialMetadata.name, dirName });
    } else {
      console.warn(msg);
    }
  }

  // Ensure required fields
  if (!partialMetadata.name) {
    throw new InvalidSkillError('unknown', "SKILL.md is missing required 'name' field in frontmatter");
  }
  const skillName = partialMetadata.name;
  if (!partialMetadata.description) {
    throw new InvalidSkillError(skillName, "SKILL.md is missing required 'description' field in frontmatter");
  }
  if (!partialMetadata.instructions) {
    throw new InvalidSkillError(skillName, 'SKILL.md has no body content for instructions');
  }

  const metadata = partialMetadata as SkillMetadata;

  // Validate against schema
  const parsed = skillMetadataSchema.safeParse(metadata);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
    throw new InvalidSkillError(partialMetadata.name ?? 'unknown', issues);
  }

  return {
    kind: SkillKind.FILE,
    provide: Symbol(`skill:${metadata.name}`),
    metadata: parsed.data as SkillMetadata,
    filePath: skillMdPath,
  };
}

/**
 * Convenience helper to load a skill directory.
 * Returns a SkillFileRecord suitable for passing to `@FrontMcp({ skills: [...] })`.
 *
 * @param dirPath - Path to the skill directory containing SKILL.md
 * @param logger - Optional SDK logger for structured warnings
 * @returns A SkillFileRecord
 *
 * @example
 * ```typescript
 * const mySkill = await skillDir('./skills/review-pr');
 *
 * @FrontMcp({ skills: [mySkill] })
 * class MyApp {}
 * ```
 */
export async function skillDir(dirPath: string, logger?: FrontMcpLogger): Promise<SkillFileRecord> {
  return loadSkillDirectory(dirPath, logger);
}
