// *****************************************************************************
// Copyright (C) 2025 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';

let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});

import 'reflect-metadata';

import { expect } from 'chai';
import { dump, load } from 'js-yaml';
import URI from '@theia/core/lib/common/uri';
import { parseTemplateWithMetadata, ParsedTemplate } from './prompttemplate-parser';
import { serializeFrontmatter } from '../common/frontmatter';
import {
    CustomizationSource, DefaultPromptFragmentCustomizationService, CUSTOM_AGENTS_DIRECTORY, CUSTOM_AGENT_FILE_NAME
} from './frontend-prompt-customization-service';

disableJSDOM();

describe('Prompt Template Parser', () => {

    describe('YAML Front Matter Parsing', () => {
        it('extracts YAML front matter correctly', () => {
            const fileContent = `---
isCommand: true
commandName: hello
commandDescription: Say hello
commandArgumentHint: <name>
commandAgents:
  - Universal
  - Agent2
---
Template content here`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.template).to.equal('Template content here');
            expect(result.metadata).to.not.be.undefined;
            expect(result.metadata?.isCommand).to.be.true;
            expect(result.metadata?.commandName).to.equal('hello');
            expect(result.metadata?.commandDescription).to.equal('Say hello');
            expect(result.metadata?.commandArgumentHint).to.equal('<name>');
            expect(result.metadata?.commandAgents).to.deep.equal(['Universal', 'Agent2']);
        });

        it('returns template without front matter when none exists', () => {
            const fileContent = 'Just a regular template';

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.template).to.equal('Just a regular template');
            expect(result.metadata).to.be.undefined;
        });

        it('handles missing front matter gracefully', () => {
            const fileContent = `---
This is not valid YAML front matter
Template content`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            // Should return content as-is when front matter is invalid
            expect(result.template).to.equal(fileContent);
        });

        it('handles invalid YAML gracefully', () => {
            const fileContent = `---
isCommand: true
commandName: [unclosed array
---
Template content`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            // Should return template without metadata on parse error
            expect(result.template).to.equal(fileContent);
            expect(result.metadata).to.be.undefined;
        });

        it('validates command metadata types', () => {
            const fileContent = `---
isCommand: "true"
commandName: 123
commandDescription: valid
commandArgumentHint: <arg>
commandAgents: "not-an-array"
---
Template`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.template).to.equal('Template');
            expect(result.metadata?.isCommand).to.be.undefined; // Wrong type
            expect(result.metadata?.commandName).to.be.undefined; // Wrong type
            expect(result.metadata?.commandDescription).to.equal('valid');
            expect(result.metadata?.commandArgumentHint).to.equal('<arg>');
            expect(result.metadata?.commandAgents).to.be.undefined; // Wrong type
        });

        it('filters commandAgents to strings only', () => {
            const fileContent = `---
commandAgents:
  - ValidAgent
  - 123
  - AnotherValid
  - true
  - LastValid
---
Template`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.metadata?.commandAgents).to.deep.equal(['ValidAgent', 'AnotherValid', 'LastValid']);
        });

        it('handles partial metadata fields', () => {
            const fileContent = `---
isCommand: true
commandName: test
---
Template content`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.template).to.equal('Template content');
            expect(result.metadata?.isCommand).to.be.true;
            expect(result.metadata?.commandName).to.equal('test');
            expect(result.metadata?.commandDescription).to.be.undefined;
            expect(result.metadata?.commandArgumentHint).to.be.undefined;
            expect(result.metadata?.commandAgents).to.be.undefined;
        });

        it('preserves template content with special characters', () => {
            const fileContent = `---
isCommand: true
---
Template with $ARGUMENTS and {{variable}} and ~{function}`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.template).to.equal('Template with $ARGUMENTS and {{variable}} and ~{function}');
            expect(result.metadata?.isCommand).to.be.true;
        });

        it('extracts name and description from front matter', () => {
            const fileContent = `---
name: My Fragment
description: A helpful description of this fragment
---
Template content`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.template).to.equal('Template content');
            expect(result.metadata?.name).to.equal('My Fragment');
            expect(result.metadata?.description).to.equal('A helpful description of this fragment');
        });

        it('extracts name and description alongside command metadata', () => {
            const fileContent = `---
name: App Tester
description: Delegate testing to AppTester
isCommand: true
commandName: apptester
---
Template content`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.metadata?.name).to.equal('App Tester');
            expect(result.metadata?.description).to.equal('Delegate testing to AppTester');
            expect(result.metadata?.isCommand).to.be.true;
            expect(result.metadata?.commandName).to.equal('apptester');
        });

        it('handles missing name and description gracefully', () => {
            const fileContent = `---
isCommand: true
---
Template content`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.metadata?.name).to.be.undefined;
            expect(result.metadata?.description).to.be.undefined;
            expect(result.metadata?.isCommand).to.be.true;
        });

        it('rejects non-string name and description', () => {
            const fileContent = `---
name: 42
description: true
---
Template`;

            const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);

            expect(result.metadata?.name).to.be.undefined;
            expect(result.metadata?.description).to.be.undefined;
        });
    });

    describe('DefaultPromptFragmentCustomizationService - addTemplate conflict resolution', () => {
        before(() => disableJSDOM = enableJSDOM());
        after(() => disableJSDOM());

        interface FragmentEntry {
            id: string;
            template: string;
            sourceUri: string;
            sourceUris: string[];
            priority: number;
            origin: CustomizationSource;
            customizationId: string;
        }

        /**
         * Test subclass that exposes the protected `addTemplate` and `provenanceLabel`
         * methods so we can unit-test conflict resolution without mocking the filesystem.
         */
        class TestableCustomizationService extends DefaultPromptFragmentCustomizationService {
            // Prevent @postConstruct from running (it touches preferences)
            protected override init(): void { }

            /** Configure fake workspace roots so provenanceLabel can resolve them. */
            setRoots(rootUris: string[]): void {
                const roots = rootUris.map(r => new URI(r));
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                (this as any).workspaceService = {
                    getWorkspaceRootUri(uri: URI): URI | undefined {
                        return roots.find(root => uri.toString().startsWith(root.toString()));
                    }
                };
            }

            public testAddTemplate(
                active: Map<string, FragmentEntry>,
                id: string,
                template: string,
                sourceUri: string,
                all: Map<string, FragmentEntry>,
                priority: number,
                origin: CustomizationSource
            ): void {
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                (this as any).addTemplate(active, id, template, sourceUri, all, priority, origin);
            }

            public testProvenanceLabel(sourceUri: string): string {
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                return (this as any).provenanceLabel(sourceUri);
            }
        }

        let service: TestableCustomizationService;
        let activeMap: Map<string, FragmentEntry>;
        let allMap: Map<string, FragmentEntry>;

        beforeEach(() => {
            service = new TestableCustomizationService();
            service.setRoots([
                'file:///rootA',
                'file:///rootB',
                'file:///rootC',
                'file:///home/user/my-project'
            ]);
            activeMap = new Map();
            allMap = new Map();
        });

        it('adds a fragment when no conflict exists', () => {
            const uri = 'file:///rootA/.prompts/project-info.prompttemplate';
            service.testAddTemplate(
                activeMap, 'project-info', 'Content A', uri, allMap, 2, CustomizationSource.FOLDER
            );

            expect(activeMap.has('project-info')).to.be.true;
            expect(activeMap.get('project-info')!.template).to.equal('Content A');
            expect(activeMap.get('project-info')!.sourceUris).to.deep.equal([uri]);
        });

        it('higher priority replaces lower priority', () => {
            service.testAddTemplate(
                activeMap, 'project-info', 'Low priority',
                'file:///rootA/.prompts/project-info.prompttemplate', allMap, 1, CustomizationSource.CUSTOMIZED
            );
            service.testAddTemplate(
                activeMap, 'project-info', 'High priority',
                'file:///rootB/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
            );

            expect(activeMap.get('project-info')!.template).to.equal('High priority');
        });

        it('same source URI updates in place', () => {
            const uri = 'file:///rootA/.prompts/project-info.prompttemplate';
            service.testAddTemplate(activeMap, 'project-info', 'Original', uri, allMap, 2, CustomizationSource.FOLDER);
            service.testAddTemplate(activeMap, 'project-info', 'Updated', uri, allMap, 2, CustomizationSource.FOLDER);

            expect(activeMap.get('project-info')!.template).to.equal('Updated');
            expect(activeMap.get('project-info')!.sourceUris).to.deep.equal([uri]);
        });

        it('equal priority from different sources concatenates with provenance labels', () => {
            service.testAddTemplate(
                activeMap, 'project-info', 'Content A',
                'file:///rootA/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
            );
            service.testAddTemplate(
                activeMap, 'project-info', 'Content B',
                'file:///rootB/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
            );

            const entry = activeMap.get('project-info')!;
            expect(entry.sourceUris).to.have.lengthOf(2);
            expect(entry.template).to.contain('Content A');
            expect(entry.template).to.contain('Content B');
            expect(entry.template).to.contain('### rootA');
            expect(entry.template).to.contain('### rootB');
        });

        it('three-way merge concatenates all sources in order', () => {
            service.testAddTemplate(
                activeMap, 'project-info', 'Content A',
                'file:///rootA/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
            );
            service.testAddTemplate(
                activeMap, 'project-info', 'Content B',
                'file:///rootB/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
            );
            service.testAddTemplate(
                activeMap, 'project-info', 'Content C',
                'file:///rootC/.prompts/project-info.prompttemplate', allMap, 2, CustomizationSource.FOLDER
            );

            const entry = activeMap.get('project-info')!;
            expect(entry.sourceUris).to.have.lengthOf(3);
            expect(entry.template).to.contain('### rootA');
            expect(entry.template).to.contain('### rootB');
            expect(entry.template).to.contain('### rootC');
            // Verify ordering: A before B before C
            const idxA = entry.template.indexOf('Content A');
            const idxB = entry.template.indexOf('Content B');
            const idxC = entry.template.indexOf('Content C');
            expect(idxA).to.be.lessThan(idxB);
            expect(idxB).to.be.lessThan(idxC);
        });

        it('provenanceLabel extracts grandparent directory name from URI', () => {
            // For "file:///home/user/my-project/.prompts/foo.prompttemplate"
            // parent is ".prompts", grandparent is "my-project"
            const label = service.testProvenanceLabel(
                'file:///home/user/my-project/.prompts/foo.prompttemplate'
            );
            expect(label).to.equal('my-project');
        });

        it('provenanceLabel falls back to parent if grandparent is empty', () => {
            expect(service.testProvenanceLabel('file:///.prompts/foo.prompttemplate'))
                .to.equal('.prompts');
        });

        it('merged entry preserves primary sourceUri for backwards compatibility', () => {
            const uriA = 'file:///rootA/.prompts/project-info.prompttemplate';
            const uriB = 'file:///rootB/.prompts/project-info.prompttemplate';
            service.testAddTemplate(activeMap, 'project-info', 'Content A', uriA, allMap, 2, CustomizationSource.FOLDER);
            service.testAddTemplate(activeMap, 'project-info', 'Content B', uriB, allMap, 2, CustomizationSource.FOLDER);

            const entry = activeMap.get('project-info')!;
            // Primary sourceUri should be the first one added
            expect(entry.sourceUri).to.equal(uriA);
            // All sources tracked
            expect(entry.sourceUris).to.deep.equal([uriA, uriB]);
        });

        it('all map tracks each source independently', () => {
            const uriA = 'file:///rootA/.prompts/project-info.prompttemplate';
            const uriB = 'file:///rootB/.prompts/project-info.prompttemplate';
            service.testAddTemplate(activeMap, 'project-info', 'Content A', uriA, allMap, 2, CustomizationSource.FOLDER);
            service.testAddTemplate(activeMap, 'project-info', 'Content B', uriB, allMap, 2, CustomizationSource.FOLDER);

            // allCustomizations is keyed by sourceUri, so both should be present
            expect(allMap.has(uriA)).to.be.true;
            expect(allMap.has(uriB)).to.be.true;
            expect(allMap.get(uriA)!.template).to.equal('Content A');
            expect(allMap.get(uriB)!.template).to.equal('Content B');
        });
    });
});

