import { readFileSync, existsSync, readdirSync, realpathSync } from 'fs';
import { resolve, sep } from 'path';
import type { SkillIndex } from './skill-index';
import { parseSkillFrontmatter } from './skill-parser';
import { normalizeSkillName } from './skill-name';
import { gossipLog, log as _log } from './log';
import { loadMemoryConfig } from './memory-config';
import { emitPipelineSignals } from './signal-helpers';

const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]{0,62}$/;

const MAX_CONTEXTUAL_SKILLS = 3;
/**
 * Fractional boost added to a contextual skill's raw hit count when its
 * `category` frontmatter is in the task's extracted categories. Chosen as 0.5
 * to preserve integer-tie semantics against raw hits:
 *   - non-category 2-hit (2.0) still beats category 1-hit (1.5)
 *   - category 1-hit (1.5) beats non-category 1-hit (1.0)
 *   - 0 raw hits + boost (0.5) does NOT pass MIN_KEYWORD_HITS=1 threshold
 * See consensus f2ff0fac-fb384daa for the pinned design.
 */
const CATEGORY_BOOST = 0.5;
// Lowered from 2 → 1 (consensus c8977bda-37564212): cross-cutting skills
// (citation_grounding, error_handling) starved on well-framed tasks where
// only a single keyword matched. MAX_CONTEXTUAL_SKILLS=3 remains the budget
// safety net — hit count still orders candidates, so low-signal matches lose
// to stronger ones and only fill the remaining slots when nothing else wins.
const MIN_KEYWORD_HITS = 1;

/** Default keyword sets by category — used when skill frontmatter has no explicit keywords */
export const DEFAULT_KEYWORDS: Record<string, string[]> = {
  trust_boundaries: ['auth', 'authentication', 'authorization', 'session', 'cookie', 'token', 'path', 'traversal', 'injection', 'middleware', 'permission', 'role', 'privilege', 'acl'],
  injection_vectors: ['injection', 'xss', 'sql', 'sanitize', 'escape', 'template', 'eval', 'exec', 'html', 'uri', 'command'],
  input_validation: ['validation', 'schema', 'zod', 'parse', 'sanitize', 'input', 'form', 'request', 'coerce', 'transform'],
  concurrency: ['race condition', 'concurrent', 'mutex', 'lock', 'atomic', 'parallel', 'deadlock', 'semaphore'],
  resource_exhaustion: ['memory', 'leak', 'unbounded', 'growth', 'limit', 'cap', 'timeout', 'pool', 'cache', 'backpressure', 'buffer', 'queue', 'throttle'],
  type_safety: ['type guard', 'generic', 'cast', 'assertion', 'narrowing', 'discriminated', 'satisfies'],
  error_handling: ['error handling', 'catch', 'throw', 'exception', 'retry', 'fallback', 'recovery', 'graceful'],
  data_integrity: ['data integrity', 'migration', 'serialize', 'deserialize', 'corrupt', 'consistency', 'invariant', 'transaction', 'rollback', 'idempotent'],
  // Fabrication-class failures: agent cites code that does not match repo state.
  // Kept in sync with CATEGORY_KEYWORDS in skill-engine.ts — both tables drive contextual activation
  // and auto-inference in gossip_signals, so they must agree.
  citation_grounding: ['cite', 'citation', 'line number', 'anchor', 'file path', 'reference', 'fabricat', 'hallucin', 'verify', 'does not exist', 'no such'],
  // Phase 1 dev-quality extensions (consensus 09693c51-184246e5).
  observability: ['log', 'logging', 'metric', 'tracing', 'telemetry', 'monitor', 'dashboard', 'stderr', 'observability'],
  cli_ergonomics: ['cli', 'flag', 'help text', 'error message', 'usage', 'prompt', 'banner', 'spinner'],
  performance: ['latency', 'slow', 'performance', 'n+1', 'uncached', 'readfilesync', 'synchronous', 'hot path'],
  testing: ['test', 'tests', 'testing', 'coverage', 'mock', 'fixture', 'unit test', 'integration test', 'e2e', 'test suite'],
};

