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

import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
  addMemory,
  applyInboxMemoryPatch,
  dismissInboxSkill,
  dismissInboxMemoryPatch,
  listInboxSkills,
  listInboxPatches,
  listInboxMemoryPatches,
  applyInboxPatch,
  dismissInboxPatch,
  listMemoryFiles,
  moveInboxSkill,
  refreshMemory,
  showMemory,
} from './memory.js';
import * as memoryDiscovery from '../utils/memoryDiscovery.js';

vi.mock('../utils/memoryDiscovery.js', () => ({
  refreshServerHierarchicalMemory: vi.fn(),
}));

vi.mock('../config/storage.js', () => ({
  Storage: {
    getUserSkillsDir: vi.fn(),
    getGlobalGeminiDir: vi.fn(),
  },
}));

const mockRefresh = vi.mocked(memoryDiscovery.refreshServerHierarchicalMemory);

describe('memory commands', () => {
  let mockConfig: Config;

  beforeEach(() => {
    mockConfig = {
      getUserMemory: vi.fn(),
      getGeminiMdFileCount: vi.fn(),
      getGeminiMdFilePaths: vi.fn(),
      isJitContextEnabled: vi.fn(),
      updateSystemInstructionIfInitialized: vi
        .fn()
        .mockResolvedValue(undefined),
    } as unknown as Config;
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('showMemory', () => {
    it('should show memory content if it exists', () => {
      vi.mocked(mockConfig.getUserMemory).mockReturnValue(
        'some memory content',
      );
      vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(1);

      const result = showMemory(mockConfig);

      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toContain(
          'Current memory content from 1 file(s)',
        );
        expect(result.content).toContain('some memory content');
      }
    });

    it('should show a message if memory is empty', () => {
      vi.mocked(mockConfig.getUserMemory).mockReturnValue('');
      vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(0);

      const result = showMemory(mockConfig);

      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toBe('Memory is currently empty.');
      }
    });
  });

  describe('addMemory', () => {
    it('should return a tool action to save memory', () => {
      const result = addMemory('new memory');
      expect(result.type).toBe('tool');
      if (result.type === 'tool') {
        expect(result.toolName).toBe('save_memory');
        expect(result.toolArgs).toEqual({ fact: 'new memory' });
      }
    });

    it('should trim the arguments', () => {
      const result = addMemory('  new memory  ');
      expect(result.type).toBe('tool');
      if (result.type === 'tool') {
        expect(result.toolArgs).toEqual({ fact: 'new memory' });
      }
    });

    it('should return an error if args are empty', () => {
      const result = addMemory('');
      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('error');
        expect(result.content).toBe('Usage: /memory add <text to remember>');
      }
    });

    it('should return an error if args are just whitespace', () => {
      const result = addMemory('   ');
      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('error');
        expect(result.content).toBe('Usage: /memory add <text to remember>');
      }
    });

    it('should return an error if args are undefined', () => {
      const result = addMemory(undefined);
      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('error');
        expect(result.content).toBe('Usage: /memory add <text to remember>');
      }
    });
  });

  describe('refreshMemory', () => {
    it('should refresh memory and show success message', async () => {
      mockRefresh.mockResolvedValue({
        memoryContent: { project: 'refreshed content' },
        fileCount: 2,
        filePaths: [],
      });

      const result = await refreshMemory(mockConfig);

      expect(mockRefresh).toHaveBeenCalledWith(mockConfig);
      expect(
        mockConfig.updateSystemInstructionIfInitialized,
      ).toHaveBeenCalled();
      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toBe(
          'Memory reloaded successfully. Loaded 33 characters from 2 file(s)',
        );
      }
    });

    it('should show a message if no memory content is found after refresh', async () => {
      mockRefresh.mockResolvedValue({
        memoryContent: { project: '' },
        fileCount: 0,
        filePaths: [],
      });

      const result = await refreshMemory(mockConfig);
      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toBe(
          'Memory reloaded successfully. No memory content found',
        );
      }
    });
  });

  describe('listMemoryFiles', () => {
    it('should list the memory files in use', () => {
      const filePaths = ['/path/to/GEMINI.md', '/other/path/GEMINI.md'];
      vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue(filePaths);

      const result = listMemoryFiles(mockConfig);

      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toContain(
          'There are 2 GEMINI.md file(s) in use:',
        );
        expect(result.content).toContain(filePaths.join('\n'));
      }
    });

    it('should show a message if no memory files are in use', () => {
      vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue([]);

      const result = listMemoryFiles(mockConfig);

      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toBe('No GEMINI.md files in use.');
      }
    });

    it('should show a message if file paths are undefined', () => {
      vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue(
        undefined as unknown as string[],
      );

      const result = listMemoryFiles(mockConfig);

      expect(result.type).toBe('message');
      if (result.type === 'message') {
        expect(result.messageType).toBe('info');
        expect(result.content).toBe('No GEMINI.md files in use.');
      }
    });
  });

  describe('listInboxSkills', () => {
    let tmpDir: string;
    let skillsDir: string;
    let memoryTempDir: string;
    let inboxConfig: Config;

    async function writeSkillMd(
      dirName: string,
      name: string,
      description: string,
    ): Promise<void> {
      const dir = path.join(skillsDir, dirName);
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(
        path.join(dir, 'SKILL.md'),
        `---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
      );
    }

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-test-'));
      skillsDir = path.join(tmpDir, 'skills-memory');
      memoryTempDir = path.join(tmpDir, 'memory-temp');
      await fs.mkdir(skillsDir, { recursive: true });
      await fs.mkdir(memoryTempDir, { recursive: true });

      inboxConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => skillsDir,
          getProjectMemoryTempDir: () => memoryTempDir,
          getProjectSkillsDir: () => path.join(tmpDir, 'project-skills'),
        },
      } as unknown as Config;
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('should return inbox skills with name, description, and extractedAt', async () => {
      await writeSkillMd('my-skill', 'my-skill', 'A test skill');
      await writeSkillMd('other-skill', 'other-skill', 'Another skill');

      const stateContent = JSON.stringify({
        runs: [
          {
            runAt: '2025-01-15T10:00:00Z',
            sessionIds: ['sess-1'],
            skillsCreated: ['my-skill'],
          },
          {
            runAt: '2025-01-16T12:00:00Z',
            sessionIds: ['sess-2'],
            skillsCreated: ['other-skill'],
          },
        ],
      });
      await fs.writeFile(
        path.join(memoryTempDir, '.extraction-state.json'),
        stateContent,
      );

      const skills = await listInboxSkills(inboxConfig);

      expect(skills).toHaveLength(2);
      const mySkill = skills.find((s) => s.dirName === 'my-skill');
      expect(mySkill).toBeDefined();
      expect(mySkill!.name).toBe('my-skill');
      expect(mySkill!.description).toBe('A test skill');
      expect(mySkill!.extractedAt).toBe('2025-01-15T10:00:00Z');

      const otherSkill = skills.find((s) => s.dirName === 'other-skill');
      expect(otherSkill).toBeDefined();
      expect(otherSkill!.name).toBe('other-skill');
      expect(otherSkill!.description).toBe('Another skill');
      expect(otherSkill!.extractedAt).toBe('2025-01-16T12:00:00Z');
    });

    it('should return an empty array when the inbox is empty', async () => {
      const skills = await listInboxSkills(inboxConfig);
      expect(skills).toEqual([]);
    });

    it('should return an empty array when the inbox directory does not exist', async () => {
      const missingConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
          getProjectMemoryTempDir: () => memoryTempDir,
        },
      } as unknown as Config;

      const skills = await listInboxSkills(missingConfig);
      expect(skills).toEqual([]);
    });
  });

  describe('memory patch inbox', () => {
    let tmpDir: string;
    let memoryTempDir: string;
    let projectRoot: string;
    let globalMemoryDir: string;
    let patchConfig: Config;
    const isCaseInsensitivePathPlatform =
      process.platform === 'win32' || process.platform === 'darwin';

    function buildUpdatePatch(
      absoluteTargetPath: string,
      original: string,
      updated: string,
    ): string {
      // Minimal one-hunk patch that replaces `original` with `updated`.
      const oldLines = original === '' ? 0 : original.split('\n').length - 1;
      const newLines = updated === '' ? 0 : updated.split('\n').length - 1;
      const removed = original
        .split('\n')
        .slice(0, oldLines)
        .map((line) => `-${line}`);
      const added = updated
        .split('\n')
        .slice(0, newLines)
        .map((line) => `+${line}`);
      return [
        `--- ${absoluteTargetPath}`,
        `+++ ${absoluteTargetPath}`,
        `@@ -1,${oldLines} +1,${newLines} @@`,
        ...removed,
        ...added,
        '',
      ].join('\n');
    }

    function buildCreationPatch(
      absoluteTargetPath: string,
      content: string,
    ): string {
      const contentLines = content.split('\n');
      const lineCount = content.endsWith('\n')
        ? contentLines.length - 1
        : contentLines.length;
      const additions = (
        content.endsWith('\n') ? contentLines.slice(0, -1) : contentLines
      ).map((line) => `+${line}`);
      return [
        `--- /dev/null`,
        `+++ ${absoluteTargetPath}`,
        `@@ -0,0 +1,${lineCount} @@`,
        ...additions,
        '',
      ].join('\n');
    }

    function swapAsciiPathCase(filePath: string): string {
      return filePath.replace(/[a-z]/gi, (char) =>
        char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase(),
      );
    }

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-patch-test-'));
      // Canonicalize so test-side paths match production's
      // canonicalizeDirIfPresent → fs.realpath. On Windows runners
      // os.tmpdir() returns the 8.3 short form (C:\Users\RUNNER~1\...) but
      // fs.realpath expands it to the long form (C:\Users\runneradmin\...),
      // which would otherwise break the auto-pointer absolute-path asserts.
      tmpDir = await fs.realpath(tmpDir);
      memoryTempDir = path.join(tmpDir, 'memory-temp');
      projectRoot = path.join(tmpDir, 'project');
      globalMemoryDir = path.join(tmpDir, 'global');
      await fs.mkdir(memoryTempDir, { recursive: true });
      await fs.mkdir(projectRoot, { recursive: true });
      await fs.mkdir(globalMemoryDir, { recursive: true });

      patchConfig = {
        storage: {
          getProjectMemoryTempDir: () => memoryTempDir,
          getProjectMemoryDir: () => memoryTempDir,
        },
        isTrustedFolder: () => true,
      } as unknown as Config;
      vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(globalMemoryDir);
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('aggregates all .patch files of a kind into a single inbox entry', async () => {
      // Multiple physical .patch files in the kind dir → ONE consolidated
      // inbox entry per kind, with all hunks merged into entries[].
      const target = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(target, '- old\n');

      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'a-update.patch'),
        buildUpdatePatch(target, '- old\n', '- new\n'),
      );
      // Second source patch — same kind, different hunk.
      const sibling = path.join(memoryTempDir, 'topic.md');
      await fs.writeFile(sibling, 'topic A\n');
      await fs.writeFile(
        path.join(patchDir, 'b-topic.patch'),
        buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
      );

      const patches = await listInboxMemoryPatches(patchConfig);

      expect(patches).toHaveLength(1);
      const memoryPatch = patches[0];
      expect(memoryPatch).toMatchObject({
        kind: 'private',
        relativePath: 'private',
        name: 'Private memory',
      });
      // Both source files contributed their hunks.
      expect(memoryPatch.entries).toHaveLength(2);
      expect(memoryPatch.sourceFiles).toEqual([
        'a-update.patch',
        'b-topic.patch',
      ]);
      expect(memoryPatch.entries[0].targetPath).toBe(target);
      expect(memoryPatch.entries[0].isNewFile).toBe(false);
      expect(memoryPatch.entries[1].targetPath).toBe(sibling);
      expect(memoryPatch.extractedAt).toBeDefined();
    });

    it('omits patches whose headers leave the allowed root from the listing', async () => {
      // Bad patches must NOT show up in the inbox at all — listing filters
      // them out so the user only ever sees actionable items. (They'd also
      // be rejected at Apply time, but we don't want to surface them.)
      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'escape.patch'),
        buildCreationPatch(path.join(projectRoot, 'GEMINI.md'), 'Hi.\n'),
      );

      const patches = await listInboxMemoryPatches(patchConfig);
      expect(patches).toHaveLength(0);

      // Direct apply still rejects it (defense-in-depth).
      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'escape.patch',
      );
      expect(result.success).toBe(false);
      expect(result.message).toMatch(/outside the private memory root/i);
    });

    it('rejects private patches that target in-root non-memory documents', async () => {
      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });

      const rejectedTargets = [
        ['state.patch', path.join(memoryTempDir, '.extraction-state.json')],
        ['lock.patch', path.join(memoryTempDir, '.extraction.lock')],
        [
          'inbox.patch',
          path.join(memoryTempDir, '.inbox', 'private', 'review.md'),
        ],
        [
          'skills.patch',
          path.join(memoryTempDir, 'skills', 'generated', 'SKILL.md'),
        ],
        ['text.patch', path.join(memoryTempDir, 'notes.txt')],
        ['nested.patch', path.join(memoryTempDir, 'nested', 'topic.md')],
      ] as const;

      for (const [fileName, targetPath] of rejectedTargets) {
        await fs.writeFile(
          path.join(patchDir, fileName),
          buildCreationPatch(targetPath, 'rejected\n'),
        );
      }

      const patches = await listInboxMemoryPatches(patchConfig);
      expect(patches).toHaveLength(0);

      for (const [fileName, targetPath] of rejectedTargets) {
        const result = await applyInboxMemoryPatch(
          patchConfig,
          'private',
          fileName,
        );
        expect(result.success).toBe(false);
        expect(result.message).toMatch(
          /outside the private memory root or target allowlist/i,
        );
        await expect(fs.access(targetPath)).rejects.toThrow();
      }
    });

    it('omits global patches with disallowed targets from the listing', async () => {
      // Same defense for the global tier: only ~/.gemini/GEMINI.md is allowed.
      // memory.md (legacy lowercase), sibling .md files, and settings.json all
      // get filtered out of the listing instead of confusing the user.
      const patchDir = path.join(memoryTempDir, '.inbox', 'global');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'wrong-name.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'memory.md'),
          'rejected\n',
        ),
      );
      await fs.writeFile(
        path.join(patchDir, 'sibling.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'notes.md'),
          'rejected\n',
        ),
      );
      await fs.writeFile(
        path.join(patchDir, 'settings.patch'),
        buildCreationPatch(path.join(globalMemoryDir, 'settings.json'), '{}\n'),
      );
      await fs.writeFile(
        path.join(patchDir, 'nested.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'GEMINI.md', 'nested.md'),
          'rejected\n',
        ),
      );

      const patches = await listInboxMemoryPatches(patchConfig);
      expect(patches).toHaveLength(0);
    });

    it('applies a private update patch and removes it from the inbox', async () => {
      const target = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(target, '- old\n');

      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'MEMORY.patch'),
        buildUpdatePatch(target, '- old\n', '- accepted\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'MEMORY.patch',
      );

      expect(result.success).toBe(true);
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- accepted\n');
      await expect(
        fs.access(path.join(patchDir, 'MEMORY.patch')),
      ).rejects.toThrow();
    });

    it.runIf(isCaseInsensitivePathPlatform)(
      'accepts private memory patch targets with different path casing',
      async () => {
        const target = path.join(memoryTempDir, 'MEMORY.md');
        await fs.writeFile(target, '- old\n');

        const patchDir = path.join(memoryTempDir, '.inbox', 'private');
        await fs.mkdir(patchDir, { recursive: true });
        await fs.writeFile(
          path.join(patchDir, 'MEMORY.patch'),
          buildUpdatePatch(
            swapAsciiPathCase(target),
            '- old\n',
            '- accepted\n',
          ),
        );

        const patches = await listInboxMemoryPatches(patchConfig);
        expect(patches).toHaveLength(1);

        const result = await applyInboxMemoryPatch(
          patchConfig,
          'private',
          'MEMORY.patch',
        );

        expect(result.success).toBe(true);
        await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
          '- accepted\n',
        );
      },
    );

    it('applies a private creation patch with a paired MEMORY.md pointer', async () => {
      // The auto-memory contract: creating a sibling .md file requires a
      // hunk that adds a pointer to MEMORY.md (so the sibling becomes
      // discoverable to future sessions).
      const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(memoryMd, '# Project Memory\n');

      const target = path.join(memoryTempDir, 'topic.md');
      await expect(fs.access(target)).rejects.toThrow();

      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      const multiHunkPatch =
        buildCreationPatch(target, '# Topic\n- new fact\n') +
        buildUpdatePatch(
          memoryMd,
          '# Project Memory\n',
          '# Project Memory\n- See topic.md for the new fact.\n',
        );
      await fs.writeFile(path.join(patchDir, 'topic.patch'), multiHunkPatch);

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'topic.patch',
      );

      expect(result.success).toBe(true);
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
        '# Topic\n- new fact\n',
      );
      await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toContain(
        'See topic.md',
      );
      await expect(
        fs.access(path.join(patchDir, 'topic.patch')),
      ).rejects.toThrow();
    });

    it('auto-bundles a MEMORY.md pointer when the patch creates an orphan sibling', async () => {
      // Sibling .md files in <memoryDir> are loaded by future sessions ONLY
      // when MEMORY.md references them. To avoid orphans, applying a sibling
      // creation patch with no MEMORY.md update auto-bundles a pointer line.
      const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(memoryMd, '# Project Memory\n');

      const target = path.join(memoryTempDir, 'orphan-topic.md');
      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'orphan-topic.patch'),
        buildCreationPatch(target, '# Orphan Topic\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'orphan-topic.patch',
      );

      expect(result.success).toBe(true);
      expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
      expect(result.message).toContain('"orphan-topic.md"');
      // The sibling exists.
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
        '# Orphan Topic\n',
      );
      // MEMORY.md now references the sibling — using ABSOLUTE PATH so a
      // future agent can `read_file` it without resolving relatives. We
      // assert the line shape is `- See <absolute>/orphan-topic.md ...` and
      // verify the path is absolute via path.isAbsolute (cross-platform —
      // the previous /^- See \/.+\/.../ regex was Unix-only and broke on
      // Windows where the absolute path is e.g. `C:\Users\...\orphan-topic.md`).
      const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
      expect(memoryAfter).toContain(target);
      const pointerLineMatch = memoryAfter.match(
        /^- See (.+orphan-topic\.md) /m,
      );
      expect(pointerLineMatch).not.toBeNull();
      expect(path.isAbsolute(pointerLineMatch![1])).toBe(true);
      // The patch was committed and removed from inbox.
      await expect(
        fs.access(path.join(patchDir, 'orphan-topic.patch')),
      ).rejects.toThrow();
    });

    it('auto-creates MEMORY.md if it does not exist when bundling pointers', async () => {
      // No MEMORY.md on disk + a creation patch for a sibling →
      // auto-bundle should create MEMORY.md from scratch with the pointer.
      const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
      await expect(fs.access(memoryMd)).rejects.toThrow();

      const target = path.join(memoryTempDir, 'fresh-topic.md');
      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'fresh-topic.patch'),
        buildCreationPatch(target, '# Fresh Topic\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'fresh-topic.patch',
      );

      expect(result.success).toBe(true);
      expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
      const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
      expect(memoryAfter).toContain('Project Memory');
      // Pointer must be absolute so the future agent can read_file directly.
      expect(memoryAfter).toContain(target);
    });

    it('accepts a private creation patch when MEMORY.md already references the new file', async () => {
      // If MEMORY.md was previously prepared with a pointer (e.g. by a
      // separately-applied patch), the follow-up creation patch is fine.
      const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(
        memoryMd,
        '# Project Memory\n- See later-topic.md for details.\n',
      );

      const target = path.join(memoryTempDir, 'later-topic.md');
      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'later-topic.patch'),
        buildCreationPatch(target, '# Later Topic\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'later-topic.patch',
      );

      expect(result.success).toBe(true);
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
        '# Later Topic\n',
      );
    });

    it('applies a global creation patch to ~/.gemini/GEMINI.md', async () => {
      const target = path.join(globalMemoryDir, 'GEMINI.md');
      // Sanity check: target does not exist before apply.
      await expect(fs.access(target)).rejects.toThrow();

      const patchDir = path.join(memoryTempDir, '.inbox', 'global');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'GEMINI.patch'),
        buildCreationPatch(target, '# Personal preferences\n- prefer X\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'global',
        'GEMINI.patch',
      );

      expect(result.success).toBe(true);
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
        '# Personal preferences\n- prefer X\n',
      );
      await expect(
        fs.access(path.join(patchDir, 'GEMINI.patch')),
      ).rejects.toThrow();
    });

    it('applies a global update patch to ~/.gemini/GEMINI.md', async () => {
      const target = path.join(globalMemoryDir, 'GEMINI.md');
      await fs.writeFile(target, '- prefer X\n');

      const patchDir = path.join(memoryTempDir, '.inbox', 'global');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'GEMINI.patch'),
        buildUpdatePatch(target, '- prefer X\n', '- prefer Y\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'global',
        'GEMINI.patch',
      );

      expect(result.success).toBe(true);
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- prefer Y\n');
      await expect(
        fs.access(path.join(patchDir, 'GEMINI.patch')),
      ).rejects.toThrow();
    });

    it.runIf(isCaseInsensitivePathPlatform)(
      'accepts global memory patch targets with different path casing',
      async () => {
        const target = path.join(globalMemoryDir, 'GEMINI.md');
        await fs.writeFile(target, '- prefer X\n');

        const patchDir = path.join(memoryTempDir, '.inbox', 'global');
        await fs.mkdir(patchDir, { recursive: true });
        await fs.writeFile(
          path.join(patchDir, 'GEMINI.patch'),
          buildUpdatePatch(
            swapAsciiPathCase(target),
            '- prefer X\n',
            '- prefer Y\n',
          ),
        );

        const patches = await listInboxMemoryPatches(patchConfig);
        expect(patches).toHaveLength(1);

        const result = await applyInboxMemoryPatch(
          patchConfig,
          'global',
          'GEMINI.patch',
        );

        expect(result.success).toBe(true);
        await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
          '- prefer Y\n',
        );
      },
    );

    it('dismisses a single memory patch from the inbox (legacy single-file mode)', async () => {
      const patchDir = path.join(memoryTempDir, '.inbox', 'global');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'GEMINI.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'GEMINI.md'),
          'Prefer concise.\n',
        ),
      );

      const result = await dismissInboxMemoryPatch(
        patchConfig,
        'global',
        'GEMINI.patch',
      );

      expect(result.success).toBe(true);
      await expect(
        fs.access(path.join(patchDir, 'GEMINI.patch')),
      ).rejects.toThrow();
    });

    it('apply with relativePath = kind runs every source patch in sequence', async () => {
      // Aggregate apply: pass `relativePath = kind`. Each .patch file under
      // the kind dir is applied atomically in lexical order; the result
      // message summarizes successes/failures.
      const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(memoryMd, '- old\n');
      const sibling = path.join(memoryTempDir, 'topic.md');
      await fs.writeFile(sibling, 'topic A\n');

      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'a-update.patch'),
        buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
      );
      await fs.writeFile(
        path.join(patchDir, 'b-topic.patch'),
        buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'private', // ← aggregate mode
      );

      expect(result.success).toBe(true);
      expect(result.message).toMatch(/applied all 2 private memory patches/i);

      // Both targets were updated, both source patches removed.
      await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
      await expect(fs.readFile(sibling, 'utf-8')).resolves.toBe('topic B\n');
      await expect(
        fs.access(path.join(patchDir, 'a-update.patch')),
      ).rejects.toThrow();
      await expect(
        fs.access(path.join(patchDir, 'b-topic.patch')),
      ).rejects.toThrow();
    });

    it('aggregate apply reports successes and failures when one source patch is stale', async () => {
      const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(memoryMd, '- old\n');

      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      // Good patch: updates the existing line.
      await fs.writeFile(
        path.join(patchDir, 'a-good.patch'),
        buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
      );
      // Stale patch: context expects something that doesn't exist.
      await fs.writeFile(
        path.join(patchDir, 'b-stale.patch'),
        buildUpdatePatch(memoryMd, '- never existed\n', '- attempted\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'private',
      );

      // Any failure → success=false so the dialog keeps the inbox entry
      // visible. (The successful sub-patches were already removed from disk;
      // the next listing will surface only the failures for retry.)
      expect(result.success).toBe(false);
      expect(result.message).toMatch(/applied 1 of 2/i);
      expect(result.message).toMatch(/b-stale\.patch/);

      // Good patch committed and removed; stale patch stays in inbox.
      await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
      await expect(
        fs.access(path.join(patchDir, 'a-good.patch')),
      ).rejects.toThrow();
      await expect(
        fs.access(path.join(patchDir, 'b-stale.patch')),
      ).resolves.toBeUndefined();
    });

    it('dismiss with relativePath = kind removes all source patches', async () => {
      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'a.patch'),
        buildCreationPatch(path.join(memoryTempDir, 'a.md'), 'a\n'),
      );
      await fs.writeFile(
        path.join(patchDir, 'b.patch'),
        buildCreationPatch(path.join(memoryTempDir, 'b.md'), 'b\n'),
      );

      const result = await dismissInboxMemoryPatch(
        patchConfig,
        'private',
        'private',
      );

      expect(result.success).toBe(true);
      expect(result.message).toMatch(/dismissed 2/i);
      await expect(fs.access(path.join(patchDir, 'a.patch'))).rejects.toThrow();
      await expect(fs.access(path.join(patchDir, 'b.patch'))).rejects.toThrow();
    });

    it('rejects global patches that target anything other than ~/.gemini/GEMINI.md', async () => {
      const patchDir = path.join(memoryTempDir, '.inbox', 'global');
      await fs.mkdir(patchDir, { recursive: true });

      // memory.md (lowercase) is NOT a valid global memory file.
      await fs.writeFile(
        path.join(patchDir, 'wrong-name.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'memory.md'),
          'Should be rejected.\n',
        ),
      );

      // Sibling .md files in ~/.gemini/ are also not allowed.
      await fs.writeFile(
        path.join(patchDir, 'sibling.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'notes.md'),
          'Should be rejected.\n',
        ),
      );

      // Non-memory files (settings, credentials) must stay off-limits.
      await fs.writeFile(
        path.join(patchDir, 'settings.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'settings.json'),
          '{"foo": 1}\n',
        ),
      );

      // Child paths under the single allowed file path are not allowed either.
      await fs.writeFile(
        path.join(patchDir, 'nested.patch'),
        buildCreationPatch(
          path.join(globalMemoryDir, 'GEMINI.md', 'nested.md'),
          'Should be rejected.\n',
        ),
      );

      for (const fileName of [
        'wrong-name.patch',
        'sibling.patch',
        'settings.patch',
        'nested.patch',
      ]) {
        const result = await applyInboxMemoryPatch(
          patchConfig,
          'global',
          fileName,
        );
        expect(result.success).toBe(false);
        expect(result.message).toMatch(/outside the global memory root/i);
      }

      // None of the bogus targets were created.
      for (const orphan of ['memory.md', 'notes.md', 'settings.json']) {
        await expect(
          fs.access(path.join(globalMemoryDir, orphan)),
        ).rejects.toThrow();
      }
      await expect(
        fs.access(path.join(globalMemoryDir, 'GEMINI.md', 'nested.md')),
      ).rejects.toThrow();
    });

    it('rejects invalid memory patch paths', async () => {
      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        '../MEMORY.patch',
      );

      expect(result.success).toBe(false);
      expect(result.message).toBe('Invalid memory patch path.');
    });

    it('rejects a creation patch whose target already exists', async () => {
      const target = path.join(memoryTempDir, 'MEMORY.md');
      await fs.writeFile(target, 'pre-existing\n');

      const patchDir = path.join(memoryTempDir, '.inbox', 'private');
      await fs.mkdir(patchDir, { recursive: true });
      await fs.writeFile(
        path.join(patchDir, 'MEMORY.patch'),
        buildCreationPatch(target, 'replacement\n'),
      );

      const result = await applyInboxMemoryPatch(
        patchConfig,
        'private',
        'MEMORY.patch',
      );

      expect(result.success).toBe(false);
      expect(result.message).toMatch(/declares a new file/);
      await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
        'pre-existing\n',
      );
      await expect(
        fs.access(path.join(patchDir, 'MEMORY.patch')),
      ).resolves.toBeUndefined();
    });
  });

  describe('moveInboxSkill', () => {
    let tmpDir: string;
    let skillsDir: string;
    let globalSkillsDir: string;
    let projectSkillsDir: string;
    let moveConfig: Config;

    async function writeSkillMd(
      dirName: string,
      name: string,
      description: string,
    ): Promise<void> {
      const dir = path.join(skillsDir, dirName);
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(
        path.join(dir, 'SKILL.md'),
        `---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
      );
    }

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'move-test-'));
      skillsDir = path.join(tmpDir, 'skills-memory');
      globalSkillsDir = path.join(tmpDir, 'global-skills');
      projectSkillsDir = path.join(tmpDir, 'project-skills');
      await fs.mkdir(skillsDir, { recursive: true });

      moveConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => skillsDir,
          getProjectSkillsDir: () => projectSkillsDir,
        },
      } as unknown as Config;

      vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('should move a skill to global skills directory', async () => {
      await writeSkillMd('my-skill', 'my-skill', 'A test skill');

      const result = await moveInboxSkill(moveConfig, 'my-skill', 'global');

      expect(result.success).toBe(true);
      expect(result.message).toBe('Moved "my-skill" to ~/.gemini/skills.');

      // Verify the skill was copied to global
      const targetSkill = await fs.readFile(
        path.join(globalSkillsDir, 'my-skill', 'SKILL.md'),
        'utf-8',
      );
      expect(targetSkill).toContain('name: my-skill');

      // Verify the skill was removed from inbox
      await expect(
        fs.access(path.join(skillsDir, 'my-skill')),
      ).rejects.toThrow();
    });

    it('should move a skill to project skills directory', async () => {
      await writeSkillMd('my-skill', 'my-skill', 'A test skill');

      const result = await moveInboxSkill(moveConfig, 'my-skill', 'project');

      expect(result.success).toBe(true);
      expect(result.message).toBe('Moved "my-skill" to .gemini/skills.');

      // Verify the skill was copied to project
      const targetSkill = await fs.readFile(
        path.join(projectSkillsDir, 'my-skill', 'SKILL.md'),
        'utf-8',
      );
      expect(targetSkill).toContain('name: my-skill');

      // Verify the skill was removed from inbox
      await expect(
        fs.access(path.join(skillsDir, 'my-skill')),
      ).rejects.toThrow();
    });

    it('should return an error when the source skill does not exist', async () => {
      const result = await moveInboxSkill(moveConfig, 'nonexistent', 'global');

      expect(result.success).toBe(false);
      expect(result.message).toBe('Skill "nonexistent" not found in inbox.');
    });

    it('should reject invalid skill directory names', async () => {
      const result = await moveInboxSkill(moveConfig, '../escape', 'global');

      expect(result.success).toBe(false);
      expect(result.message).toBe('Invalid skill name.');
    });

    it('should return an error when the target already exists', async () => {
      await writeSkillMd('my-skill', 'my-skill', 'A test skill');

      // Pre-create the target
      const targetDir = path.join(globalSkillsDir, 'my-skill');
      await fs.mkdir(targetDir, { recursive: true });
      await fs.writeFile(path.join(targetDir, 'SKILL.md'), 'existing content');

      const result = await moveInboxSkill(moveConfig, 'my-skill', 'global');

      expect(result.success).toBe(false);
      expect(result.message).toBe(
        'A skill named "my-skill" already exists in global skills.',
      );
    });

    it('should detect conflicts based on the normalized skill name', async () => {
      await writeSkillMd(
        'inbox-skill',
        'gke:prs-troubleshooter',
        'A test skill',
      );
      await fs.mkdir(
        path.join(globalSkillsDir, 'existing-gke-prs-troubleshooter'),
        { recursive: true },
      );
      await fs.writeFile(
        path.join(
          globalSkillsDir,
          'existing-gke-prs-troubleshooter',
          'SKILL.md',
        ),
        [
          '---',
          'name: gke-prs-troubleshooter',
          'description: Existing skill',
          '---',
          'Existing body content',
          '',
        ].join('\n'),
      );

      const result = await moveInboxSkill(moveConfig, 'inbox-skill', 'global');

      expect(result.success).toBe(false);
      expect(result.message).toBe(
        'A skill named "gke-prs-troubleshooter" already exists in global skills.',
      );
      await expect(
        fs.access(path.join(skillsDir, 'inbox-skill', 'SKILL.md')),
      ).resolves.toBeUndefined();
      await expect(
        fs.access(path.join(globalSkillsDir, 'inbox-skill')),
      ).rejects.toThrow();
    });
  });

  describe('dismissInboxSkill', () => {
    let tmpDir: string;
    let skillsDir: string;
    let dismissConfig: Config;

    async function writeSkillMd(
      dirName: string,
      name: string,
      description: string,
    ): Promise<void> {
      const dir = path.join(skillsDir, dirName);
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(
        path.join(dir, 'SKILL.md'),
        `---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
      );
    }

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dismiss-test-'));
      skillsDir = path.join(tmpDir, 'skills-memory');
      await fs.mkdir(skillsDir, { recursive: true });

      dismissConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => skillsDir,
        },
      } as unknown as Config;
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('should remove a skill from the inbox', async () => {
      await writeSkillMd('my-skill', 'my-skill', 'A test skill');

      const result = await dismissInboxSkill(dismissConfig, 'my-skill');

      expect(result.success).toBe(true);
      expect(result.message).toBe('Dismissed "my-skill" from inbox.');

      // Verify the skill directory was removed
      await expect(
        fs.access(path.join(skillsDir, 'my-skill')),
      ).rejects.toThrow();
    });

    it('should return an error when the skill does not exist', async () => {
      const result = await dismissInboxSkill(dismissConfig, 'nonexistent');

      expect(result.success).toBe(false);
      expect(result.message).toBe('Skill "nonexistent" not found in inbox.');
    });

    it('should reject invalid skill directory names', async () => {
      const result = await dismissInboxSkill(dismissConfig, 'nested\\skill');

      expect(result.success).toBe(false);
      expect(result.message).toBe('Invalid skill name.');
    });
  });

  describe('listInboxPatches', () => {
    let tmpDir: string;
    let skillsDir: string;
    let memoryTempDir: string;
    let patchConfig: Config;

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-list-test-'));
      skillsDir = path.join(tmpDir, 'skills-memory');
      memoryTempDir = path.join(tmpDir, 'memory-temp');
      await fs.mkdir(skillsDir, { recursive: true });
      await fs.mkdir(memoryTempDir, { recursive: true });

      patchConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => skillsDir,
          getProjectMemoryTempDir: () => memoryTempDir,
        },
      } as unknown as Config;
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('should return empty array when no patches exist', async () => {
      const result = await listInboxPatches(patchConfig);
      expect(result).toEqual([]);
    });

    it('should return empty array when directory does not exist', async () => {
      const badConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
          getProjectMemoryTempDir: () => memoryTempDir,
        },
      } as unknown as Config;

      const result = await listInboxPatches(badConfig);
      expect(result).toEqual([]);
    });

    it('should return parsed patch entries', async () => {
      const targetFile = path.join(tmpDir, 'target.md');
      const patchContent = [
        `--- ${targetFile}`,
        `+++ ${targetFile}`,
        '@@ -1,3 +1,4 @@',
        ' line1',
        ' line2',
        '+line2.5',
        ' line3',
        '',
      ].join('\n');

      await fs.writeFile(
        path.join(skillsDir, 'update-skill.patch'),
        patchContent,
      );

      const result = await listInboxPatches(patchConfig);

      expect(result).toHaveLength(1);
      expect(result[0].fileName).toBe('update-skill.patch');
      expect(result[0].name).toBe('update-skill');
      expect(result[0].entries).toHaveLength(1);
      expect(result[0].entries[0].targetPath).toBe(targetFile);
      expect(result[0].entries[0].diffContent).toContain('+line2.5');
    });

    it('should use each patch file mtime for extractedAt', async () => {
      const firstTarget = path.join(tmpDir, 'first.md');
      const secondTarget = path.join(tmpDir, 'second.md');
      const firstTimestamp = new Date('2025-01-15T10:00:00.000Z');
      const secondTimestamp = new Date('2025-01-16T12:00:00.000Z');

      await fs.writeFile(
        path.join(memoryTempDir, '.extraction-state.json'),
        JSON.stringify({
          runs: [
            {
              runAt: '2025-02-01T00:00:00Z',
              sessionIds: ['later-run'],
              skillsCreated: [],
            },
          ],
        }),
      );

      await fs.writeFile(
        path.join(skillsDir, 'first.patch'),
        [
          `--- ${firstTarget}`,
          `+++ ${firstTarget}`,
          '@@ -1,1 +1,1 @@',
          '-before',
          '+after',
          '',
        ].join('\n'),
      );
      await fs.writeFile(
        path.join(skillsDir, 'second.patch'),
        [
          `--- ${secondTarget}`,
          `+++ ${secondTarget}`,
          '@@ -1,1 +1,1 @@',
          '-before',
          '+after',
          '',
        ].join('\n'),
      );

      await fs.utimes(
        path.join(skillsDir, 'first.patch'),
        firstTimestamp,
        firstTimestamp,
      );
      await fs.utimes(
        path.join(skillsDir, 'second.patch'),
        secondTimestamp,
        secondTimestamp,
      );

      const result = await listInboxPatches(patchConfig);
      const firstPatch = result.find(
        (patch) => patch.fileName === 'first.patch',
      );
      const secondPatch = result.find(
        (patch) => patch.fileName === 'second.patch',
      );

      expect(firstPatch?.extractedAt).toBe(firstTimestamp.toISOString());
      expect(secondPatch?.extractedAt).toBe(secondTimestamp.toISOString());
    });

    it('should skip patches with no hunks', async () => {
      await fs.writeFile(
        path.join(skillsDir, 'empty.patch'),
        'not a valid patch',
      );

      const result = await listInboxPatches(patchConfig);
      expect(result).toEqual([]);
    });
  });

  describe('applyInboxPatch', () => {
    let tmpDir: string;
    let skillsDir: string;
    let memoryTempDir: string;
    let globalSkillsDir: string;
    let projectSkillsDir: string;
    let applyConfig: Config;

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-apply-test-'));
      skillsDir = path.join(tmpDir, 'skills-memory');
      memoryTempDir = path.join(tmpDir, 'memory-temp');
      globalSkillsDir = path.join(tmpDir, 'global-skills');
      projectSkillsDir = path.join(tmpDir, 'project-skills');
      await fs.mkdir(skillsDir, { recursive: true });
      await fs.mkdir(memoryTempDir, { recursive: true });
      await fs.mkdir(globalSkillsDir, { recursive: true });
      await fs.mkdir(projectSkillsDir, { recursive: true });

      applyConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => skillsDir,
          getProjectMemoryTempDir: () => memoryTempDir,
          getProjectSkillsDir: () => projectSkillsDir,
        },
        isTrustedFolder: () => true,
      } as unknown as Config;

      vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('should apply a valid patch and delete it', async () => {
      const targetFile = path.join(projectSkillsDir, 'target.md');
      await fs.writeFile(targetFile, 'line1\nline2\nline3\n');

      const patchContent = [
        `--- ${targetFile}`,
        `+++ ${targetFile}`,
        '@@ -1,3 +1,4 @@',
        ' line1',
        ' line2',
        '+line2.5',
        ' line3',
        '',
      ].join('\n');
      const patchPath = path.join(skillsDir, 'good.patch');
      await fs.writeFile(patchPath, patchContent);

      const result = await applyInboxPatch(applyConfig, 'good.patch');

      expect(result.success).toBe(true);
      expect(result.message).toContain('Applied patch to 1 file');

      // Verify target was modified
      const modified = await fs.readFile(targetFile, 'utf-8');
      expect(modified).toContain('line2.5');

      // Verify patch was deleted
      await expect(fs.access(patchPath)).rejects.toThrow();
    });

    it('should apply a multi-file patch', async () => {
      const file1 = path.join(globalSkillsDir, 'file1.md');
      const file2 = path.join(projectSkillsDir, 'file2.md');
      await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
      await fs.writeFile(file2, 'xxx\nyyy\nzzz\n');

      const patchContent = [
        `--- ${file1}`,
        `+++ ${file1}`,
        '@@ -1,3 +1,4 @@',
        ' aaa',
        ' bbb',
        '+bbb2',
        ' ccc',
        `--- ${file2}`,
        `+++ ${file2}`,
        '@@ -1,3 +1,4 @@',
        ' xxx',
        ' yyy',
        '+yyy2',
        ' zzz',
        '',
      ].join('\n');
      await fs.writeFile(path.join(skillsDir, 'multi.patch'), patchContent);

      const result = await applyInboxPatch(applyConfig, 'multi.patch');

      expect(result.success).toBe(true);
      expect(result.message).toContain('2 files');

      expect(await fs.readFile(file1, 'utf-8')).toContain('bbb2');
      expect(await fs.readFile(file2, 'utf-8')).toContain('yyy2');
    });

    it('should apply repeated file blocks against the cumulative patched content', async () => {
      const targetFile = path.join(projectSkillsDir, 'target.md');
      await fs.writeFile(targetFile, 'alpha\nbeta\ngamma\ndelta\n');

      await fs.writeFile(
        path.join(skillsDir, 'multi-section.patch'),
        [
          `--- ${targetFile}`,
          `+++ ${targetFile}`,
          '@@ -1,4 +1,5 @@',
          ' alpha',
          ' beta',
          '+beta2',
          ' gamma',
          ' delta',
          `--- ${targetFile}`,
          `+++ ${targetFile}`,
          '@@ -2,4 +2,5 @@',
          ' beta',
          ' beta2',
          ' gamma',
          '+gamma2',
          ' delta',
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(applyConfig, 'multi-section.patch');

      expect(result.success).toBe(true);
      expect(result.message).toContain('Applied patch to 1 file');
      expect(await fs.readFile(targetFile, 'utf-8')).toBe(
        'alpha\nbeta\nbeta2\ngamma\ngamma2\ndelta\n',
      );
    });

    it('should reject /dev/null patches that target an existing skill file', async () => {
      const targetFile = path.join(projectSkillsDir, 'existing-skill.md');
      await fs.writeFile(targetFile, 'original content\n');

      const patchPath = path.join(skillsDir, 'bad-new-file.patch');
      await fs.writeFile(
        patchPath,
        [
          '--- /dev/null',
          `+++ ${targetFile}`,
          '@@ -0,0 +1 @@',
          '+replacement content',
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(applyConfig, 'bad-new-file.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain('target already exists');
      expect(await fs.readFile(targetFile, 'utf-8')).toBe('original content\n');
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });

    it('should fail when patch does not exist', async () => {
      const result = await applyInboxPatch(applyConfig, 'missing.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain('not found');
    });

    it('should reject invalid patch file names', async () => {
      const outsidePatch = path.join(tmpDir, 'outside.patch');
      await fs.writeFile(outsidePatch, 'outside patch content');

      const result = await applyInboxPatch(applyConfig, '../outside.patch');

      expect(result.success).toBe(false);
      expect(result.message).toBe('Invalid patch file name.');
      await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
    });

    it('should fail when target file does not exist', async () => {
      const missingFile = path.join(projectSkillsDir, 'missing-target.md');
      const patchContent = [
        `--- ${missingFile}`,
        `+++ ${missingFile}`,
        '@@ -1,3 +1,4 @@',
        ' a',
        ' b',
        '+c',
        ' d',
        '',
      ].join('\n');
      await fs.writeFile(
        path.join(skillsDir, 'bad-target.patch'),
        patchContent,
      );

      const result = await applyInboxPatch(applyConfig, 'bad-target.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain('Target file not found');
    });

    it('should reject targets outside the global and workspace skill roots', async () => {
      const outsideFile = path.join(tmpDir, 'outside.md');
      await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');

      const patchContent = [
        `--- ${outsideFile}`,
        `+++ ${outsideFile}`,
        '@@ -1,3 +1,4 @@',
        ' line1',
        ' line2',
        '+line2.5',
        ' line3',
        '',
      ].join('\n');
      const patchPath = path.join(skillsDir, 'outside.patch');
      await fs.writeFile(patchPath, patchContent);

      const result = await applyInboxPatch(applyConfig, 'outside.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain(
        'outside the global/workspace skill directories',
      );
      expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });

    it('should reject targets that escape the skill root through a symlinked parent', async () => {
      const outsideDir = path.join(tmpDir, 'outside-dir');
      const linkDir = path.join(projectSkillsDir, 'linked');
      await fs.mkdir(outsideDir, { recursive: true });
      await fs.symlink(
        outsideDir,
        linkDir,
        process.platform === 'win32' ? 'junction' : 'dir',
      );

      const outsideFile = path.join(outsideDir, 'escaped.md');
      await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');

      const patchPath = path.join(skillsDir, 'symlink.patch');
      await fs.writeFile(
        patchPath,
        [
          `--- ${path.join(linkDir, 'escaped.md')}`,
          `+++ ${path.join(linkDir, 'escaped.md')}`,
          '@@ -1,3 +1,4 @@',
          ' line1',
          ' line2',
          '+line2.5',
          ' line3',
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(applyConfig, 'symlink.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain(
        'outside the global/workspace skill directories',
      );
      expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });

    it('should reject patches that contain no hunks', async () => {
      await fs.writeFile(
        path.join(skillsDir, 'empty.patch'),
        [
          `--- ${path.join(projectSkillsDir, 'target.md')}`,
          `+++ ${path.join(projectSkillsDir, 'target.md')}`,
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(applyConfig, 'empty.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain('contains no valid hunks');
    });

    it('should reject project-scope patches when the workspace is untrusted', async () => {
      const targetFile = path.join(projectSkillsDir, 'target.md');
      await fs.writeFile(targetFile, 'line1\nline2\nline3\n');

      const patchPath = path.join(skillsDir, 'workspace.patch');
      await fs.writeFile(
        patchPath,
        [
          `--- ${targetFile}`,
          `+++ ${targetFile}`,
          '@@ -1,3 +1,4 @@',
          ' line1',
          ' line2',
          '+line2.5',
          ' line3',
          '',
        ].join('\n'),
      );

      const untrustedConfig = {
        storage: applyConfig.storage,
        isTrustedFolder: () => false,
      } as Config;
      const result = await applyInboxPatch(untrustedConfig, 'workspace.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain(
        'Project skill patches are unavailable until this workspace is trusted.',
      );
      expect(await fs.readFile(targetFile, 'utf-8')).toBe(
        'line1\nline2\nline3\n',
      );
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });

    it('should reject project-scope patches through a symlinked project skills root when the workspace is untrusted', async () => {
      const realProjectSkillsDir = path.join(tmpDir, 'project-skills-real');
      const symlinkedProjectSkillsDir = path.join(
        tmpDir,
        'project-skills-link',
      );
      await fs.mkdir(realProjectSkillsDir, { recursive: true });
      await fs.symlink(
        realProjectSkillsDir,
        symlinkedProjectSkillsDir,
        process.platform === 'win32' ? 'junction' : 'dir',
      );
      projectSkillsDir = symlinkedProjectSkillsDir;

      const targetFile = path.join(realProjectSkillsDir, 'target.md');
      await fs.writeFile(targetFile, 'line1\nline2\nline3\n');

      const patchPath = path.join(skillsDir, 'workspace-symlink.patch');
      await fs.writeFile(
        patchPath,
        [
          `--- ${targetFile}`,
          `+++ ${targetFile}`,
          '@@ -1,3 +1,4 @@',
          ' line1',
          ' line2',
          '+line2.5',
          ' line3',
          '',
        ].join('\n'),
      );

      const untrustedConfig = {
        storage: applyConfig.storage,
        isTrustedFolder: () => false,
      } as Config;
      const result = await applyInboxPatch(
        untrustedConfig,
        'workspace-symlink.patch',
      );

      expect(result.success).toBe(false);
      expect(result.message).toContain(
        'Project skill patches are unavailable until this workspace is trusted.',
      );
      expect(await fs.readFile(targetFile, 'utf-8')).toBe(
        'line1\nline2\nline3\n',
      );
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });

    it('should reject patches with mismatched diff headers', async () => {
      const sourceFile = path.join(projectSkillsDir, 'source.md');
      const targetFile = path.join(projectSkillsDir, 'target.md');
      await fs.writeFile(sourceFile, 'aaa\nbbb\nccc\n');
      await fs.writeFile(targetFile, 'xxx\nyyy\nzzz\n');

      const patchPath = path.join(skillsDir, 'mismatched-headers.patch');
      await fs.writeFile(
        patchPath,
        [
          `--- ${sourceFile}`,
          `+++ ${targetFile}`,
          '@@ -1,3 +1,4 @@',
          ' xxx',
          ' yyy',
          '+yyy2',
          ' zzz',
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(
        applyConfig,
        'mismatched-headers.patch',
      );

      expect(result.success).toBe(false);
      expect(result.message).toContain('invalid diff headers');
      expect(await fs.readFile(sourceFile, 'utf-8')).toBe('aaa\nbbb\nccc\n');
      expect(await fs.readFile(targetFile, 'utf-8')).toBe('xxx\nyyy\nzzz\n');
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });

    it('should strip git-style a/ and b/ prefixes and apply successfully', async () => {
      const targetFile = path.join(projectSkillsDir, 'prefixed.md');
      await fs.writeFile(targetFile, 'line1\nline2\nline3\n');

      const patchPath = path.join(skillsDir, 'git-prefix.patch');
      await fs.writeFile(
        patchPath,
        [
          `--- a/${targetFile}`,
          `+++ b/${targetFile}`,
          '@@ -1,3 +1,4 @@',
          ' line1',
          ' line2',
          '+line2.5',
          ' line3',
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(applyConfig, 'git-prefix.patch');

      expect(result.success).toBe(true);
      expect(result.message).toContain('Applied patch to 1 file');
      expect(await fs.readFile(targetFile, 'utf-8')).toBe(
        'line1\nline2\nline2.5\nline3\n',
      );
      await expect(fs.access(patchPath)).rejects.toThrow();
    });

    it('should not write any files if one patch in a multi-file set fails', async () => {
      const file1 = path.join(projectSkillsDir, 'file1.md');
      await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
      const missingFile = path.join(projectSkillsDir, 'missing.md');

      const patchContent = [
        `--- ${file1}`,
        `+++ ${file1}`,
        '@@ -1,3 +1,4 @@',
        ' aaa',
        ' bbb',
        '+bbb2',
        ' ccc',
        `--- ${missingFile}`,
        `+++ ${missingFile}`,
        '@@ -1,3 +1,4 @@',
        ' x',
        ' y',
        '+z',
        ' w',
        '',
      ].join('\n');
      await fs.writeFile(path.join(skillsDir, 'partial.patch'), patchContent);

      const result = await applyInboxPatch(applyConfig, 'partial.patch');

      expect(result.success).toBe(false);
      // Verify file1 was NOT modified (dry-run failed)
      const content = await fs.readFile(file1, 'utf-8');
      expect(content).not.toContain('bbb2');
    });

    it('should roll back earlier file updates if a later commit step fails', async () => {
      const file1 = path.join(projectSkillsDir, 'file1.md');
      await fs.writeFile(file1, 'aaa\nbbb\nccc\n');

      const conflictPath = path.join(projectSkillsDir, 'conflict');
      const nestedNewFile = path.join(conflictPath, 'nested.md');

      const patchPath = path.join(skillsDir, 'rollback.patch');
      await fs.writeFile(
        patchPath,
        [
          `--- ${file1}`,
          `+++ ${file1}`,
          '@@ -1,3 +1,4 @@',
          ' aaa',
          ' bbb',
          '+bbb2',
          ' ccc',
          '--- /dev/null',
          `+++ ${conflictPath}`,
          '@@ -0,0 +1 @@',
          '+new file content',
          '--- /dev/null',
          `+++ ${nestedNewFile}`,
          '@@ -0,0 +1 @@',
          '+nested new file content',
          '',
        ].join('\n'),
      );

      const result = await applyInboxPatch(applyConfig, 'rollback.patch');

      expect(result.success).toBe(false);
      expect(result.message).toContain('could not be applied atomically');
      expect(await fs.readFile(file1, 'utf-8')).toBe('aaa\nbbb\nccc\n');
      expect((await fs.stat(conflictPath)).isDirectory()).toBe(true);
      await expect(fs.access(nestedNewFile)).rejects.toThrow();
      await expect(fs.access(patchPath)).resolves.toBeUndefined();
    });
  });

  describe('dismissInboxPatch', () => {
    let tmpDir: string;
    let skillsDir: string;
    let dismissPatchConfig: Config;

    beforeEach(async () => {
      tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-dismiss-test-'));
      skillsDir = path.join(tmpDir, 'skills-memory');
      await fs.mkdir(skillsDir, { recursive: true });

      dismissPatchConfig = {
        storage: {
          getProjectSkillsMemoryDir: () => skillsDir,
        },
      } as unknown as Config;
    });

    afterEach(async () => {
      await fs.rm(tmpDir, { recursive: true, force: true });
    });

    it('should delete the patch file and return success', async () => {
      const patchPath = path.join(skillsDir, 'old.patch');
      await fs.writeFile(patchPath, 'some patch content');

      const result = await dismissInboxPatch(dismissPatchConfig, 'old.patch');

      expect(result.success).toBe(true);
      expect(result.message).toContain('Dismissed');
      await expect(fs.access(patchPath)).rejects.toThrow();
    });

    it('should return error when patch does not exist', async () => {
      const result = await dismissInboxPatch(
        dismissPatchConfig,
        'nonexistent.patch',
      );

      expect(result.success).toBe(false);
      expect(result.message).toContain('not found');
    });

    it('should reject invalid patch file names', async () => {
      const outsidePatch = path.join(tmpDir, 'outside.patch');
      await fs.writeFile(outsidePatch, 'outside patch content');

      const result = await dismissInboxPatch(
        dismissPatchConfig,
        '../outside.patch',
      );

      expect(result.success).toBe(false);
      expect(result.message).toBe('Invalid patch file name.');
      await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
    });
  });
});