interface FakeFileStat {
    resource: URI;
    isFile: boolean;
    isDirectory: boolean;
    children?: FakeFileStat[];
}

/**
 * Minimal in-memory {@link FileService} covering exactly the operations the customAgents.yml
 * migration uses (exists/read/resolve/createFile/move/delete). Files are keyed by their URI
 * string; directories are implied by the paths of the files they contain.
 */
class FakeFileService {
    readonly files = new Map<string, string>();
    /** Target URIs for which `createFile` should throw, to simulate write failures. */
    readonly failCreateFor = new Set<string>();

    write(uri: URI, content: string): void {
        this.files.set(uri.toString(), content);
    }

    content(uri: URI): string | undefined {
        return this.files.get(uri.toString());
    }

    protected childrenOf(dirUriStr: string): FakeFileStat[] {
        const prefix = dirUriStr.endsWith('/') ? dirUriStr : dirUriStr + '/';
        const isDirectoryByName = new Map<string, boolean>();
        for (const key of this.files.keys()) {
            if (!key.startsWith(prefix)) {
                continue;
            }
            const rest = key.substring(prefix.length);
            const slash = rest.indexOf('/');
            if (slash === -1) {
                if (!isDirectoryByName.has(rest)) {
                    isDirectoryByName.set(rest, false);
                }
            } else {
                isDirectoryByName.set(rest.substring(0, slash), true);
            }
        }
        return Array.from(isDirectoryByName.entries()).map(([name, isDirectory]) => ({
            resource: new URI(prefix + name),
            isFile: !isDirectory,
            isDirectory
        }));
    }