export interface DroppedSkill {
  skill: string;
  reason:
    | 'status-failed'
    | 'status-silent'
    /**
     * Skill was previously `passed` and was demoted to `inconclusive` by the
     * drift detector (carries `regressed_from_passed_at` frontmatter). Held out
     * of injection until it re-passes effectiveness checks; see
     * docs/specs/2026-05-13-passed-skill-drift-detection.md §Quarantine.
     */
    | 'status-drift-demoted'
    | 'below-keyword-threshold'
    | 'no-task-provided'
    | 'budget-exceeded'
    /**
     * Skill declared `task_type` that does not match the dispatch's inferred
     * type (e.g. a review-only skill on an implement dispatch). Evaluated
     * BEFORE keyword-hit threshold and category boost so mismatched skills
     * never consume the contextual budget.
     */
    | 'task-type-mismatch'
    /**
     * Skill declared a `scope` array (cross-cutting always-load on matching
     * task types) but the current dispatch's task_type is not in the scope
     * list. Distinct from `task-type-mismatch` so callers can tell the two
     * filter axes apart in observability logs.
     */
    | 'scope-type-mismatch'
    /**
     * Skill is flagged `propagated: true` and the project's memory-config.json
     * has `bundledMemories.enabled: false`. All propagated skills are suppressed.
     */
    | 'kill-switch'
    /**
     * Skill is flagged `propagated: true` and its name appears in
     * `bundledMemories.exclude`. Only this skill is suppressed.
     */
    | 'excluded';
  hits: number;
}

export interface LoadSkillsResult {
  content: string;
  loaded: string[];
  /**
   * Structured drop records. Every skill that was considered but not injected
   * appears here with the reason. Closes the silent-drop observability gap
   * where contextual skills with `task` undefined were previously skipped
   * without appearing in `loaded` OR `dropped`.
   */
  dropped: DroppedSkill[];
  activatedContextual: string[];
  /**
   * Skills activated via the scope axis (task-type-aware always-load).
   * These are distinct from `activatedContextual` (keyword-gated) and do not
   * count against the MAX_CONTEXTUAL_SKILLS budget. Populated only when
   * `dispatchTaskType` is provided and a skill's `scope` array matches it.
   */
  loadedScoped: string[];
  /**
   * Resolved absolute paths of every skill file successfully loaded into `content`.
   * Index-aligned with `loaded` (paths[i] is the resolved path of loaded[i]).
   * Realpath-normalized so symlinked skills dedupe correctly. Empty array when
   * no skills loaded. Consumers (e.g. dispatch-prompt warm cache) use these paths
   * + their mtimeMs to build a skill-set fingerprint without re-walking the FS.
   */
  paths: string[];
}

/**
 * Compute the category match boost for a contextual skill.
 * Returns CATEGORY_BOOST (0.5) if the skill's category is in the task's
 * extracted categories, otherwise 0. Zero-category tasks always return 0.
 */
function categoryBoost(skillCategory: string | undefined, categories: string[]): number {
  if (!skillCategory || categories.length === 0) return 0;
  return categories.includes(skillCategory) ? CATEGORY_BOOST : 0;
}

/**
 * Load skill files for an agent and return structured result.
 *
 * Resolution order per skill:
 * 1. Agent's local skills: .gossip/agents/<id>/skills/
 * 2. Project skills: .gossip/skills/
 * 3. Default skills: packages/orchestrator/src/default-skills/
 *
 * Permanent skills are always loaded. Contextual skills require MIN_KEYWORD_HITS
 * (word-boundary match) against the task string, capped at MAX_CONTEXTUAL_SKILLS.
 *
 * When `taskCategories` is provided, skills whose frontmatter `category` is in
 * that array receive a fractional boost (CATEGORY_BOOST) applied to raw hits
 * BEFORE the threshold gate. A 0-hit skill with boost 0.5 still fails the
 * MIN_KEYWORD_HITS=1 gate (effective 0.5 < 1). A 1-hit skill with boost gets
 * 1.5 effective hits — enough to outrank a non-category 1-hit but not a
 * non-category 2-hit. See consensus f2ff0fac-fb384daa.
 */
