/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { randomUUID } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as Diff from 'diff';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
import {
  type AppliedSkillPatchTarget,
  type InboxMemoryPatchKind,
  applyParsedPatchesWithAllowedRoots,
  applyParsedSkillPatches,
  findDisallowedMemoryPatchTarget,
  getInboxMemoryPatchSourcePath,
  getMemoryPatchTargetValidationContext,
  isResolvedMemoryPatchTargetAllowed,
  hasParsedPatchHunks,
  isProjectSkillPatchTarget,
  listInboxPatchFiles,
  listValidInboxPatchFiles,
  normalizeInboxMemoryPatchPath,
  resolveMemoryPatchTargetWithinAllowedSet,
  validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
import type { MessageActionReturn } from './types.js';

export type { InboxMemoryPatchKind } from '../services/memoryPatchUtils.js';
export { getAllowedMemoryPatchRoots } from '../services/memoryPatchUtils.js';

export function showMemory(config: Config): MessageActionReturn {
  const memoryContent = flattenMemory(config.getUserMemory());
  const fileCount = config.getGeminiMdFileCount() || 0;
  let content: string;

  if (memoryContent.length > 0) {
    content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`;
  } else {
    content = 'Memory is currently empty.';
  }

  return {
    type: 'message',
    messageType: 'info',
    content,
  };
}

export async function refreshMemory(
  config: Config,
): Promise<MessageActionReturn> {
  await config.getMemoryContextManager()?.refresh();
  const memoryContent = flattenMemory(config.getUserMemory());
  const fileCount = config.getGeminiMdFileCount();

  config.updateSystemInstructionIfInitialized();
  let content: string;

  if (memoryContent.length > 0) {
    content = `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s)`;
  } else {
    content = 'Memory reloaded successfully. No memory content found';
  }

  return {
    type: 'message',
    messageType: 'info',
    content,
  };
}

export function listMemoryFiles(config: Config): MessageActionReturn {
  const filePaths = config.getGeminiMdFilePaths() || [];
  const fileCount = filePaths.length;
  let content: string;

  if (fileCount > 0) {
    content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join(
      '\n',
    )}`;
  } else {
    content = 'No GEMINI.md files in use.';
  }

  return {
    type: 'message',
    messageType: 'info',
    content,
  };
}

/**
 * Represents a skill found in the extraction inbox.
 */
export interface InboxSkill {
  /** Directory name in the inbox. */
  dirName: string;
  /** Skill name from SKILL.md frontmatter. */
  name: string;
  /** Skill description from SKILL.md frontmatter. */
  description: string;
  /** Raw SKILL.md content for preview. */
  content: string;
  /** When the skill was extracted (ISO string), if known. */
  extractedAt?: string;
}

/**
 * Scans the skill extraction inbox and returns structured data
 * for each extracted skill.
 */
export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
  const skillsDir = config.storage.getProjectSkillsMemoryDir();

  let entries: Array<import('node:fs').Dirent>;
  try {
    entries = await fs.readdir(skillsDir, { withFileTypes: true });
  } catch {
    return [];
  }

  const dirs = entries.filter((e) => e.isDirectory());
  if (dirs.length === 0) {
    return [];
  }

  // Load extraction state to get dates
  const memoryDir = config.storage.getProjectMemoryTempDir();
  const statePath = path.join(memoryDir, '.extraction-state.json');
  const state = await readExtractionState(statePath);

  // Build a map: skillDirName → extractedAt
  const skillDateMap = new Map<string, string>();
  for (const run of state.runs) {
    for (const skillName of run.skillsCreated) {
      skillDateMap.set(skillName, run.runAt);
    }
  }

  const skills: InboxSkill[] = [];
  for (const dir of dirs) {
    const skillPath = path.join(skillsDir, dir.name, 'SKILL.md');
    const skillDef = await loadSkillFromFile(skillPath);
    if (!skillDef) continue;

    let content = '';
    try {
      content = await fs.readFile(skillPath, 'utf-8');
    } catch {
      // Best-effort — preview will be empty
    }

    skills.push({
      dirName: dir.name,
      name: skillDef.name,
      description: skillDef.description,
      content,
      extractedAt: skillDateMap.get(dir.name),
    });
  }

  return skills;
}

export type InboxSkillDestination = 'global' | 'project';

function isValidInboxSkillDirName(dirName: string): boolean {
  return (
    dirName.length > 0 &&
    dirName !== '.' &&
    dirName !== '..' &&
    !dirName.includes('/') &&
    !dirName.includes('\\')
  );
}

function isValidInboxPatchFileName(fileName: string): boolean {
  return (
    fileName.length > 0 &&
    fileName !== '.' &&
    fileName !== '..' &&
    !fileName.includes('/') &&
    !fileName.includes('\\')
  );
}