    async exists(uri: URI): Promise<boolean> {
        const key = uri.toString();
        return this.files.has(key) || this.childrenOf(key).length > 0;
    }

    async read(uri: URI): Promise<{ value: string }> {
        const key = uri.toString();
        if (!this.files.has(key)) {
            throw new Error('ENOENT: ' + key);
        }
        return { value: this.files.get(key)! };
    }

    async resolve(uri: URI): Promise<FakeFileStat> {
        const key = uri.toString();
        if (this.files.has(key)) {
            return { resource: uri, isFile: true, isDirectory: false };
        }
        const children = this.childrenOf(key);
        if (children.length === 0) {
            throw new Error('ENOENT: ' + key);
        }
        return { resource: uri, isFile: false, isDirectory: true, children };
    }

    async createFile(uri: URI, value?: { toString(): string }): Promise<void> {
        const key = uri.toString();
        if (this.failCreateFor.has(key)) {
            throw new Error('simulated write failure for ' + key);
        }
        this.files.set(key, value ? value.toString() : '');
    }

    async move(source: URI, target: URI): Promise<void> {
        const sourceKey = source.toString();
        if (!this.files.has(sourceKey)) {
            throw new Error('ENOENT: ' + sourceKey);
        }
        const content = this.files.get(sourceKey)!;
        this.files.delete(sourceKey);
        this.files.set(target.toString(), content);
    }