export function loadSkills(
  agentId: string,
  skills: string[],
  projectRoot: string,
  index?: SkillIndex,
  task?: string,
  taskCategories?: string[],
  /**
   * Dispatch task type. When provided, skills whose frontmatter `task_type`
   * is set to a CONCRETE type ('review'|'implement'|'research') that does
   * not match are hard-rejected with `task-type-mismatch` BEFORE the
   * keyword-hit gate. Skills with `task_type: 'any'` (the default for
   * unlabelled skills) are unaffected, preserving backwards-compat.
   *
   * When undefined, the filter is skipped entirely (same as pre-migration
   * behaviour) — call sites that don't yet know the dispatch type retain
   * today's semantics.
   */
  dispatchTaskType?: 'review' | 'implement' | 'research',
): LoadSkillsResult {
  const effectiveSkills = index && index.getAgentSlots(agentId).length > 0
    ? index.getEnabledSkills(agentId)
    : skills;

  const categories = taskCategories ?? [];

  // Load kill-switch config once per invocation (not inside the loop).
  const memConfig = loadMemoryConfig(projectRoot);

  const permanent: Array<{ name: string; content: string; path: string }> = [];
  const scoped: Array<{ name: string; content: string; path: string }> = [];
  const contextualCandidates: Array<{ name: string; content: string; path: string; hits: number; rawHits: number; boost: number }> = [];
  const loaded: string[] = [];
  const paths: string[] = [];
  const dropped: DroppedSkill[] = [];
  const activatedContextual: string[] = [];
  const loadedScoped: string[] = [];

  for (const skill of effectiveSkills) {
    const resolved = resolveSkill(agentId, skill, projectRoot);
    if (!resolved) continue;
    const { content, path: resolvedPath } = resolved;

    // Filter by skill effectiveness status written by checkEffectiveness().
    // 'failed' and 'silent_skill' are suppressed — injecting a skill the RL loop
    // has marked as harmful or silent would re-pollute the forward pass.
    //
    // Drift-demoted skills (`status: inconclusive` AND `regressed_from_passed_at` set)
    // are quarantined here too — see drift-detection spec
    // docs/specs/2026-05-13-passed-skill-drift-detection.md §"Quarantine
    // drift-demoted skills". Organic inconclusive (no regressed_from_passed_at)
    // continues to inject unchanged.
    const parsedFrontmatter = parseSkillFrontmatter(content);
    const frontmatterStatus = parsedFrontmatter?.status;
    const isDriftDemoted =
      frontmatterStatus === 'inconclusive' &&
      parsedFrontmatter?.regressed_from_passed_at != null;
    if (
      frontmatterStatus === 'failed' ||
      frontmatterStatus === 'silent_skill' ||
      isDriftDemoted
    ) {
      const dropReason: DroppedSkill['reason'] =
        frontmatterStatus === 'failed'
          ? 'status-failed'
          : isDriftDemoted
            ? 'status-drift-demoted'
            : 'status-silent';
      gossipLog(
        `Skipping ${isDriftDemoted ? 'drift-demoted inconclusive' : frontmatterStatus} skill ${agentId}/${skill} from injection`,
      );
      dropped.push({
        skill,
        reason: dropReason,
        hits: 0,
      });
      continue;
    }
    if (frontmatterStatus === 'flagged_for_manual_review') {
      gossipLog(`Injecting flagged_for_manual_review skill ${agentId}/${skill} — manual review recommended`);
    }

    // Kill-switch filter: propagated skills (ikp §4).
    // Read propagated from raw frontmatter — SkillFrontmatter interface does not
    // expose it yet (intentional: this field is for bundled skills only).
    const isPropagated = /^propagated:\s*true\s*$/m.test(content.split('\n---')[0]);
    if (isPropagated) {
      if (!memConfig.bundledMemories.enabled) {
        const reason = 'kill-switch' as const;
        _log('skill-loader', `Skipping propagated skill ${agentId}/${skill}: bundledMemories.enabled=false`);
        emitPipelineSignals(projectRoot, [{
          type: 'pipeline',
          signal: 'skill_injection_skipped',
          agentId,
          taskId: `skill-loader:${agentId}:${skill}`,
          metadata: { skillName: skill, reason },
          timestamp: new Date().toISOString(),
        }]);
        dropped.push({ skill, reason, hits: 0 });
        continue;
      }
      if (memConfig.bundledMemories.exclude.includes(skill)) {
        const reason = 'excluded' as const;
        _log('skill-loader', `Skipping propagated skill ${agentId}/${skill}: listed in bundledMemories.exclude`);
        emitPipelineSignals(projectRoot, [{
          type: 'pipeline',
          signal: 'skill_injection_skipped',
          agentId,
          taskId: `skill-loader:${agentId}:${skill}`,
          metadata: { skillName: skill, reason },
          timestamp: new Date().toISOString(),
        }]);
        dropped.push({ skill, reason, hits: 0 });
        continue;
      }
    }

    // ── Scope axis (Option A from finding c8977bda-37564212:f3) ──────────────
    // Skills with `scope: [review, ...]` frontmatter are cross-cutting
    // always-loads for matching task types. They bypass keyword matching and
    // the contextual budget entirely. If scope is present and the dispatch
    // type matches → inject unconditionally. If scope is present but the
    // dispatch type does NOT match → drop as scope-type-mismatch. Skills
    // without a scope declaration fall through to the existing task-type /
    // mode / contextual machinery unchanged.
    //
    // When dispatchTaskType is undefined (call sites that don't know the type),
    // scope-declared skills are treated as permanent (injected unconditionally)
    // so the backwards-compat guarantee is preserved — the same as the
    // task_type filter, which is also skipped when dispatchTaskType is absent.
    const frontmatterForScope = parseSkillFrontmatter(content);
    const skillScope = frontmatterForScope?.scope;
    if (skillScope && skillScope.length > 0) {
      if (!dispatchTaskType || skillScope.includes(dispatchTaskType)) {
        // Scope matches (or no dispatch type known) — inject unconditionally.
        scoped.push({ name: skill, content, path: resolvedPath });
      } else {
        // Scope declared but dispatch type not in the list.
        dropped.push({ skill, reason: 'scope-type-mismatch', hits: 0 });
      }
      // Either way, the scope axis has handled this skill — skip the
      // task_type / mode / contextual machinery below.
      continue;
    }
    // ─────────────────────────────────────────────────────────────────────────

    // Task-type axis filter. Evaluated BEFORE keyword-hit counting and the
    // contextual budget, so a mismatched skill never starves a valid one
    // out of the MAX_CONTEXTUAL_SKILLS slots. Skills without an explicit
    // task_type parse to 'any' (see skill-parser coercion), which passes
    // the gate for every dispatch — backwards-compat by default.
    if (dispatchTaskType) {
      const skillTaskType = frontmatterForScope?.task_type ?? 'any';
      if (skillTaskType !== 'any' && skillTaskType !== dispatchTaskType) {
        dropped.push({ skill, reason: 'task-type-mismatch', hits: 0 });
        continue;
      }
    }

    const mode = index?.getSkillMode(agentId, skill) ?? 'permanent';

    if (mode === 'permanent') {
      permanent.push({ name: skill, content, path: resolvedPath });
    } else if (task) {
      const rawHits = countKeywordHits(content, skill, task);
      const frontmatter = parseSkillFrontmatter(content);
      const boost = categoryBoost(frontmatter?.category, categories);
      const effectiveHits = rawHits + boost;
      // Threshold applied to effective hits. With CATEGORY_BOOST=0.5 and
      // MIN_KEYWORD_HITS=1, a 0-hit skill with boost still fails (0.5 < 1)
      // but a 1-hit skill with boost passes (1.5 >= 1) and outranks plain
      // 1-hit candidates during the descending sort below.
      if (effectiveHits >= MIN_KEYWORD_HITS) {
        contextualCandidates.push({ name: skill, content, path: resolvedPath, hits: effectiveHits, rawHits, boost });
      } else {
        // Report raw hits so operators see the real keyword-match count; boost
        // already failed to rescue, so recording effective hits would hide the
        // fact that the skill had 0 keyword matches.
        dropped.push({ skill, reason: 'below-keyword-threshold', hits: rawHits });
      }
    } else {
      // No task provided — record the silent drop so it shows up in observability
      // instead of vanishing between loaded and dropped.
      dropped.push({ skill, reason: 'no-task-provided', hits: 0 });
    }
  }

  // Sort contextual by effective hit count (descending), with alphabetical
  // name as a deterministic tiebreaker. Node's Array.sort has been stable
  // since v12, but relying on input order here would leak skill-index
  // iteration order into activation decisions — the name tiebreaker makes
  // ties deterministic regardless of discovery order.
  contextualCandidates.sort((a, b) => {
    if (b.hits !== a.hits) return b.hits - a.hits;
    return a.name.localeCompare(b.name);
  });
  const accepted = contextualCandidates.slice(0, MAX_CONTEXTUAL_SKILLS);
  const rejected = contextualCandidates.slice(MAX_CONTEXTUAL_SKILLS);

  // Iteration order here defines the index-alignment of `paths` with `loaded`:
  // permanent → scoped → accepted (contextual). Both arrays must push in the
  // same order so consumers can rely on paths[i] being the resolved path of
  // loaded[i] (see LoadSkillsResult.paths docstring).
  for (const s of permanent) {
    loaded.push(s.name);
    paths.push(s.path);
  }
  for (const s of scoped) {
    loaded.push(s.name);
    paths.push(s.path);
    loadedScoped.push(s.name);
  }
  for (const s of accepted) {
    loaded.push(s.name);
    paths.push(s.path);
    activatedContextual.push(s.name);
  }
  for (const s of rejected) dropped.push({ skill: s.name, reason: 'budget-exceeded', hits: s.hits });

  // Strip delimiter strings from skill content to prevent prompt injection
  const sanitizeContent = (c: string) => c.replace(/---\s*END SKILLS\s*---/gi, '--- END-SKILLS ---');
  const sections = [
    ...permanent.map(s => sanitizeContent(s.content)),
    ...scoped.map(s => sanitizeContent(s.content)),
    ...accepted.map(s => sanitizeContent(s.content)),
  ];

  const contentStr = sections.length > 0
    ? '\n\n--- SKILLS ---\n\n' + sections.join('\n\n---\n\n') + '\n\n--- END SKILLS ---\n\n'
    : '';

  return { content: contentStr, loaded, paths, dropped, activatedContextual, loadedScoped };
}