async function getSkillNameForConflictCheck(
  skillDir: string,
  fallbackName: string,
): Promise<string> {
  const skill = await loadSkillFromFile(path.join(skillDir, 'SKILL.md'));
  return skill?.name ?? fallbackName;
}

/**
 * Copies an inbox skill to the target skills directory.
 */
export async function moveInboxSkill(
  config: Config,
  dirName: string,
  destination: InboxSkillDestination,
): Promise<{ success: boolean; message: string }> {
  if (!isValidInboxSkillDirName(dirName)) {
    return {
      success: false,
      message: 'Invalid skill name.',
    };
  }

  const skillsDir = config.storage.getProjectSkillsMemoryDir();
  const sourcePath = path.join(skillsDir, dirName);

  try {
    await fs.access(sourcePath);
  } catch {
    return {
      success: false,
      message: `Skill "${dirName}" not found in inbox.`,
    };
  }

  const targetBase =
    destination === 'global'
      ? Storage.getUserSkillsDir()
      : config.storage.getProjectSkillsDir();
  const targetPath = path.join(targetBase, dirName);
  const skillName = await getSkillNameForConflictCheck(sourcePath, dirName);

  try {
    await fs.access(targetPath);
    return {
      success: false,
      message: `A skill named "${skillName}" already exists in ${destination} skills.`,
    };
  } catch {
    // Target doesn't exist — good
  }

  const existingTargetSkills = await loadSkillsFromDir(targetBase);
  if (existingTargetSkills.some((skill) => skill.name === skillName)) {
    return {
      success: false,
      message: `A skill named "${skillName}" already exists in ${destination} skills.`,
    };
  }

  await fs.mkdir(targetBase, { recursive: true });
  await fs.cp(sourcePath, targetPath, { recursive: true });

  // Remove from inbox after successful copy
  await fs.rm(sourcePath, { recursive: true, force: true });

  const label =
    destination === 'global' ? '~/.gemini/skills' : '.gemini/skills';
  return {
    success: true,
    message: `Moved "${dirName}" to ${label}.`,
  };
}

/**
 * Removes a skill from the extraction inbox.
 */
export async function dismissInboxSkill(
  config: Config,
  dirName: string,
): Promise<{ success: boolean; message: string }> {
  if (!isValidInboxSkillDirName(dirName)) {
    return {
      success: false,
      message: 'Invalid skill name.',
    };
  }

  const skillsDir = config.storage.getProjectSkillsMemoryDir();
  const sourcePath = path.join(skillsDir, dirName);

  try {
    await fs.access(sourcePath);
  } catch {
    return {
      success: false,
      message: `Skill "${dirName}" not found in inbox.`,
    };
  }

  await fs.rm(sourcePath, { recursive: true, force: true });

  return {
    success: true,
    message: `Dismissed "${dirName}" from inbox.`,
  };
}

/**
 * A parsed patch entry from a unified diff, representing changes to a single file.
 */
export interface InboxPatchEntry {
  /** Absolute path to the target file (or '/dev/null' for new files). */
  targetPath: string;
  /** The unified diff text for this single file. */
  diffContent: string;
}

/**
 * Represents a .patch file found in the extraction inbox.
 */
export interface InboxPatch {
  /** The .patch filename (e.g. "update-docs-writer.patch"). */
  fileName: string;
  /** Display name (filename without .patch extension). */
  name: string;
  /** Per-file entries parsed from the patch. */
  entries: InboxPatchEntry[];
  /** When the patch was extracted (ISO string), if known. */
  extractedAt?: string;
}

/**
 * One target file inside a memory patch (most patches will have a single entry).
 */
export interface InboxMemoryPatchEntry {
  /** Absolute path of the markdown file the patch will modify. */
  targetPath: string;
  /** Unified diff for this single file (used for UI preview). */
  diffContent: string;
  /** True when this entry creates a new file (`/dev/null` source). */
  isNewFile: boolean;
}

/**
 * Represents the AGGREGATED inbox state for one memory kind. Even when the
 * extraction agent has produced multiple `.patch` files under
 * `<memoryDir>/.inbox/<kind>/` (e.g. across several sessions), the inbox
 * surfaces them as ONE entry per kind. Apply runs each underlying patch in
 * sequence; Dismiss removes them all.
 */
export interface InboxMemoryPatch {
  /** Memory tier — one entry per kind in the inbox. */
  kind: InboxMemoryPatchKind;
  /**
   * Stable identifier for this consolidated entry. Set to the kind itself
   * (`"private"` or `"global"`); kept in the type for backwards-compat with
   * the per-file API the dialog passes through.
   */
  relativePath: string;
  /** Display name shown in the inbox row (e.g. `"Private memory"`). */
  name: string;
  /** All hunks from all underlying source patches, concatenated in order. */
  entries: InboxMemoryPatchEntry[];
  /** Basenames of the underlying `.patch` files being aggregated. */
  sourceFiles: string[];
  /** Most recent mtime across the source files (ISO string), if known. */
  extractedAt?: string;
}