    async delete(uri: URI): Promise<void> {
        const key = uri.toString();
        this.files.delete(key);
        const prefix = key.endsWith('/') ? key : key + '/';
        for (const existing of Array.from(this.files.keys())) {
            if (existing.startsWith(prefix)) {
                this.files.delete(existing);
            }
        }
    }
}

describe('DefaultPromptFragmentCustomizationService - customAgents.yml migration', () => {
    before(() => disableJSDOM = enableJSDOM());
    after(() => disableJSDOM());

    /** Test subclass: skip @postConstruct and pin the scope so we don't need preferences/env. */
    class MigrationTestService extends DefaultPromptFragmentCustomizationService {
        templatesDir!: URI;
        protected override init(): void { }
        protected override getTemplatesDirectoryURI(): Promise<URI> {
            return Promise.resolve(this.templatesDir);
        }
        testParse(raw: string): unknown {
            return this.parseCustomAgentsYaml(raw);
        }
        testUnfold(raw: string): string {
            return this.unfoldPromptBlockScalars(raw);
        }
    }

    const scope = new URI('file:///ws/.prompts');
    const yamlURI = scope.resolve('customAgents.yml');
    const backupURI = scope.resolve('customAgents.yml.bak');
    const agentMd = (id: string): URI => scope.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(id).resolve(CUSTOM_AGENT_FILE_NAME);

    const agents = [
        { id: 'foo', name: 'Foo', description: 'Foo agent', prompt: 'Foo prompt', defaultLLM: 'default/universal' },
        { id: 'bar', name: 'Bar', description: 'Bar agent', prompt: 'Bar prompt', defaultLLM: 'default/universal' }
    ];

    let fileService: FakeFileService;
    let service: MigrationTestService;

    beforeEach(() => {
        fileService = new FakeFileService();
        service = new MigrationTestService();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (service as any).fileService = fileService;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (service as any).logger = { debug(): void { }, info(): void { }, warn(): void { }, error(): void { } };
        service.templatesDir = scope;
    });

    it('fresh migration writes agent.md per entry, folds in sibling prompt files, and backs up the yaml', async () => {
        fileService.write(yamlURI, dump(agents));
        fileService.write(scope.resolve('Foo_prompt.prompttemplate'), 'custom foo prompt');

        const [report] = await service.migrateCustomAgentsYaml();

        expect(report.migrated).to.equal(2);
        expect(report.alreadyPresent).to.equal(0);
        expect(report.failed).to.equal(0);
        expect(report.yamlBackedUp).to.be.true;
        expect(report.promptOverridesMigrated).to.equal(1);

        expect(await fileService.exists(agentMd('foo'))).to.be.true;
        expect(await fileService.exists(agentMd('bar'))).to.be.true;
        // The sibling override is moved into the agent folder and removed from the scope root.
        expect(await fileService.exists(scope.resolve('Foo_prompt.prompttemplate'))).to.be.false;
        expect(await fileService.exists(scope.resolve(CUSTOM_AGENTS_DIRECTORY).resolve('foo').resolve('Foo_prompt.prompttemplate'))).to.be.true;
        // The yaml is renamed to .bak rather than deleted.
        expect(await fileService.exists(yamlURI)).to.be.false;
        expect(await fileService.exists(backupURI)).to.be.true;
    });

    it('rerun after a successful migration is a no-op', async () => {
        fileService.write(yamlURI, dump(agents));
        await service.migrateCustomAgentsYaml();
        const fooAfterFirstRun = fileService.content(agentMd('foo'));

        const reports = await service.migrateCustomAgentsYaml();

        // The yaml is already backed up, so there is nothing left to migrate.
        expect(reports).to.have.lengthOf(0);
        expect(fileService.content(agentMd('foo'))).to.equal(fooAfterFirstRun);
    });

    it('is idempotent: never overwrites an already-migrated agent.md', async () => {
        fileService.write(yamlURI, dump(agents));
        fileService.write(agentMd('foo'), 'PRE-EXISTING FOO');

        const [report] = await service.migrateCustomAgentsYaml();

        expect(report.alreadyPresent).to.equal(1);
        expect(report.migrated).to.equal(1); // only 'bar' is written
        expect(fileService.content(agentMd('foo'))).to.equal('PRE-EXISTING FOO');
        expect(await fileService.exists(agentMd('bar'))).to.be.true;
    });

    it('partial failure with no existing backup still renames the yaml to .bak and reports the failure', async () => {
        fileService.write(yamlURI, dump(agents));
        fileService.failCreateFor.add(agentMd('bar').toString());

        const [report] = await service.migrateCustomAgentsYaml();

        expect(report.migrated).to.equal(1); // 'foo' succeeds
        expect(report.failed).to.equal(1); // 'bar' fails
        // The yaml was renamed to .bak (preserved, never silently deleted), so this is reported truthfully.
        expect(report.yamlBackedUp).to.be.true;
        expect(await fileService.exists(yamlURI)).to.be.false;
        expect(await fileService.exists(backupURI)).to.be.true;
    });

    it('partial failure preserves a pre-existing .bak and leaves the yaml in place', async () => {
        fileService.write(yamlURI, dump(agents));
        fileService.write(backupURI, 'ORIGINAL BACKUP');
        fileService.failCreateFor.add(agentMd('bar').toString());

        const [report] = await service.migrateCustomAgentsYaml();

        expect(report.failed).to.equal(1);
        expect(report.yamlBackedUp).to.be.false;
        // An existing .bak is never overwritten...
        expect(fileService.content(backupURI)).to.equal('ORIGINAL BACKUP');
        // ...and because a backup already exists, the yaml is left untouched for inspection.
        expect(await fileService.exists(yamlURI)).to.be.true;
    });

    describe('folded block-scalar prompts', () => {
        // A folded YAML scalar (`>-`) collapses single newlines into spaces, which merges markdown
        // headings into the paragraph below them. Migration must recover the original line breaks.
        const foldedYaml =
            '- id: rel\n' +
            '  name: Rel\n' +
            '  description: Release agent\n' +
            '  defaultLLM: default/code\n' +
            '  prompt: >-\n' +
            '    ## Task\n' +
            '    Do the thing.\n' +
            '\n' +
            '    ## Workflow\n' +
            '    Step one.\n';

        it('migrates a folded prompt with its headings on separate lines', async () => {
            fileService.write(yamlURI, foldedYaml);

            const [report] = await service.migrateCustomAgentsYaml();

            expect(report.migrated).to.equal(1);
            const content = fileService.content(agentMd('rel'))!;
            // js-yaml would have folded these into '## Task Do the thing.'; the fix keeps them split.
            expect(content).to.contain('## Task\nDo the thing.');
            expect(content).to.contain('## Workflow\nStep one.');
            expect(content).to.not.contain('## Task Do the thing.');
        });

        it('loads an un-migrated folded prompt with separated headings at runtime', async () => {
            fileService.write(yamlURI, foldedYaml);

            const [agent] = await service.getCustomAgents();

            expect(agent.prompt).to.contain('## Task\nDo the thing.');
            expect(agent.prompt).to.not.contain('## Task Do the thing.');
        });

        it('leaves plain and quoted prompt values untouched', () => {
            const plain = 'prompt: just a short prompt';
            const quoted = 'prompt: "> not a header"';
            expect(service.testUnfold(plain)).to.equal(plain);
            expect(service.testUnfold(quoted)).to.equal(quoted);
            // Folded headers (with or without indicators) become literal.
            expect(service.testUnfold('prompt: >-')).to.equal('prompt: |-');
            expect(service.testUnfold('prompt: >2-')).to.equal('prompt: |2-');
        });
    });

    describe('correcting already-migrated agents', () => {
        // Reproduces the bytes the v1.73.0 migration wrote: parse the backup the old (folded) way and
        // serialize it with the same frontmatter format the migration used.
        const buggyAgentMd = (raw: string, id: string): string => {
            const entry = (load(raw) as Array<{ id: string; name: string; description: string; prompt: string; defaultLLM: string }>).find(e => e.id === id)!;
            return serializeFrontmatter({ name: entry.name, description: entry.description, defaultLLM: entry.defaultLLM }, entry.prompt);
        };
        const foldedYaml =
            '- id: rel\n' +
            '  name: Rel\n' +
            '  description: Release agent\n' +
            '  defaultLLM: default/code\n' +
            '  prompt: >-\n' +
            '    ## Task\n' +
            '    Do the thing.\n';

        it('rewrites an untouched agent.md to recover folded headings', async () => {
            // Simulate the post-v1.73.0 state: yaml already backed up, agent.md written with merged headings.
            fileService.write(backupURI, foldedYaml);
            fileService.write(agentMd('rel'), buggyAgentMd(foldedYaml, 'rel'));

            const [report] = await service.migrateCustomAgentsYaml();

            expect(report.corrected).to.equal(1);
            expect(fileService.content(agentMd('rel'))!).to.contain('## Task\nDo the thing.');
        });

        it('does not touch an agent.md the user edited after migration', async () => {
            fileService.write(backupURI, foldedYaml);
            const edited = buggyAgentMd(foldedYaml, 'rel') + '\n\nManually added section.';
            fileService.write(agentMd('rel'), edited);

            const reports = await service.migrateCustomAgentsYaml();

            // Nothing is migrated and the edited file is not eligible for correction, so no report.
            expect(reports).to.have.lengthOf(0);
            expect(fileService.content(agentMd('rel'))).to.equal(edited);
        });

        it('reports nothing to do when the backup prompt has no folded headings', async () => {
            fileService.write(backupURI, dump(agents));
            fileService.write(agentMd('foo'), buggyAgentMd(dump(agents), 'foo'));

            const reports = await service.migrateCustomAgentsYaml();

            // Nothing to migrate and nothing correctable -> no report for the scope.
            expect(reports).to.have.lengthOf(0);
        });
    });

    describe('hasPendingCustomAgentMigration', () => {
        it('is true while a legacy customAgents.yml exists', async () => {
            fileService.write(yamlURI, dump(agents));
            expect(await service.hasPendingCustomAgentMigration()).to.be.true;
        });

        it('is true when a backup has correctable agents', async () => {
            const foldedYaml =
                '- id: rel\n  name: Rel\n  description: d\n  defaultLLM: default/code\n  prompt: >-\n    ## Task\n    Body.\n';
            fileService.write(backupURI, foldedYaml);
            const entry = (load(foldedYaml) as Array<{ name: string; description: string; prompt: string; defaultLLM: string }>)[0];
            fileService.write(agentMd('rel'), serializeFrontmatter({ name: entry.name, description: entry.description, defaultLLM: entry.defaultLLM }, entry.prompt));

            expect(await service.hasPendingCustomAgentMigration()).to.be.true;
        });

        it('is false once everything is migrated and correct', async () => {
            fileService.write(yamlURI, dump(agents));
            await service.migrateCustomAgentsYaml();

            expect(await service.hasPendingCustomAgentMigration()).to.be.false;
        });
    });
});