/** Cache compiled regex patterns to avoid per-dispatch recompilation */
const patternCache = new Map<string, RegExp>();
const MAX_PATTERN_CACHE = 500;
const MAX_KEYWORD_LENGTH = 100;

function getPattern(keyword: string): RegExp {
  const capped = keyword.slice(0, MAX_KEYWORD_LENGTH);
  const cached = patternCache.get(capped);
  if (cached !== undefined) {
    // LRU: delete-then-set promotes this key to most-recently-used position.
    // Without this, Map insertion order made eviction FIFO despite the LRU name,
    // so hot keywords could be evicted while cold ones survived.
    patternCache.delete(capped);
    patternCache.set(capped, cached);
    return cached;
  }
  if (patternCache.size >= MAX_PATTERN_CACHE) {
    // Evict least-recently-used entry (first in iteration order after LRU promotion)
    const first = patternCache.keys().next().value;
    if (first !== undefined) patternCache.delete(first);
  }
  const escaped = capped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
  patternCache.set(capped, pattern);
  return pattern;
}

/**
 * Count keyword hits for a contextual skill against a task string.
 * Uses word-boundary matching to prevent false positives (e.g., "auth" won't match "author").
 */
function countKeywordHits(skillContent: string, skillName: string, task: string): number {
  const keywords = getKeywords(skillContent, skillName);
  if (keywords.length === 0) return 0;

  let hits = 0;
  for (const keyword of keywords) {
    if (getPattern(keyword).test(task)) hits++;
  }
  return hits;
}