interface StagedInboxPatchTarget {
  targetPath: string;
  tempPath: string;
  original: string;
  isNewFile: boolean;
  mode?: number;
}

/**
 * Reconstructs a unified diff string for a single ParsedDiff entry.
 */
function formatParsedDiff(parsed: Diff.StructuredPatch): string {
  const lines: string[] = [];
  if (parsed.oldFileName) {
    lines.push(`--- ${parsed.oldFileName}`);
  }
  if (parsed.newFileName) {
    lines.push(`+++ ${parsed.newFileName}`);
  }
  for (const hunk of parsed.hunks) {
    lines.push(
      `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
    );
    for (const line of hunk.lines) {
      lines.push(line);
    }
  }
  return lines.join('\n');
}

function getErrorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

async function getFileMtimeIso(filePath: string): Promise<string | undefined> {
  try {
    const stats = await fs.stat(filePath);
    return stats.mtime.toISOString();
  } catch {
    return undefined;
  }
}

async function patchTargetsProjectSkills(
  targetPaths: string[],
  config: Config,
) {
  for (const targetPath of targetPaths) {
    if (await isProjectSkillPatchTarget(targetPath, config)) {
      return true;
    }
  }
  return false;
}

async function getPatchExtractedAt(
  patchPath: string,
): Promise<string | undefined> {
  try {
    const stats = await fs.stat(patchPath);
    return stats.mtime.toISOString();
  } catch {
    return undefined;
  }
}

function formatMemoryKindLabel(kind: InboxMemoryPatchKind): string {
  switch (kind) {
    case 'private':
      return 'Private memory';
    case 'global':
      return 'Global memory';
    default:
      return kind;
  }
}

/**
 * Scans `<memoryDir>/.inbox/{private,global}/` and returns ONE consolidated
 * inbox entry per kind. Each entry aggregates all hunks from every valid
 * underlying `.patch` file. Patches that fail validation (unparseable, no
 * hunks, target outside the allowed target set) are silently skipped so they
 * don't pollute the inbox UI.
 */
export async function listInboxMemoryPatches(
  config: Config,
): Promise<InboxMemoryPatch[]> {
  const kinds: InboxMemoryPatchKind[] = ['private', 'global'];
  const aggregated: InboxMemoryPatch[] = [];

  for (const kind of kinds) {
    const validationContext = await getMemoryPatchTargetValidationContext(
      config,
      kind,
    );
    const patchFiles = await listInboxPatchFiles(config, kind);

    const aggregatedEntries: InboxMemoryPatchEntry[] = [];
    const sourceFiles: string[] = [];
    let latestMtime: string | undefined;

    for (const sourcePath of patchFiles) {
      let content: string;
      try {
        content = await fs.readFile(sourcePath, 'utf-8');
      } catch {
        continue;
      }

      let parsed: Diff.StructuredPatch[];
      try {
        parsed = Diff.parsePatch(content);
      } catch {
        continue;
      }
      if (!hasParsedPatchHunks(parsed)) {
        continue;
      }

      const validated = validateParsedSkillPatchHeaders(parsed);
      if (!validated.success) {
        continue;
      }

      // Skip the entire source file if ANY of its targets escapes the kind's
      // allowed target set.
      const targetsAllAllowed = await Promise.all(
        validated.patches.map(
          async (header) =>
            (await resolveMemoryPatchTargetWithinAllowedSet(
              header.targetPath,
              validationContext,
            )) !== undefined,
        ),
      );
      if (!targetsAllAllowed.every(Boolean)) {
        continue;
      }

      for (const [index, header] of validated.patches.entries()) {
        aggregatedEntries.push({
          targetPath: header.targetPath,
          isNewFile: header.isNewFile,
          diffContent: formatParsedDiff(parsed[index]),
        });
      }

      sourceFiles.push(path.basename(sourcePath));

      const mtime = await getFileMtimeIso(sourcePath);
      if (mtime && (!latestMtime || mtime > latestMtime)) {
        latestMtime = mtime;
      }
    }

    if (aggregatedEntries.length === 0) {
      continue;
    }

    aggregated.push({
      kind,
      relativePath: kind,
      name: formatMemoryKindLabel(kind),
      entries: aggregatedEntries,
      sourceFiles,
      extractedAt: latestMtime,
    });
  }

  return aggregated;
}

/**
 * Applies an inbox memory patch atomically and removes the patch on success.
 *
 * Process:
 *   1. Parse + validate the patch headers (absolute paths only, no `a/`/`b/`).
 *   2. Dry-run the patch against the current target content (or empty for
 *      `/dev/null` creation patches).
 *   3. Stage the patched content to a temp file, then rename into place.
 *   4. On any failure, restore previous content from the staged snapshot and
 *      leave the inbox patch intact for retry.
 */
/**
 * Applies one inbox memory entry. Two modes:
 *   - Aggregate mode (`relativePath === kind`): walk every `.patch` file in
 *     the kind's inbox directory and apply each one in lexical order. Each
 *     file is its own atomic transaction; failures don't block subsequent
 *     successes. Returns an aggregated summary (e.g. "Applied 3 of 4 sub-
 *     patches; 1 failed: …").
 *   - Single-file mode (legacy): `relativePath` points at a specific
 *     `.patch` filename. Used by tests and direct callers.
 */
export async function applyInboxMemoryPatch(
  config: Config,
  kind: InboxMemoryPatchKind,
  relativePath: string,
): Promise<{ success: boolean; message: string }> {
  if (relativePath === kind) {
    return applyAllInboxPatchesForKind(config, kind);
  }

  const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
  if (!normalizedPath) {
    return { success: false, message: 'Invalid memory patch path.' };
  }

  const sourcePath = await getInboxMemoryPatchSourcePath(
    config,
    kind,
    normalizedPath,
  );
  if (!sourcePath) {
    return { success: false, message: 'Invalid memory patch path.' };
  }

  return applyMemoryPatchFile(config, kind, sourcePath, normalizedPath);
}

async function applyAllInboxPatchesForKind(
  config: Config,
  kind: InboxMemoryPatchKind,
): Promise<{ success: boolean; message: string }> {
  // Only attempt patches the user actually saw in the inbox listing.
  // Files that were filtered (bad headers, escape allowed root, etc.) stay
  // on disk untouched.
  const patchFiles = await listValidInboxPatchFiles(config, kind);
  if (patchFiles.length === 0) {
    return {
      success: false,
      message: `No ${kind} memory patches in inbox.`,
    };
  }

  const successes: string[] = [];
  const failures: Array<{ name: string; reason: string }> = [];
  let pointersAddedAcrossPatches: string[] = [];

  for (const sourcePath of patchFiles) {
    const basename = path.basename(sourcePath);
    const result = await applyMemoryPatchFile(
      config,
      kind,
      sourcePath,
      basename,
    );
    if (result.success) {
      successes.push(basename);
      // Surface auto-added MEMORY.md pointer info if present.
      const pointerMatch = result.message.match(
        /Auto-added MEMORY\.md pointer for ([^.]+)\./,
      );
      if (pointerMatch) {
        pointersAddedAcrossPatches.push(pointerMatch[1]);
      }
    } else {
      failures.push({ name: basename, reason: result.message });
    }
  }

  // De-dup pointer notes (same sibling could have been mentioned twice).
  pointersAddedAcrossPatches = Array.from(new Set(pointersAddedAcrossPatches));

  const total = successes.length + failures.length;
  if (failures.length === 0) {
    const pointerNote =
      pointersAddedAcrossPatches.length > 0
        ? ` Auto-added MEMORY.md pointer(s) for ${pointersAddedAcrossPatches.join('; ')}.`
        : '';
    return {
      success: true,
      message: `Applied all ${successes.length} ${kind} memory patch${
        successes.length === 1 ? '' : 'es'
      }.${pointerNote}`,
    };
  }

  const failureSummary = failures
    .map((f) => `"${f.name}" — ${f.reason}`)
    .join('; ');
  // Any failure → success=false so the dialog keeps the inbox entry visible
  // (the user needs to see and retry/dismiss the remaining sub-patches).
  // The successful sub-patches have already been removed from disk by
  // applyMemoryPatchFile, so the next listing will show only the failures.
  return {
    success: false,
    message:
      `Applied ${successes.length} of ${total} ${kind} memory patches. ` +
      `${failures.length} failed: ${failureSummary}`,
  };
}

async function canonicalizeDirIfPresent(dirPath: string): Promise<string> {
  try {
    return await fs.realpath(dirPath);
  } catch {
    return path.resolve(dirPath);
  }
}

/**
 * Returns the basenames of any sibling .md files (not MEMORY.md itself) that
 * are being CREATED by this patch under `<memoryDir>/` directly.
 */
function findSiblingCreations(
  appliedResults: readonly AppliedSkillPatchTarget[],
  memoryDir: string,
): AppliedSkillPatchTarget[] {
  return appliedResults.filter((entry) => {
    if (!entry.isNewFile) return false;
    const targetDir = path.dirname(path.resolve(entry.targetPath));
    if (targetDir !== memoryDir) return false;
    const basename = path.basename(entry.targetPath);
    if (basename.toLowerCase() === 'memory.md') return false;
    return basename.toLowerCase().endsWith('.md');
  });
}

interface AutoPointerAugmentation {
  /** Patch results, possibly with a synthesized/extended MEMORY.md entry. */
  results: AppliedSkillPatchTarget[];
  /** Sibling basenames a pointer was auto-added for (empty if none). */
  pointersAdded: string[];
}

/**
 * MEMORY.md is the index that gets injected into future agent contexts.
 * Sibling .md files in `<memoryDir>/` are loaded ON DEMAND by the runtime
 * agent via `read_file` — but only IF MEMORY.md references them by name
 * (see `getUserProjectMemoryPaths`).
 *
 * If a private patch creates a sibling without also referencing it from
 * MEMORY.md, the new file would never be discoverable. Rather than rejecting
 * the patch (bad UX), we auto-bundle a MEMORY.md update that adds a
 * one-line pointer per orphan sibling. The augmented entry is then committed
 * atomically alongside the rest of the patch.
 *
 * If the patch already updates/creates MEMORY.md and the new content already
 * references the sibling, no augmentation is needed.
 */
async function augmentWithAutoPointers(
  config: Config,
  appliedResults: readonly AppliedSkillPatchTarget[],
): Promise<AutoPointerAugmentation> {
  const memoryDir = await canonicalizeDirIfPresent(
    config.storage.getProjectMemoryTempDir(),
  );
  const memoryMdPath = path.join(memoryDir, 'MEMORY.md');

  const siblingCreations = findSiblingCreations(appliedResults, memoryDir);
  if (siblingCreations.length === 0) {
    return { results: [...appliedResults], pointersAdded: [] };
  }

  // Locate (or initialize) the MEMORY.md entry we'll mutate.
  const existingIdx = appliedResults.findIndex(
    (entry) => path.resolve(entry.targetPath) === memoryMdPath,
  );
  let memoryEntry: AppliedSkillPatchTarget;
  if (existingIdx >= 0) {
    memoryEntry = { ...appliedResults[existingIdx] };
  } else {
    let originalContent = '';
    let isNewFile = true;
    try {
      originalContent = await fs.readFile(memoryMdPath, 'utf-8');
      isNewFile = false;
    } catch {
      // MEMORY.md doesn't exist yet — we'll create it with a default heading.
    }
    memoryEntry = {
      targetPath: memoryMdPath,
      original: originalContent,
      patched: isNewFile ? '# Project Memory\n' : originalContent,
      isNewFile,
    };
  }

  const pointersAdded: string[] = [];
  for (const sibling of siblingCreations) {
    const basename = path.basename(sibling.targetPath);
    // Resolve to absolute path so the runtime agent can `read_file` the
    // sibling directly without needing to know <memoryDir>.
    const absoluteTarget = path.resolve(sibling.targetPath);
    // Existing reference can be by either basename or absolute path; both count.
    if (
      memoryEntry.patched.includes(basename) ||
      memoryEntry.patched.includes(absoluteTarget)
    ) {
      continue; // Already referenced.
    }
    const stem = basename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').trim();
    const pointer = `- See ${absoluteTarget} for ${stem || basename} notes.`;
    memoryEntry.patched = memoryEntry.patched.endsWith('\n')
      ? `${memoryEntry.patched}${pointer}\n`
      : `${memoryEntry.patched}\n${pointer}\n`;
    pointersAdded.push(basename);
  }

  if (pointersAdded.length === 0) {
    return { results: [...appliedResults], pointersAdded: [] };
  }

  const results = [...appliedResults];
  if (existingIdx >= 0) {
    results[existingIdx] = memoryEntry;
  } else {
    results.push(memoryEntry);
  }
  return { results, pointersAdded };
}

/**
 * Internal helper: parses, validates, and atomically commits a memory patch
 * file at a known absolute path. Separated from `applyInboxMemoryPatch` so the
 * path-resolution and patch-apply concerns stay testable independently.
 */
async function applyMemoryPatchFile(
  config: Config,
  kind: InboxMemoryPatchKind,
  patchPath: string,
  displayName: string,
): Promise<{ success: boolean; message: string }> {
  let content: string;
  try {
    content = await fs.readFile(patchPath, 'utf-8');
  } catch {
    return {
      success: false,
      message: `Memory patch "${displayName}" not found in inbox.`,
    };
  }

  let parsed: Diff.StructuredPatch[];
  try {
    parsed = Diff.parsePatch(content);
  } catch (error) {
    return {
      success: false,
      message: `Failed to parse memory patch "${displayName}": ${getErrorMessage(error)}`,
    };
  }
  if (!hasParsedPatchHunks(parsed)) {
    return {
      success: false,
      message: `Memory patch "${displayName}" contains no valid hunks.`,
    };
  }

  const validationContext = await getMemoryPatchTargetValidationContext(
    config,
    kind,
  );
  const disallowedTargetPath = await findDisallowedMemoryPatchTarget(
    parsed,
    validationContext,
  );
  if (disallowedTargetPath) {
    return {
      success: false,
      message: `Memory patch "${displayName}" targets a file outside the ${kind} memory root or target allowlist: ${disallowedTargetPath}`,
    };
  }

  const applied = await applyParsedPatchesWithAllowedRoots(
    parsed,
    validationContext.allowedRoots,
    {
      isResolvedTargetAllowed: (resolvedTargetPath) =>
        isResolvedMemoryPatchTargetAllowed(
          resolvedTargetPath,
          validationContext,
        ),
    },
  );
  if (!applied.success) {
    switch (applied.reason) {
      case 'missingTargetPath':
        return {
          success: false,
          message: `Memory patch "${displayName}" is missing a target file path.`,
        };
      case 'invalidPatchHeaders':
        return {
          success: false,
          message: `Memory patch "${displayName}" has invalid diff headers.`,
        };
      case 'outsideAllowedRoots':
        return {
          success: false,
          message: `Memory patch "${displayName}" targets a file outside the ${kind} memory root or target allowlist: ${applied.targetPath}`,
        };
      case 'newFileAlreadyExists':
        return {
          success: false,
          message: `Memory patch "${displayName}" declares a new file, but the target already exists: ${applied.targetPath}`,
        };
      case 'targetNotFound':
        return {
          success: false,
          message: `Target file not found: ${applied.targetPath}`,
        };
      case 'doesNotApply':
        return {
          success: false,
          message: applied.isNewFile
            ? `Memory patch "${displayName}" failed to apply for new file ${applied.targetPath}.`
            : `Memory patch does not apply cleanly to ${applied.targetPath}.`,
        };
      default:
        return {
          success: false,
          message: `Memory patch "${displayName}" could not be applied.`,
        };
    }
  }

  // Auto-bundle a MEMORY.md pointer for any sibling .md the patch creates
  // without referencing it from MEMORY.md. Without that pointer the new file
  // would never be loaded into a future session (see augmentWithAutoPointers).
  let pointersAdded: string[] = [];
  let resultsToCommit: AppliedSkillPatchTarget[] = [...applied.results];
  if (kind === 'private') {
    const augmented = await augmentWithAutoPointers(config, applied.results);
    resultsToCommit = augmented.results;
    pointersAdded = augmented.pointersAdded;
  }

  let stagedTargets: StagedInboxPatchTarget[];
  try {
    stagedTargets = await stageInboxPatchTargets(resultsToCommit);
  } catch (error) {
    return {
      success: false,
      message: `Memory patch "${displayName}" could not be staged: ${getErrorMessage(error)}.`,
    };
  }

  const committedTargets: StagedInboxPatchTarget[] = [];
  try {
    for (const stagedTarget of stagedTargets) {
      await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
      committedTargets.push(stagedTarget);
    }
  } catch (error) {
    for (const committedTarget of committedTargets.reverse()) {
      try {
        await restoreCommittedInboxPatchTarget(committedTarget);
      } catch {
        // Best-effort rollback. We still report the commit failure below.
      }
    }
    await cleanupStagedInboxPatchTargets(
      stagedTargets.filter((target) => !committedTargets.includes(target)),
    );
    return {
      success: false,
      message: `Memory patch "${displayName}" could not be applied atomically: ${getErrorMessage(error)}.`,
    };
  }

  await fs.unlink(patchPath);

  const fileCount = resultsToCommit.length;
  const baseMessage = `Applied memory patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`;
  const pointerNote =
    pointersAdded.length > 0
      ? ` Auto-added MEMORY.md pointer for ${pointersAdded
          .map((name) => `"${name}"`)
          .join(', ')} so the new sibling file is discoverable.`
      : '';
  return {
    success: true,
    message: `${baseMessage}${pointerNote}`,
  };
}

/**
 * Removes inbox memory patch(es) without applying. Two modes:
 *   - Aggregate (`relativePath === kind`): unlink every `.patch` file in the
 *     kind's inbox directory. Used by the consolidated inbox UI's Dismiss.
 *   - Single-file (legacy): unlink one specific `.patch` file.
 */
export async function dismissInboxMemoryPatch(
  config: Config,
  kind: InboxMemoryPatchKind,
  relativePath: string,
): Promise<{ success: boolean; message: string }> {
  if (relativePath === kind) {
    // Dismiss the same set of files the listing surfaced — leave the
    // already-filtered (bad-target, malformed) files alone for forensic
    // inspection.
    const patchFiles = await listValidInboxPatchFiles(config, kind);
    if (patchFiles.length === 0) {
      return {
        success: false,
        message: `No ${kind} memory patches in inbox.`,
      };
    }
    let removed = 0;
    for (const sourcePath of patchFiles) {
      try {
        await fs.unlink(sourcePath);
        removed += 1;
      } catch {
        // Best-effort: keep going if one delete fails.
      }
    }
    return {
      success: removed > 0,
      message: `Dismissed ${removed} ${kind} memory patch${
        removed === 1 ? '' : 'es'
      } from inbox.`,
    };
  }

  const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
  if (!normalizedPath) {
    return { success: false, message: 'Invalid memory patch path.' };
  }

  const sourcePath = await getInboxMemoryPatchSourcePath(
    config,
    kind,
    normalizedPath,
  );
  if (!sourcePath) {
    return { success: false, message: 'Invalid memory patch path.' };
  }

  try {
    await fs.access(sourcePath);
  } catch {
    return {
      success: false,
      message: `Memory patch "${normalizedPath}" not found in inbox.`,
    };
  }

  await fs.unlink(sourcePath);

  return {
    success: true,
    message: `Dismissed "${normalizedPath}" from inbox.`,
  };
}

async function findNearestExistingDirectory(
  startPath: string,
): Promise<string> {
  let currentPath = path.resolve(startPath);

  while (true) {
    try {
      const stats = await fs.stat(currentPath);
      if (stats.isDirectory()) {
        return currentPath;
      }
    } catch {
      // Keep walking upward until we find an existing directory.
    }

    const parentPath = path.dirname(currentPath);
    if (parentPath === currentPath) {
      return currentPath;
    }
    currentPath = parentPath;
  }
}

async function writeExclusiveFile(
  filePath: string,
  content: string,
  mode?: number,
): Promise<void> {
  const handle = await fs.open(filePath, 'wx');
  try {
    await handle.writeFile(content, 'utf-8');
  } finally {
    await handle.close();
  }

  if (mode !== undefined) {
    await fs.chmod(filePath, mode);
  }
}

async function cleanupStagedInboxPatchTargets(
  stagedTargets: StagedInboxPatchTarget[],
): Promise<void> {
  await Promise.allSettled(
    stagedTargets.map(async ({ tempPath }) => {
      try {
        await fs.unlink(tempPath);
      } catch {
        // Best-effort cleanup.
      }
    }),
  );
}

async function restoreCommittedInboxPatchTarget(
  stagedTarget: StagedInboxPatchTarget,
): Promise<void> {
  if (stagedTarget.isNewFile) {
    try {
      await fs.unlink(stagedTarget.targetPath);
    } catch {
      // Best-effort rollback.
    }
    return;
  }

  const restoreDir = await findNearestExistingDirectory(
    path.dirname(stagedTarget.targetPath),
  );
  const restorePath = path.join(
    restoreDir,
    `.${path.basename(stagedTarget.targetPath)}.${randomUUID()}.rollback`,
  );

  await writeExclusiveFile(
    restorePath,
    stagedTarget.original,
    stagedTarget.mode,
  );
  await fs.rename(restorePath, stagedTarget.targetPath);
}

async function stageInboxPatchTargets(
  targets: AppliedSkillPatchTarget[],
): Promise<StagedInboxPatchTarget[]> {
  const stagedTargets: StagedInboxPatchTarget[] = [];

  try {
    for (const target of targets) {
      let mode: number | undefined;
      if (!target.isNewFile) {
        await fs.access(target.targetPath, fsConstants.W_OK);
        mode = (await fs.stat(target.targetPath)).mode;
      }

      const tempDir = await findNearestExistingDirectory(
        path.dirname(target.targetPath),
      );
      const tempPath = path.join(
        tempDir,
        `.${path.basename(target.targetPath)}.${randomUUID()}.patch-tmp`,
      );

      await writeExclusiveFile(tempPath, target.patched, mode);
      stagedTargets.push({
        targetPath: target.targetPath,
        tempPath,
        original: target.original,
        isNewFile: target.isNewFile,
        mode,
      });
    }

    for (const target of stagedTargets) {
      if (!target.isNewFile) {
        continue;
      }
      await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
    }

    return stagedTargets;
  } catch (error) {
    await cleanupStagedInboxPatchTargets(stagedTargets);
    throw error;
  }
}

/**
 * Scans the skill extraction inbox for .patch files and returns
 * structured data for each valid patch.
 */
export async function listInboxPatches(config: Config): Promise<InboxPatch[]> {
  const skillsDir = config.storage.getProjectSkillsMemoryDir();

  let entries: string[];
  try {
    entries = await fs.readdir(skillsDir);
  } catch {
    return [];
  }

  const patchFiles = entries.filter((e) => e.endsWith('.patch'));
  if (patchFiles.length === 0) {
    return [];
  }

  const patches: InboxPatch[] = [];
  for (const patchFile of patchFiles) {
    const patchPath = path.join(skillsDir, patchFile);
    try {
      const content = await fs.readFile(patchPath, 'utf-8');
      const parsed = Diff.parsePatch(content);
      if (!hasParsedPatchHunks(parsed)) continue;

      const patchEntries: InboxPatchEntry[] = parsed.map((p) => ({
        targetPath: p.newFileName ?? p.oldFileName ?? '',
        diffContent: formatParsedDiff(p),
      }));

      patches.push({
        fileName: patchFile,
        name: patchFile.replace(/\.patch$/, ''),
        entries: patchEntries,
        extractedAt: await getPatchExtractedAt(patchPath),
      });
    } catch {
      // Skip unreadable patch files
    }
  }

  return patches;
}

/**
 * Applies a .patch file from the inbox by reading each target file,
 * applying the diff, and writing the result. Deletes the patch on success.
 */
export async function applyInboxPatch(
  config: Config,
  fileName: string,
): Promise<{ success: boolean; message: string }> {
  if (!isValidInboxPatchFileName(fileName)) {
    return {
      success: false,
      message: 'Invalid patch file name.',
    };
  }

  const skillsDir = config.storage.getProjectSkillsMemoryDir();
  const patchPath = path.join(skillsDir, fileName);

  let content: string;
  try {
    content = await fs.readFile(patchPath, 'utf-8');
  } catch {
    return {
      success: false,
      message: `Patch "${fileName}" not found in inbox.`,
    };
  }

  let parsed: Diff.StructuredPatch[];
  try {
    parsed = Diff.parsePatch(content);
  } catch (error) {
    return {
      success: false,
      message: `Failed to parse patch "${fileName}": ${getErrorMessage(error)}`,
    };
  }
  if (!hasParsedPatchHunks(parsed)) {
    return {
      success: false,
      message: `Patch "${fileName}" contains no valid hunks.`,
    };
  }

  const validatedHeaders = validateParsedSkillPatchHeaders(parsed);
  if (!validatedHeaders.success) {
    return {
      success: false,
      message:
        validatedHeaders.reason === 'missingTargetPath'
          ? `Patch "${fileName}" is missing a target file path.`
          : `Patch "${fileName}" has invalid diff headers.`,
    };
  }

  if (
    !config.isTrustedFolder() &&
    (await patchTargetsProjectSkills(
      validatedHeaders.patches.map((patch) => patch.targetPath),
      config,
    ))
  ) {
    return {
      success: false,
      message:
        'Project skill patches are unavailable until this workspace is trusted.',
    };
  }

  // Dry-run first: verify all patches apply cleanly before writing anything.
  // Repeated file blocks are validated against the progressively patched content.
  const applied = await applyParsedSkillPatches(parsed, config);
  if (!applied.success) {
    switch (applied.reason) {
      case 'missingTargetPath':
        return {
          success: false,
          message: `Patch "${fileName}" is missing a target file path.`,
        };
      case 'invalidPatchHeaders':
        return {
          success: false,
          message: `Patch "${fileName}" has invalid diff headers.`,
        };
      case 'outsideAllowedRoots':
        return {
          success: false,
          message: `Patch "${fileName}" targets a file outside the global/workspace skill directories: ${applied.targetPath}`,
        };
      case 'newFileAlreadyExists':
        return {
          success: false,
          message: `Patch "${fileName}" declares a new file, but the target already exists: ${applied.targetPath}`,
        };
      case 'targetNotFound':
        return {
          success: false,
          message: `Target file not found: ${applied.targetPath}`,
        };
      case 'doesNotApply':
        return {
          success: false,
          message: applied.isNewFile
            ? `Patch "${fileName}" failed to apply for new file ${applied.targetPath}.`
            : `Patch does not apply cleanly to ${applied.targetPath}.`,
        };
      default:
        return {
          success: false,
          message: `Patch "${fileName}" could not be applied.`,
        };
    }
  }

  let stagedTargets: StagedInboxPatchTarget[];
  try {
    stagedTargets = await stageInboxPatchTargets(applied.results);
  } catch (error) {
    return {
      success: false,
      message: `Patch "${fileName}" could not be staged: ${getErrorMessage(error)}.`,
    };
  }

  const committedTargets: StagedInboxPatchTarget[] = [];
  try {
    for (const stagedTarget of stagedTargets) {
      await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
      committedTargets.push(stagedTarget);
    }
  } catch (error) {
    for (const committedTarget of committedTargets.reverse()) {
      try {
        await restoreCommittedInboxPatchTarget(committedTarget);
      } catch {
        // Best-effort rollback. We still report the commit failure below.
      }
    }
    await cleanupStagedInboxPatchTargets(
      stagedTargets.filter((target) => !committedTargets.includes(target)),
    );
    return {
      success: false,
      message: `Patch "${fileName}" could not be applied atomically: ${getErrorMessage(error)}.`,
    };
  }

  // Remove the patch file
  await fs.unlink(patchPath);

  const fileCount = applied.results.length;
  return {
    success: true,
    message: `Applied patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`,
  };
}

/**
 * Removes a .patch file from the extraction inbox.
 */
export async function dismissInboxPatch(
  config: Config,
  fileName: string,
): Promise<{ success: boolean; message: string }> {
  if (!isValidInboxPatchFileName(fileName)) {
    return {
      success: false,
      message: 'Invalid patch file name.',
    };
  }

  const skillsDir = config.storage.getProjectSkillsMemoryDir();
  const patchPath = path.join(skillsDir, fileName);

  try {
    await fs.access(patchPath);
  } catch {
    return {
      success: false,
      message: `Patch "${fileName}" not found in inbox.`,
    };
  }

  await fs.unlink(patchPath);

  return {
    success: true,
    message: `Dismissed "${fileName}" from inbox.`,
  };
}