describe('DefaultPromptFragmentCustomizationService - custom agent scopes', () => {
    before(() => disableJSDOM = enableJSDOM());
    after(() => disableJSDOM());

    /** Test subclass: skip @postConstruct and pin the global templates directory. */
    class AgentScopeTestService extends DefaultPromptFragmentCustomizationService {
        globalDir = new URI('file:///global/prompt-templates');
        protected override init(): void { }
        protected override getTemplatesDirectoryURI(): Promise<URI> {
            return Promise.resolve(this.globalDir);
        }
        setCustomAgentDirs(paths: string[]): void {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            (this as any).customAgentDirs = new Set(paths);
        }
    }

    const agentMd = (scope: URI, id: string): URI => scope.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(id).resolve(CUSTOM_AGENT_FILE_NAME);
    const agentFile = (name: string, description: string): string =>
        `---\nname: ${name}\ndescription: ${description}\ndefaultLLM: default/universal\n---\n${name} prompt`;

    let fileService: FakeFileService;
    let service: AgentScopeTestService;

    beforeEach(() => {
        fileService = new FakeFileService();
        service = new AgentScopeTestService();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (service as any).fileService = fileService;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (service as any).logger = { debug(): void { }, info(): void { }, warn(): void { }, error(): void { } };
    });

    it('lists the built-in .agents scope before .prompts and the global directory', async () => {
        service.setCustomAgentDirs(['/ws/.agents', '/ws/.prompts']);

        const locations = await service.getCustomAgentsLocations();

        const agentDirParents = locations.filter(l => l.kind === 'agents-dir').map(l => l.uri.parent.path.toString());
        expect(agentDirParents).to.deep.equal(['/ws/.agents', '/ws/.prompts', '/global/prompt-templates']);
    });

    it('discovers custom agents stored under the .agents directory', async () => {
        service.setCustomAgentDirs(['/ws/.agents']);
        fileService.write(agentMd(new URI('file:///ws/.agents'), 'foo'), agentFile('Foo', 'Foo agent'));

        const agents = await service.getCustomAgents();

        expect(agents.map(a => a.id)).to.deep.equal(['foo']);
        expect(agents[0].name).to.equal('Foo');
    });

    it('prefers an agent in .agents over one with the same id in .prompts', async () => {
        service.setCustomAgentDirs(['/ws/.agents', '/ws/.prompts']);
        fileService.write(agentMd(new URI('file:///ws/.agents'), 'shared'), agentFile('FromAgents', 'agents version'));
        fileService.write(agentMd(new URI('file:///ws/.prompts'), 'shared'), agentFile('FromPrompts', 'prompts version'));

        const agents = await service.getCustomAgents();

        expect(agents).to.have.lengthOf(1);
        expect(agents[0].description).to.equal('agents version');
    });
});