/**
 * Extract keywords from skill frontmatter or fall back to category defaults.
 */
function getKeywords(content: string, skillName: string): string[] {
  const frontmatter = parseSkillFrontmatter(content);
  if (frontmatter?.keywords && frontmatter.keywords.length > 0) {
    return frontmatter.keywords.map(k => k.toLowerCase());
  }
  if (frontmatter?.category && DEFAULT_KEYWORDS[frontmatter.category]) {
    return DEFAULT_KEYWORDS[frontmatter.category];
  }
  // Fallback: skill name as single keyword. With MIN_KEYWORD_HITS=1, this
  // fallback IS reachable — a skill with broken frontmatter will fire
  // whenever its filename word appears in the task. Warn loudly so missing
  // keywords/category surface quickly instead of silently activating on
  // tenuous filename matches. Per bench review 12827629-fa9a4660:f2 and
  // cross-review 5ad115dd-fbc14d01:f6.
  _log('skill-loader', `WARNING: skill '${skillName}' has no keywords/category frontmatter — contextual activation will fail (using filename fallback)`);
  return [skillName.replace(/-/g, ' ')];
}

/**
 * Resolve a skill name to its file content and resolved absolute path.
 * Returns `null` if no resolution path produced a readable file.
 *
 * The `path` field is realpath-normalized (so symlinked skills dedupe with
 * their target) when realpathSync succeeds; on realpathSync failure we fall
 * back to the non-realpath'd absolute path so a transient FS error never
 * fails the load.
 */