describe('DefaultPromptFragmentCustomizationService - custom agent change detection', () => {
    before(() => disableJSDOM = enableJSDOM());
    after(() => disableJSDOM());

    class ChangeDetectionTestService extends DefaultPromptFragmentCustomizationService {
        protected override init(): void { }
        isAgentChange(path: string): boolean {
            return this.isCustomAgentChange(path);
        }
    }

    let service: ChangeDetectionTestService;
    beforeEach(() => { service = new ChangeDetectionTestService(); });

    it('detects deletion of the whole agents directory', () => {
        expect(service.isAgentChange('file:///ws/.agents/agents')).to.be.true;
    });

    it('detects changes to an agent.md inside the agents directory', () => {
        expect(service.isAgentChange('file:///ws/.agents/agents/foo/agent.md')).to.be.true;
    });

    it('detects changes to a legacy customAgents.yml', () => {
        expect(service.isAgentChange('file:///ws/.prompts/customAgents.yml')).to.be.true;
    });

    it('ignores the scope directory itself', () => {
        expect(service.isAgentChange('file:///ws/.agents')).to.be.false;
    });

    it('ignores unrelated files such as skills', () => {
        expect(service.isAgentChange('file:///ws/.agents/skills/foo/SKILL.md')).to.be.false;
    });
});