function resolveSkill(
  agentId: string,
  skill: string,
  projectRoot: string,
): { content: string; path: string } | null {
  // Sanitize agentId to prevent path traversal
  if (!SAFE_AGENT_ID.test(agentId)) return null;

  // Use canonical normalization for skill name (consistent with SkillIndex)
  const normalized = normalizeSkillName(skill);
  if (!normalized) return null;
  const filename = `${normalized}.md`;

  const bases = [
    resolve(projectRoot, '.gossip', 'agents', agentId, 'skills'),
    resolve(projectRoot, '.gossip', 'skills'),
    resolve(__dirname, 'default-skills'),
  ];

  for (const base of bases) {
    const candidate = resolve(base, filename);
    // Validate resolved path stays within base directory
    if (!candidate.startsWith(base + sep)) continue;
    if (existsSync(candidate)) {
      // Guard against permission errors, I/O failures, corrupted files.
      // Per bench review 12827629-fa9a4660:f1, an unguarded readFileSync here
      // propagated uncaught through dispatch handlers and could crash the
      // entire gossip_dispatch call. Now we log and fall through to the next
      // base (or return null) instead.
      try {
        const content = readFileSync(candidate, 'utf-8');
        // Realpath-normalize so symlinked skills collapse to a single fingerprint
        // entry. If realpathSync throws (broken symlink, permissions), fall back
        // to the non-realpath'd candidate — a fingerprint glitch is better than
        // failing the load entirely.
        let path: string;
        try {
          path = realpathSync(candidate);
        } catch {
          path = candidate;
        }
        return { content, path };
      } catch (err: any) {
        _log('skill-loader', `Failed to read skill file ${candidate}: ${err?.message ?? err}`);
        continue;
      }
    }
  }
  return null;
}

/** Check if a skill file exists in any resolution path (without reading content). */
export function resolveSkillExists(agentId: string, skill: string, projectRoot: string): boolean {
  return resolveSkill(agentId, skill, projectRoot) !== null;
}

/**
 * Test-only handle for LRU cache behavior verification. Not part of the public
 * API — consumers should not rely on this shape. Exposed so tests can assert
 * eviction order without duplicating the module-scoped cache.
 */
export const __lruInternals = {
  patternCache,
  getPattern,
  MAX_PATTERN_CACHE,
};

/**
 * List available skills for an agent (from all sources, deduplicated).
 */
export function listAvailableSkills(agentId: string, projectRoot: string): string[] {
  const skills = new Set<string>();

  const defaultDir = resolve(__dirname, 'default-skills');
  if (existsSync(defaultDir)) {
    for (const f of readdirSync(defaultDir)) {
      if (f.endsWith('.md')) skills.add(f.replace('.md', ''));
    }
  }

  const projectDir = resolve(projectRoot, '.gossip', 'skills');
  if (existsSync(projectDir)) {
    for (const f of readdirSync(projectDir)) {
      if (f.endsWith('.md')) skills.add(f.replace('.md', ''));
    }
  }

  if (!SAFE_AGENT_ID.test(agentId)) return Array.from(skills).sort();
  const agentDir = resolve(projectRoot, '.gossip', 'agents', agentId, 'skills');
  if (existsSync(agentDir)) {
    for (const f of readdirSync(agentDir)) {
      if (f.endsWith('.md')) skills.add(f.replace('.md', ''));
    }
  }

  return Array.from(skills).sort();
}
