// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// 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 { expect } from 'chai';
import { CancellationTokenSource, OS, PreferenceService } from '@theia/core';
import {
    GetWorkspaceDirectoryStructure,
    FileContentFunction,
    GetWorkspaceFileList,
    FileDiagnosticProvider,
    WorkspaceFunctionScope,
    FindFilesByPattern
} from './workspace-functions';
import { AiConfigurationService, ToolInvocationContext } from '@theia/ai-core';
import { Container } from '@theia/core/shared/inversify';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileOperationError, FileOperationResult, FileStat } from '@theia/filesystem/lib/common/files';
import { URI } from '@theia/core/lib/common/uri';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ProblemManager } from '@theia/markers/lib/browser';
import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
import { FileSearchService } from '@theia/file-search/lib/common/file-search-service';
import { Minimatch } from 'minimatch';

const makeFileSearchService = (
    impl?: (searchPattern: string, options: FileSearchService.Options) => Promise<string[]>
): FileSearchService & { calls: Array<{ searchPattern: string; options: FileSearchService.Options }> } => {
    const calls: Array<{ searchPattern: string; options: FileSearchService.Options }> = [];
    return {
        calls,
        find: async (searchPattern: string, options: FileSearchService.Options) => {
            calls.push({ searchPattern, options });
            return impl ? impl(searchPattern, options) : [];
        }
    } as unknown as FileSearchService & { calls: Array<{ searchPattern: string; options: FileSearchService.Options }> };
};

/**
 * A FileSearchService stand-in that mimics ripgrep: given a flat map of file URIs per
 * root, it returns the files under the requested root that match the include globs and
 * are not removed by the exclude globs (matched against each file's root-relative path).
 */
const makeRipgrepLikeSearchService = (filesByRoot: Record<string, string[]>) =>
    makeFileSearchService(async (_searchPattern, options) => {
        const root = options.rootUris?.[0];
        if (!root) {
            return [];
        }
        const rootUri = new URI(root);
        const includes = (options.includePatterns ?? []).map(pattern => new Minimatch(pattern, { dot: true }));
        const excludes = (options.excludePatterns ?? []).map(pattern => new Minimatch(pattern, { dot: true }));
        return (filesByRoot[root] ?? []).filter(file => {
            const relativePath = rootUri.relative(new URI(file))?.toString() ?? '';
            const included = includes.length === 0 || includes.some(matcher => matcher.match(relativePath));
            const excluded = excludes.some(matcher => matcher.match(relativePath));
            return included && !excluded;
        });
    });

const makeTrustAwareReader = (overrides: { [pref: string]: unknown } = {}): AiConfigurationService => ({
    get: <T>(name: string, fallback?: T) => (name in overrides ? overrides[name] as T : fallback),
    ready: Promise.resolve(),
    onDidChangeTrust: () => ({ dispose: () => { /* noop */ } })
} as unknown as AiConfigurationService);

const makeEnvVariablesServer = (homeDirUri: string = 'file:///home/test'): EnvVariablesServer => ({
    getHomeDirUri: async () => homeDirUri,
    getExecPath: async () => '',
    getVariables: async () => [],
    getValue: async () => undefined,
    getConfigDirUri: async () => 'file:///home/test/.config'
} as unknown as EnvVariablesServer);

disableJSDOM();

describe('Workspace Functions Cancellation Tests', () => {
    let cancellationTokenSource: CancellationTokenSource;
    let mockCtx: ToolInvocationContext;
    let container: Container;

    before(() => {
        disableJSDOM = enableJSDOM();
    });
    after(() => {
        // Disable JSDOM after all tests
        disableJSDOM();
    });

    beforeEach(() => {
        cancellationTokenSource = new CancellationTokenSource();

        // Setup mock context
        mockCtx = {
            cancellationToken: cancellationTokenSource.token
        };

        // Create a new container for each test
        container = new Container();

        // Mock dependencies
        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async () => ({
                isDirectory: true,
                children: [
                    {
                        isDirectory: true,
                        resource: new URI('file:///workspace/dir'),
                        path: { base: 'dir' }
                    }
                ],
                resource: new URI('file:///workspace')
            }),
            read: async () => ({ value: { toString: () => 'test content' } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        const mockMonacoWorkspace = {
            getTextDocument: () => undefined
        } as unknown as MonacoWorkspace;

        const mockProblemManager = {
            findMarkers: () => [],
            onDidChangeMarkers: () => ({ dispose: () => { } })
        } as unknown as ProblemManager;

        const mockMonacoTextModelService = {
            createModelReference: async () => ({
                object: {
                    lineCount: 10,
                    getText: () => 'test text'
                },
                dispose: () => { }
            })
        } as unknown as MonacoTextModelService;

        // Register mocks in the container
        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace);
        container.bind(ProblemManager).toConstantValue(mockProblemManager);
        container.bind(MonacoTextModelService).toConstantValue(mockMonacoTextModelService);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(FileSearchService).toConstantValue(makeFileSearchService());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(GetWorkspaceDirectoryStructure).toSelf();
        container.bind(FileContentFunction).toSelf();
        container.bind(GetWorkspaceFileList).toSelf();
        container.bind(FileDiagnosticProvider).toSelf();
        container.bind(FindFilesByPattern).toSelf();
    });

    afterEach(() => {
        cancellationTokenSource.dispose();
    });

    it('GetWorkspaceDirectoryStructure should respect cancellation token', async () => {
        const getDirectoryStructure = container.get(GetWorkspaceDirectoryStructure);
        cancellationTokenSource.cancel();

        const handler = getDirectoryStructure.getTool().handler;
        const result = await handler(JSON.stringify({}), mockCtx);

        const jsonResponse = typeof result === 'string' ? JSON.parse(result) : result;
        expect(jsonResponse.error).to.equal('Operation cancelled by user');
    });

    it('FileContentFunction should respect cancellation token', async () => {
        const fileContentFunction = container.get(FileContentFunction);
        cancellationTokenSource.cancel();

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), mockCtx);

        const jsonResponse = JSON.parse(result as string);
        expect(jsonResponse.error).to.equal('Operation cancelled by user');
    });

    it('GetWorkspaceFileList should respect cancellation token', async () => {
        const getWorkspaceFileList = container.get(GetWorkspaceFileList);
        cancellationTokenSource.cancel();

        const handler = getWorkspaceFileList.getTool().handler;
        const result = await handler(JSON.stringify({ path: '' }), mockCtx);

        expect(result).to.include('Operation cancelled by user');
    });

    it('GetWorkspaceFileList should check cancellation at multiple points', async () => {
        const getWorkspaceFileList = container.get(GetWorkspaceFileList);

        // We'll let it pass the first check then cancel
        const mockFileService = container.get(FileService);
        const originalResolve = mockFileService.resolve;

        // Mock resolve to cancel the token after it's called
        mockFileService.resolve = async (...args: unknown[]) => {
            const innerResult = await originalResolve.apply(mockFileService, args);
            cancellationTokenSource.cancel();
            return innerResult;
        };

        const handler = getWorkspaceFileList.getTool().handler;
        // Use a path that triggers file resolution (root name + subdirectory)
        const result = await handler(JSON.stringify({ path: 'workspace/dir' }), mockCtx);

        expect(result).to.include('Operation cancelled by user');
    });

    it('FileDiagnosticProvider should respect cancellation token', async () => {
        const fileDiagnosticProvider = container.get(FileDiagnosticProvider);
        cancellationTokenSource.cancel();

        const handler = fileDiagnosticProvider.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), mockCtx);

        const jsonResponse = JSON.parse(result as string);
        expect(jsonResponse.error).to.equal('Operation cancelled by user');
    });
});

describe('FileContentFunction.getArgumentsShortLabel', () => {
    let container: Container;
    let getArgumentsShortLabel: (args: string) => { label: string; hasMore: boolean } | undefined;

    let disableJSDOMInner: () => void;
    before(() => {
        disableJSDOMInner = enableJSDOM();
    });
    after(() => {
        disableJSDOMInner();
    });

    beforeEach(() => {
        container = new Container();

        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async () => ({
                isDirectory: true,
                children: [],
                resource: new URI('file:///workspace')
            }),
            read: async () => ({ value: { toString: () => 'test content' } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        const mockMonacoWorkspace = {
            getTextDocument: () => undefined
        } as unknown as MonacoWorkspace;

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FileContentFunction).toSelf();

        const fileContentFunction = container.get(FileContentFunction);
        const tool = fileContentFunction.getTool();
        getArgumentsShortLabel = tool.getArgumentsShortLabel!;
    });

    it('returns label for valid file argument', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts' }));
        expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: false });
    });

    it('returns undefined for invalid JSON', () => {
        const result = getArgumentsShortLabel('not valid json');
        expect(result).to.be.undefined;
    });

    it('returns undefined when file key is missing', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ path: 'src/index.ts' }));
        expect(result).to.be.undefined;
    });

    it('returns hasMore true when offset is provided', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', offset: 10 }));
        expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true });
    });

    it('returns hasMore true when limit is provided', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', limit: 50 }));
        expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true });
    });

    it('returns hasMore true when both offset and limit are provided', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', offset: 10, limit: 50 }));
        expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true });
    });
});

describe('FileContentFunction handler', () => {
    let container: Container;
    let fileContentFunction: FileContentFunction;
    // Mutable delegates — tests reassign these directly instead of casting the mock object.
    let mockResolve: () => Promise<unknown>;
    let mockRead: () => Promise<unknown>;
    let mockReadStream: () => Promise<unknown>;
    let mockMonacoWorkspace: MonacoWorkspace;
    let mockPreferenceService: { get: <T>(path: string, defaultValue: T) => T };

    const makeMockStream = (content: string) => {
        const handlers: Record<string, Function> = {};
        // Use setTimeout so the macro-task fires after all pending microtasks
        // (including the await continuation that registers the listeners).
        setTimeout(() => {
            handlers['data']?.(content);
            handlers['end']?.();
        }, 0);
        return {
            on(event: string, cb: Function): void { handlers[event] = cb; },
            pause(): void { },
            resume(): void { },
            destroy(): void { },
            removeListener(): void { }
        };
    };

    let disableJSDOMInner: () => void;
    before(() => {
        disableJSDOMInner = enableJSDOM();
    });
    after(() => {
        disableJSDOMInner();
    });

    beforeEach(() => {
        container = new Container();

        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: 1024,
            resource: new URI('file:///workspace/test.txt')
        });

        mockRead = async () => ({ value: 'line1\nline2\nline3\nline4\nline5' });

        mockReadStream = async () => ({ value: makeMockStream('line1\nline2\nline3\nline4\nline5') });

        // The mock object is stable across a test; individual methods delegate to
        // the mutable variables above so tests can substitute behaviour without
        // the fragile `(obj as unknown as {…}).method = …` double-cast pattern.
        const mockFileService = {
            exists: async () => true,
            resolve: () => mockResolve(),
            read: () => mockRead(),
            readStream: () => mockReadStream(),
        } as unknown as FileService;

        mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        mockMonacoWorkspace = {
            getTextDocument: () => undefined
        } as unknown as MonacoWorkspace;

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FileContentFunction).toSelf();

        fileContentFunction = container.get(FileContentFunction);
    });

    it('returns file content when file is within size limit', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);
        expect(result).to.equal('line1\nline2\nline3\nline4\nline5');
    });

    it('rejects without reading when on-disk size exceeds limit', async () => {
        // Stat reports 512 KB; default limit is 256 KB
        let readCalled = false;
        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: 512 * 1024,
            resource: new URI('file:///workspace/big.txt')
        });
        mockRead = async () => {
            readCalled = true;
            return { value: 'should not be read' };
        };

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'big.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.sizeKB).to.equal(512);
        expect(readCalled).to.be.false;
    });

    it('returns editor content when file is open in editor and within size limit', async () => {
        let resolveCalled = false;
        mockResolve = async () => {
            resolveCalled = true;
            return { isFile: true, isDirectory: false, size: 1024, resource: new URI('file:///workspace/open.txt') };
        };
        mockMonacoWorkspace.getTextDocument = () => ({
            getText: () => 'editor content'
        } as unknown as ReturnType<MonacoWorkspace['getTextDocument']>);

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'open.txt' }), undefined);

        expect(result).to.equal('editor content');
        expect(resolveCalled).to.be.false;
    });

    it('rejects editor content when it exceeds the size limit', async () => {
        const bigContent = 'x'.repeat(512 * 1024);
        mockMonacoWorkspace.getTextDocument = () => ({
            getText: () => bigContent
        } as unknown as ReturnType<MonacoWorkspace['getTextDocument']>);

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'open.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.sizeKB).to.equal(512);
    });

    it('returns sliced content with header when offset and limit are provided', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', offset: 1, limit: 2 }), undefined);

        expect(result).to.include('[Lines 2\u20133 of 5 total.');
        expect(result).to.include('line2\nline3');
    });

    it('returns sliced editor content with header when file is open and offset/limit are provided', async () => {
        mockMonacoWorkspace.getTextDocument = () => ({
            getText: () => 'alpha\nbeta\ngamma\ndelta\nepsilon'
        } as unknown as ReturnType<MonacoWorkspace['getTextDocument']>);

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'open.txt', offset: 1, limit: 2 }), undefined);

        expect(result).to.include('[Lines 2\u20133 of 5 total.');
        expect(result).to.include('beta\ngamma');
    });

    it('does not call resolve() or read() for paginated disk reads, uses readStream instead', async () => {
        // Stat would report huge file, but the streaming path bypasses both stat and read
        let resolveCalled = false;
        let readCalled = false;
        mockResolve = async () => {
            resolveCalled = true;
            return {
                isFile: true,
                isDirectory: false,
                size: 512 * 1024,
                resource: new URI('file:///workspace/big.txt')
            };
        };
        mockRead = async () => {
            readCalled = true;
            return { value: 'should not be read' };
        };

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 3 }), undefined);

        // resolve and read are NOT called in the streaming path
        expect(resolveCalled).to.be.false;
        expect(readCalled).to.be.false;
        expect(result).to.include('line1\nline2\nline3');
    });

    it('rejects when the requested slice itself exceeds the size limit', async () => {
        const bigLine = 'x'.repeat(1024);
        const bigContent = Array.from({ length: 300 }, () => bigLine).join('\n');
        mockReadStream = async () => ({ value: makeMockStream(bigContent) });

        const handler = fileContentFunction.getTool().handler;
        // Reading all 300 lines × 1 KB each = ~300 KB, over the 256 KB default limit
        const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 300 }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.resultSizeKB).to.be.greaterThan(256);
    });

    it('returns File not found error when file does not exist', async () => {
        mockResolve = async () => { throw new Error('File not found'); };
        mockRead = async () => { throw new Error('File not found'); };
        mockReadStream = async () => { throw new Error('File not found'); };

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'nonexistent.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.equal('File not found');
    });

    it('rejects negative offset', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', offset: -1 }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('non-negative integer');
    });

    it('rejects fractional offset', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', offset: 1.5 }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('non-negative integer');
    });

    it('rejects negative limit', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', limit: -1 }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('positive integer');
    });

    it('rejects zero limit', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', limit: 0 }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('positive integer');
    });

    it('returns content from offset to end when only offset is provided', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', offset: 2 }), undefined);

        expect(result).to.include('[Lines 3\u20135 of 5 total.');
        expect(result).to.include('line3\nline4\nline5');
    });

    it('returns last line when offset is at boundary', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', offset: 4, limit: 1 }), undefined);

        expect(result).to.include('[Lines 5\u20135 of 5 total.');
        expect(result).to.include('line5');
    });

    it('returns empty content when offset is beyond end of file', async () => {
        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt', offset: 100, limit: 5 }), undefined);

        // slice beyond end returns empty array → empty joined string
        expect(result).to.include('[Lines 101\u2013100 of 5 total.');
    });

    it('uses custom preference value for size limit', async () => {
        // Set a very small limit of 1 KB
        mockPreferenceService.get = <T>(_path: string, _defaultValue: T) => 1 as unknown as T;

        const content = 'x'.repeat(2 * 1024); // 2 KB
        mockRead = async () => ({ value: content });
        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: 2 * 1024,
            resource: new URI('file:///workspace/test.txt')
        });

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.maxSizeKB).to.equal(1);
    });

    it('falls back to streaming when stat.size is undefined and file is within limit', async () => {
        // stat does not include a size — the code must not treat this as "0 KB"
        // but instead stream the file and succeed when content is small.
        let readCalled = false;
        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: undefined,
            resource: new URI('file:///workspace/test.txt')
        });
        mockRead = async () => {
            readCalled = true;
            return { value: 'should not be used' };
        };
        // mockReadStream already returns the small 5-line fixture from beforeEach

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);

        expect(readCalled).to.be.false;
        expect(result).to.include('line1');
        // Full-file streaming fallback must NOT include the [Lines...] header
        expect(result).to.not.include('[Lines');
    });

    it('returns size-limit error (not "File not found") when stat.size is undefined and streamed content exceeds limit', async () => {
        // stat does not include a size; the streamed content is larger than the limit.
        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: undefined,
            resource: new URI('file:///workspace/big.txt')
        });
        const bigLine = 'x'.repeat(1024);
        const bigContent = Array.from({ length: 300 }, () => bigLine).join('\n'); // ~300 KB
        mockReadStream = async () => ({ value: makeMockStream(bigContent) });

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'big.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.error).to.include('offset');
        expect(parsed.error).not.to.equal('File not found');
    });

    it('returns size-limit error (not "File not found") when fileService.read throws FILE_TOO_LARGE', async () => {
        // Simulate a file system provider that enforces its own hard size limit below maxSizeKB.
        // stat.size is present and within our configured limit, but read() still throws.
        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: 100 * 1024, // 100 KB — under the 256 KB default limit
            resource: new URI('file:///workspace/test.txt')
        });
        mockRead = async () => {
            throw new FileOperationError('File too large', FileOperationResult.FILE_TOO_LARGE);
        };

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.error).to.include('offset');
        expect(parsed.maxSizeKB).to.equal(256);
    });

    it('returns size-limit error (not "File not found") when fileService.read throws FILE_EXCEEDS_MEMORY_LIMIT', async () => {
        mockResolve = async () => ({
            isFile: true,
            isDirectory: false,
            size: 100 * 1024,
            resource: new URI('file:///workspace/test.txt')
        });
        mockRead = async () => {
            throw new FileOperationError('Exceeds memory limit', FileOperationResult.FILE_EXCEEDS_MEMORY_LIMIT);
        };

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.error).to.include('offset');
        expect(parsed.maxSizeKB).to.equal(256);
    });

    it('returns size-limit error (not "File not found") when readStream throws FILE_TOO_LARGE for paginated read', async () => {
        // This is the key scenario: files.maxFileSizeMB is lower than the file size,
        // but the caller is trying to read a chunk with offset/limit.
        // Before the fix, readStream would throw FILE_TOO_LARGE and the catch block
        // would return "File not found".
        mockReadStream = async () => {
            throw new FileOperationError('File too large', FileOperationResult.FILE_TOO_LARGE);
        };

        const handler = fileContentFunction.getTool().handler;
        const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 50 }), undefined);

        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('size limit');
        expect(parsed.error).to.include('offset');
        expect(parsed.error).not.to.equal('File not found');
        expect(parsed.maxSizeKB).to.equal(256);
    });
});

describe('FindFilesByPattern.getArgumentsShortLabel', () => {
    let container: Container;
    let getArgumentsShortLabel: (args: string) => { label: string; hasMore: boolean } | undefined;

    let disableJSDOMInner: () => void;
    before(() => {
        disableJSDOMInner = enableJSDOM();
    });
    after(() => {
        disableJSDOMInner();
    });

    beforeEach(() => {
        container = new Container();

        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async () => ({
                isDirectory: true,
                children: [],
                resource: new URI('file:///workspace')
            }),
            read: async () => ({ value: { toString: () => 'test content' } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(FileSearchService).toConstantValue(makeFileSearchService());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FindFilesByPattern).toSelf();

        const findFilesByPattern = container.get(FindFilesByPattern);
        const tool = findFilesByPattern.getTool();
        getArgumentsShortLabel = tool.getArgumentsShortLabel!;
    });

    it('returns label for valid pattern argument', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ pattern: '**/*.ts' }));
        expect(result).to.deep.equal({ label: '**/*.ts', hasMore: false });
    });

    it('returns hasMore true when additional arguments exist', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ pattern: '**/*.ts', exclude: ['node_modules'] }));
        expect(result).to.deep.equal({ label: '**/*.ts', hasMore: true });
    });

    it('returns undefined for invalid JSON', () => {
        const result = getArgumentsShortLabel('not valid json');
        expect(result).to.be.undefined;
    });

    it('returns undefined when pattern key is missing', () => {
        const result = getArgumentsShortLabel(JSON.stringify({ glob: '**/*.ts' }));
        expect(result).to.be.undefined;
    });
});

describe('FindFilesByPattern.findFiles', () => {
    let container: Container;
    let findFilesByPattern: FindFilesByPattern;
    let fileSearchService: ReturnType<typeof makeFileSearchService>;
    let searchResults: string[];
    let roots: Array<{ resource: URI }>;
    let prefs: { [key: string]: unknown };
    let allowedExternal: string[];

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        searchResults = [];
        roots = [{ resource: new URI('file:///workspace') }];
        prefs = {
            'ai-features.workspaceFunctions.considerGitIgnore': true,
            'ai-features.workspaceFunctions.userExcludes': ['node_modules', 'lib']
        };
        allowedExternal = [];

        const mockWorkspaceService = {
            roots: Promise.resolve(roots),
            tryGetRoots: () => roots,
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async (uri: URI) => ({ isDirectory: true, children: [], resource: uri }),
            read: async () => ({ value: { toString: () => '' } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(path: string, defaultValue: T) => (path in prefs ? prefs[path] as T : defaultValue)
        };

        const trustAwareReader = {
            get: <T>(name: string, fallback?: T) =>
                (name === 'ai-features.workspaceFunctions.allowedExternalPaths' ? (allowedExternal as unknown as T) : fallback),
            ready: Promise.resolve(),
            onDidChangeTrust: () => ({ dispose: () => { /* noop */ } })
        } as unknown as AiConfigurationService;

        fileSearchService = makeFileSearchService(async () => searchResults);

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(trustAwareReader);
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(FileSearchService).toConstantValue(fileSearchService);
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FindFilesByPattern).toSelf();

        findFilesByPattern = container.get(FindFilesByPattern);
    });

    const call = (args: object, ctx?: ToolInvocationContext) =>
        findFilesByPattern.getTool().handler(JSON.stringify(args), ctx) as Promise<string>;

    it('returns root-prefixed workspace-relative paths', async () => {
        searchResults = ['file:///workspace/src/a.ts', 'file:///workspace/src/b.ts'];
        const result = JSON.parse(await call({ pattern: '**/*.ts' }));
        expect(result.files).to.deep.equal(['workspace/src/a.ts', 'workspace/src/b.ts']);
    });

    it('passes the glob, user excludes, gitignore flag and root to the search service', async () => {
        await call({ pattern: '**/*.ts', exclude: ['**/*.spec.ts'] });
        expect(fileSearchService.calls).to.have.length(1);
        const options = fileSearchService.calls[0].options;
        expect(options.includePatterns).to.deep.equal(['**/*.ts']);
        expect(options.excludePatterns).to.deep.equal(['node_modules', 'lib', '**/*.spec.ts']);
        expect(options.useGitIgnore).to.equal(true);
        expect(options.rootUris).to.deep.equal(['file:///workspace']);
        expect(options.fuzzyMatch).to.equal(false);
    });

    it('does not use gitignore when the preference is disabled', async () => {
        prefs['ai-features.workspaceFunctions.considerGitIgnore'] = false;
        await call({ pattern: '**/*.ts' });
        expect(fileSearchService.calls[0].options.useGitIgnore).to.equal(false);
    });

    it('reports an error when no workspace is open', async () => {
        roots.length = 0;
        const result = JSON.parse(await call({ pattern: '**/*.ts' }));
        expect(result.error).to.equal('No workspace has been opened yet');
    });

    it('respects the cancellation token', async () => {
        const cts = new CancellationTokenSource();
        cts.cancel();
        const result = JSON.parse(await call({ pattern: '**/*.ts' }, { cancellationToken: cts.token }));
        expect(result.error).to.equal('Operation cancelled by user');
    });

    it('truncates to 200 results and flags truncation', async () => {
        searchResults = Array.from({ length: 250 }, (_, i) => `file:///workspace/f${i}.ts`);
        const result = JSON.parse(await call({ pattern: '**/*.ts' }));
        expect(result.files).to.have.length(200);
        expect(result.truncated).to.equal(true);
    });

    it('returns absolute paths for an allow-listed external searchRoot', async () => {
        allowedExternal = ['/external/data'];
        searchResults = ['file:///external/data/x.ts'];
        const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: '/external/data' }));
        expect(result.files).to.deep.equal(['/external/data/x.ts']);
        expect(fileSearchService.calls[0].options.rootUris).to.deep.equal(['file:///external/data']);
    });

    it('does not apply gitignore to external roots (it is scoped to workspace roots)', async () => {
        prefs['ai-features.workspaceFunctions.considerGitIgnore'] = true;
        allowedExternal = ['/external/data'];
        await call({ pattern: '**/*.ts', searchRoot: '/external/data' });
        const options = fileSearchService.calls[0].options;
        expect(options.useGitIgnore).to.equal(false);
        expect(options.excludePatterns).to.include('.git');
    });

    it('still applies gitignore to workspace roots', async () => {
        prefs['ai-features.workspaceFunctions.considerGitIgnore'] = true;
        await call({ pattern: '**/*.ts' });
        expect(fileSearchService.calls[0].options.useGitIgnore).to.equal(true);
    });

    it('matches an allow-list entry with a trailing slash against a searchRoot without one', async () => {
        allowedExternal = ['/external/data/']; // trailing slash, as a user/file-picker may enter it
        searchResults = ['file:///external/data/x.ts'];
        const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: '/external/data' }));
        expect(result.error).to.be.undefined;
        expect(result.files).to.deep.equal(['/external/data/x.ts']);
    });

    it('matches an allow-list entry without a trailing slash against a searchRoot that has one', async () => {
        allowedExternal = ['/external/data'];
        searchResults = ['file:///external/data/x.ts'];
        const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: '/external/data/' }));
        expect(result.error).to.be.undefined;
        expect(result.files).to.deep.equal(['/external/data/x.ts']);
    });
});

describe('WorkspaceFunctionScope gitignore caching', () => {
    let container: Container;
    let scope: WorkspaceFunctionScope;
    let resolveCount: number;
    let gitignoreReadCount: number;

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        resolveCount = 0;
        gitignoreReadCount = 0;

        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            resolve: async (uri: URI) => { resolveCount++; return { isDirectory: true, resource: uri }; },
            read: async (uri: URI) => {
                if (uri.path.base === '.gitignore') {
                    gitignoreReadCount++;
                    return { value: 'dist/\n' };
                }
                throw new Error('not found');
            },
            watch: () => ({ dispose: () => { } }),
            onDidFilesChange: () => ({ dispose: () => { } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(path: string, defaultValue: T) =>
                (path === 'ai-features.workspaceFunctions.considerGitIgnore' ? (true as unknown as T) : defaultValue)
        };

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(WorkspaceFunctionScope).toSelf();

        scope = container.get(WorkspaceFunctionScope);
    });

    const stat = (path: string, isDirectory = false) => ({
        resource: new URI(path),
        isDirectory,
        path: { base: path.split('/').pop() }
    }) as unknown as FileStat;

    it('still excludes gitignored paths and keeps others', async () => {
        expect(await scope.shouldExclude(stat('file:///workspace/dist/a.js'))).to.be.true;
        expect(await scope.shouldExclude(stat('file:///workspace/src/b.ts'))).to.be.false;
    });

    it('reads .gitignore at most once and issues no per-check resolve RPC', async () => {
        for (let i = 0; i < 25; i++) {
            await scope.shouldExclude(stat(`file:///workspace/src/file${i}.ts`));
        }
        expect(gitignoreReadCount).to.equal(1);
        expect(resolveCount).to.equal(0);
    });
});

describe('GetWorkspaceFileList resolves the target directory once', () => {
    let container: Container;
    let tool: GetWorkspaceFileList;
    let resolveCount: number;

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        resolveCount = 0;

        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async (uri: URI) => {
                resolveCount++;
                return {
                    isDirectory: true,
                    resource: uri,
                    children: [
                        { isDirectory: false, resource: new URI('file:///workspace/sub/a.ts'), path: { base: 'a.ts' } },
                        { isDirectory: true, resource: new URI('file:///workspace/sub/dir'), path: { base: 'dir' } }
                    ]
                };
            },
            read: async () => ({ value: { toString: () => '' } }),
            watch: () => ({ dispose: () => { } }),
            onDidFilesChange: () => ({ dispose: () => { } })
        } as unknown as FileService;

        const mockPreferenceService = { get: <T>(_path: string, def: T) => def };

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(GetWorkspaceFileList).toSelf();

        tool = container.get(GetWorkspaceFileList);
    });

    it('lists one level and resolves the directory only once', async () => {
        const result = JSON.parse(await tool.getTool().handler(JSON.stringify({ path: 'workspace/sub' }), undefined) as string);
        expect(result).to.deep.equal({ 'a.ts': 'file', 'dir': 'directory' });
        expect(resolveCount).to.equal(1);
    });
});

describe('GetWorkspaceDirectoryStructure preserves empty folders', () => {
    let container: Container;
    let tool: GetWorkspaceDirectoryStructure;

    const TREE: Record<string, string[]> = {
        'file:///workspace': ['file:///workspace/src', 'file:///workspace/empty', 'file:///workspace/node_modules'],
        'file:///workspace/src': ['file:///workspace/src/nested'],
        'file:///workspace/src/nested': [],
        'file:///workspace/empty': [],
        'file:///workspace/node_modules': ['file:///workspace/node_modules/pkg']
    };

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();

        const mockWorkspaceService = {
            roots: Promise.resolve([{ resource: new URI('file:///workspace') }]),
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            resolve: async (uri: URI) => ({
                isDirectory: true,
                resource: uri,
                children: (TREE[uri.toString()] ?? []).map(child => ({
                    isDirectory: true,
                    resource: new URI(child),
                    path: { base: child.split('/').pop() }
                }))
            }),
            read: async () => ({ value: { toString: () => '' } }),
            watch: () => ({ dispose: () => { } }),
            onDidFilesChange: () => ({ dispose: () => { } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(path: string, def: T) =>
                (path === 'ai-features.workspaceFunctions.userExcludes' ? (['node_modules'] as unknown as T) : def)
        };

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(GetWorkspaceDirectoryStructure).toSelf();

        tool = container.get(GetWorkspaceDirectoryStructure);
    });

    it('includes empty directories and excludes user-excluded ones', async () => {
        const result = await tool.getTool().handler(JSON.stringify({}), undefined);
        expect(result).to.deep.equal({
            workspace: {
                src: { nested: {} },
                empty: {}
            }
        });
    });
});

// ── HEAD: External allowed paths tests ──────────────────────────────────────

describe('FileContentFunction external paths', () => {
    let container: Container;
    let fileContentFunction: FileContentFunction;
    let allowedPaths: string[];
    let trustedScopeOnly: boolean;

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        allowedPaths = [];
        trustedScopeOnly = false;

        const mockWorkspaceService = {
            roots: [{ resource: new URI('file:///workspace') }],
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async (uri: URI) => ({
                isFile: true,
                isDirectory: false,
                size: 1024,
                resource: uri
            }),
            read: async (uri: URI) => ({ value: `content-of-${uri.path.toString()}` }),
            readStream: async () => { throw new Error('not used'); }
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        const mockMonacoWorkspace = {
            getTextDocument: () => undefined
        } as unknown as MonacoWorkspace;

        const trustAwareReader = {
            get: <T>(_name: string, fallback?: T) => (trustedScopeOnly ? fallback : (allowedPaths as unknown as T)),
            ready: Promise.resolve(),
            onDidChangeTrust: () => ({ dispose: () => { /* noop */ } })
        } as unknown as AiConfigurationService;

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace);
        container.bind(AiConfigurationService).toConstantValue(trustAwareReader);
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer('file:///home/test'));
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FileContentFunction).toSelf();

        fileContentFunction = container.get(FileContentFunction);
    });

    const callTool = (file: string) =>
        fileContentFunction.getTool().handler(JSON.stringify({ file }), undefined) as Promise<string>;

    it('rejects absolute path outside the workspace when allow-list is empty', async () => {
        const result = await callTool('/etc/hosts');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
        expect(parsed.error).to.include('allowedExternalPaths');
    });

    it('allows absolute path inside an allow-listed directory', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callTool('/external/configs/myapp.json');
        expect(result).to.equal('content-of-/external/configs/myapp.json');
    });

    it('allows file:// URI inside an allow-listed directory', async () => {
        allowedPaths = ['file:///external/configs'];
        const result = await callTool('file:///external/configs/myapp.json');
        expect(result).to.equal('content-of-/external/configs/myapp.json');
    });

    it('rejects sibling that shares the prefix but is not a child', async () => {
        // /external/configs-other must NOT be matched by /external/configs
        allowedPaths = ['/external/configs'];
        const result = await callTool('/external/configs-other/myapp.json');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    it('rejects path with .. traversal', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callTool('/external/configs/../secrets.txt');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('Invalid file path');
    });

    it('expands ~ in allow-list entries', async () => {
        allowedPaths = ['~/configs'];
        const result = await callTool('/home/test/configs/myapp.json');
        expect(result).to.equal('content-of-/home/test/configs/myapp.json');
    });

    it('expands ~ in tool input paths', async () => {
        allowedPaths = ['/home/test/configs'];
        const result = await callTool('~/configs/myapp.json');
        expect(result).to.equal('content-of-/home/test/configs/myapp.json');
    });

    it('expands ~ in both allow-list entries and tool input paths', async () => {
        allowedPaths = ['~/configs'];
        const result = await callTool('~/configs/myapp.json');
        expect(result).to.equal('content-of-/home/test/configs/myapp.json');
    });

    it('rejects ~-prefixed tool input that escapes via .. traversal', async () => {
        allowedPaths = ['/home/test/configs'];
        const result = await callTool('~/configs/../secrets.txt');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('Invalid file path');
    });

    it('still allows workspace-relative paths', async () => {
        allowedPaths = [];
        const result = await callTool('src/index.ts');
        expect(result).to.equal('content-of-/workspace/src/index.ts');
    });

    it('accepts Windows-style absolute paths in the allow-list', async () => {
        // Mixed: backslash separators in the entry, forward-slash in the file argument
        allowedPaths = ['C:\\external\\configs'];
        const result = await callTool('C:/external/configs/myapp.json');
        // The mock returns content keyed off the resolved URI's path. Both forms
        // normalize to the same `file:///c:/external/configs/myapp.json`.
        expect(result).to.match(/^content-of-/);
        expect(result).to.include('external/configs/myapp.json');
    });

    it('accepts Windows drive paths as the tool argument', async () => {
        allowedPaths = ['C:/external'];
        const result = await callTool('C:\\external\\file.txt');
        expect(result).to.match(/^content-of-/);
        expect(result).to.include('external/file.txt');
    });

    it('drops workspace-scoped allow-list values when workspace is untrusted', async () => {
        // simulate trust-aware reader behavior: when untrusted, only user/default scope is returned
        trustedScopeOnly = true;
        const result = await callTool('/external/configs/myapp.json');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });
});

describe('GetWorkspaceFileList / GetWorkspaceDirectoryStructure with external paths', () => {
    let container: Container;
    let allowedPaths: string[];

    const FS_TREE: Record<string, { isDirectory: boolean; children?: string[] }> = {
        'file:///workspace': { isDirectory: true, children: ['file:///workspace/a.ts', 'file:///workspace/sub'] },
        'file:///workspace/a.ts': { isDirectory: false },
        'file:///workspace/sub': { isDirectory: true, children: [] },
        'file:///external/configs': {
            isDirectory: true,
            children: ['file:///external/configs/myapp.json', 'file:///external/configs/sub']
        },
        'file:///external/configs/myapp.json': { isDirectory: false },
        'file:///external/configs/sub': { isDirectory: true, children: [] }
    };

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        allowedPaths = [];

        const mockWorkspaceService = {
            roots: [{ resource: new URI('file:///workspace') }],
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async (uri: URI) => {
                const node = FS_TREE[uri.toString()];
                if (!node) {
                    throw new Error('not found');
                }
                return {
                    isDirectory: node.isDirectory,
                    isFile: !node.isDirectory,
                    resource: uri,
                    children: node.children?.map(child => ({
                        isDirectory: !!FS_TREE[child]?.isDirectory,
                        isFile: !FS_TREE[child]?.isDirectory,
                        resource: new URI(child),
                        path: { base: child.split('/').pop() }
                    })) ?? []
                };
            },
            read: async () => ({ value: '' })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        const trustAwareReader = {
            get: <T>(_name: string, _fallback?: T) => allowedPaths as unknown as T,
            ready: Promise.resolve(),
            onDidChangeTrust: () => ({ dispose: () => { /* noop */ } })
        } as unknown as AiConfigurationService;

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(trustAwareReader);
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(GetWorkspaceFileList).toSelf();
        container.bind(GetWorkspaceDirectoryStructure).toSelf();
    });

    it('GetWorkspaceFileList rejects absolute path outside the allow-list', async () => {
        const tool = container.get(GetWorkspaceFileList).getTool();
        const result = await tool.handler(JSON.stringify({ path: '/external/configs' }), undefined) as string;
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    it('GetWorkspaceFileList lists an allow-listed external directory', async () => {
        allowedPaths = ['/external/configs'];
        const tool = container.get(GetWorkspaceFileList).getTool();
        const result = await tool.handler(JSON.stringify({ path: '/external/configs' }), undefined);
        expect(JSON.parse(result as string)).to.deep.equal({ 'myapp.json': 'file', 'sub': 'directory' });
    });

    it('GetWorkspaceFileList still lists workspace-relative paths', async () => {
        const tool = container.get(GetWorkspaceFileList).getTool();
        const result = await tool.handler(JSON.stringify({ path: '' }), undefined);
        expect(JSON.parse(result as string)).to.deep.equal({ workspace: 'directory' });
    });

    it('GetWorkspaceDirectoryStructure rejects external root not in the allow-list', async () => {
        const tool = container.get(GetWorkspaceDirectoryStructure).getTool();
        const result = await tool.handler(JSON.stringify({ root: '/external/configs' }), undefined) as Record<string, unknown>;
        expect(result.error).to.include('not allowed');
    });

    it('GetWorkspaceDirectoryStructure walks an allow-listed external root', async () => {
        allowedPaths = ['/external/configs'];
        const tool = container.get(GetWorkspaceDirectoryStructure).getTool();
        const result = await tool.handler(JSON.stringify({ root: '/external/configs' }), undefined);
        expect(result).to.deep.equal({ sub: {} });
    });

    it('GetWorkspaceDirectoryStructure preserves prior workspace-only behavior when root omitted', async () => {
        const tool = container.get(GetWorkspaceDirectoryStructure).getTool();
        const result = await tool.handler('', undefined);
        expect(result).to.deep.equal({ workspace: { sub: {} } });
    });
});

describe('FindFilesByPattern with searchRoot', () => {
    let container: Container;
    let findFilesByPattern: FindFilesByPattern;
    let allowedPaths: string[];

    const FILES_BY_ROOT: Record<string, string[]> = {
        'file:///workspace': ['file:///workspace/a.ts'],
        'file:///external/configs': ['file:///external/configs/myapp.json', 'file:///external/configs/other.txt']
    };

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        allowedPaths = [];

        const mockWorkspaceService = {
            roots: [{ resource: new URI('file:///workspace') }],
            tryGetRoots: () => [{ resource: new URI('file:///workspace') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async (uri: URI) => ({ isDirectory: true, children: [], resource: uri }),
            read: async () => ({ value: { toString: () => '' } })
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        const trustAwareReader = {
            get: <T>(_name: string, _fallback?: T) => allowedPaths as unknown as T,
            ready: Promise.resolve(),
            onDidChangeTrust: () => ({ dispose: () => { /* noop */ } })
        } as unknown as AiConfigurationService;

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(AiConfigurationService).toConstantValue(trustAwareReader);
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
        container.bind(FileSearchService).toConstantValue(makeRipgrepLikeSearchService(FILES_BY_ROOT));
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FindFilesByPattern).toSelf();

        findFilesByPattern = container.get(FindFilesByPattern);
    });

    const callTool = (args: object) =>
        findFilesByPattern.getTool().handler(JSON.stringify(args), undefined) as Promise<string>;

    it('searches the workspace by default and returns relative paths', async () => {
        const result = await callTool({ pattern: '**/*.ts' });
        const parsed = JSON.parse(result);
        expect(parsed.files).to.deep.equal(['workspace/a.ts']);
    });

    it('rejects searchRoot that is not in the allow-list', async () => {
        const result = await callTool({ pattern: '**/*.json', searchRoot: '/external/configs' });
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    it('searches an allow-listed external root and returns absolute paths', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callTool({ pattern: '**/*.json', searchRoot: '/external/configs' });
        const parsed = JSON.parse(result);
        expect(parsed.files).to.deep.equal(['/external/configs/myapp.json']);
    });

    it('honors exclude patterns when using an external searchRoot', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callTool({
            pattern: '**/*',
            exclude: ['**/*.txt'],
            searchRoot: '/external/configs'
        });
        const parsed = JSON.parse(result);
        expect(parsed.files).to.deep.equal(['/external/configs/myapp.json']);
    });
});

describe('WorkspaceFunctionScope path-traversal hardening', () => {
    let container: Container;
    let fileContentFunction: FileContentFunction;
    let allowedPaths: string[];
    let trustReady: Promise<void>;
    let trustAwareGet: <T>(name: string, fallback?: T) => T | undefined;

    let disableJSDOMInner: () => void;
    before(() => { disableJSDOMInner = enableJSDOM(); });
    after(() => { disableJSDOMInner(); });

    beforeEach(() => {
        container = new Container();
        allowedPaths = [];
        trustReady = Promise.resolve();
        trustAwareGet = <T>(_name: string, _fallback?: T) => allowedPaths as unknown as T;

        const mockWorkspaceService = {
            roots: [{ resource: new URI('file:///workspace/project') }],
            tryGetRoots: () => [{ resource: new URI('file:///workspace/project') }],
            onWorkspaceChanged: () => ({ dispose: () => { } })
        } as unknown as WorkspaceService;

        const mockFileService = {
            exists: async () => true,
            resolve: async (uri: URI) => ({
                isFile: true,
                isDirectory: false,
                size: 1024,
                resource: uri
            }),
            read: async (uri: URI) => ({ value: `content-of-${uri.path.toString()}` }),
            readStream: async () => { throw new Error('not used'); }
        } as unknown as FileService;

        const mockPreferenceService = {
            get: <T>(_path: string, defaultValue: T) => defaultValue
        };

        const mockMonacoWorkspace = {
            getTextDocument: () => undefined
        } as unknown as MonacoWorkspace;

        const trustAwareReader = {
            get: <T>(name: string, fallback?: T) => trustAwareGet<T>(name, fallback),
            get ready(): Promise<void> { return trustReady; },
            onDidChangeTrust: () => ({ dispose: () => { /* noop */ } })
        } as unknown as AiConfigurationService;

        container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
        container.bind(FileService).toConstantValue(mockFileService);
        container.bind(PreferenceService).toConstantValue(mockPreferenceService);
        container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace);
        container.bind(AiConfigurationService).toConstantValue(trustAwareReader);
        container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer('file:///home/test'));
        container.bind(WorkspaceFunctionScope).toSelf();
        container.bind(FileContentFunction).toSelf();
        container.bind(FileDiagnosticProvider).toSelf();
        const mockProblemManager = {
            findMarkers: () => [],
            onDidChangeMarkers: () => ({ dispose: () => { /* noop */ } })
        } as unknown as ProblemManager;
        container.bind(ProblemManager).toConstantValue(mockProblemManager);
        const mockMonacoTextModelService = {
            createModelReference: async () => ({
                object: { lineCount: 10, getText: () => '' },
                dispose: () => { /* noop */ }
            })
        } as unknown as MonacoTextModelService;
        container.bind(MonacoTextModelService).toConstantValue(mockMonacoTextModelService);

        fileContentFunction = container.get(FileContentFunction);
    });

    const callContent = (file: string) =>
        fileContentFunction.getTool().handler(JSON.stringify({ file }), undefined) as Promise<string>;

    // Item 1 — URL-encoded and literal `..` in file:// URIs must be normalized
    // before the allow-list check; otherwise the path-component prefix admits
    // the traversal.
    it('rejects file:// URI with literal .. traversal', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callContent('file:///external/configs/../secrets.txt');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    it('rejects file:// URI with percent-encoded %2e%2e traversal', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callContent('file:///external/configs/%2e%2e/secrets.txt');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    it('rejects file:// URI with upper-case percent-encoded traversal', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callContent('file:///external/configs/%2E%2E/secrets.txt');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    // Item 4 — A filename that contains `..` as a substring but is not a
    // traversal segment must be accepted.
    it('accepts a valid filename whose name contains ".." as a substring', async () => {
        allowedPaths = ['/external/configs'];
        const result = await callContent('/external/configs/my..file.json');
        expect(result).to.equal('content-of-/external/configs/my..file.json');
    });

    // Item 3 — Non-file allow-list entries are documented as unsupported.
    it('rejects http:// allow-list entry even if target path appears to match', async () => {
        allowedPaths = ['http://example.com/external/configs'];
        const result = await callContent('/external/configs/myapp.json');
        const parsed = JSON.parse(result);
        expect(parsed.error).to.include('not allowed');
    });

    // Item 7 — getAllowedExternalUris must await AiConfigurationService.ready
    // so that preference reads see the resolved trust state.
    it('awaits AiConfigurationService.ready before reading the allow-list', async () => {
        let preferenceVisible = false;
        let resolveReady: () => void = () => { /* noop */ };
        trustReady = new Promise<void>(r => { resolveReady = r; });
        trustAwareGet = <T>(_name: string, _fallback?: T) =>
            (preferenceVisible ? ['/external/configs'] : []) as unknown as T;

        const promise = callContent('/external/configs/myapp.json');

        // Simulate trust state becoming available after a microtask: the
        // allow-list is only readable once `ready` resolves.
        await Promise.resolve();
        preferenceVisible = true;
        resolveReady();

        const result = await promise;
        expect(result).to.equal('content-of-/external/configs/myapp.json');
    });

    // Item 2 — On case-insensitive filesystems (Windows, macOS by default)
    // the allow-list match must be case-insensitive. Stub OS.backend flags
    // for the test and restore them afterwards.
    describe('case-insensitive match on case-insensitive filesystems', () => {
        let originalIsWindows: boolean;
        let originalIsOSX: boolean;
        beforeEach(() => {
            originalIsWindows = OS.backend.isWindows;
            originalIsOSX = OS.backend.isOSX;
        });
        afterEach(() => {
            OS.backend.isWindows = originalIsWindows;
            OS.backend.isOSX = originalIsOSX;
        });

        it('admits a case-mismatched Windows target when allow-list entry is mixed-case', async () => {
            OS.backend.isWindows = true;
            OS.backend.isOSX = false;
            allowedPaths = ['C:\\External\\Configs'];
            const result = await callContent('c:/external/configs/myapp.json');
            // The mock returns the URI's path; both forms canonicalize to the
            // same file:///c:/... URI (drive letter is lowercased by URI.fromFilePath),
            // but the path segments differ in case.
            expect(result).to.match(/^content-of-/);
            expect(result.toLowerCase()).to.include('external/configs/myapp.json');
        });

        it('admits a case-mismatched macOS target when allow-list entry is mixed-case', async () => {
            OS.backend.isWindows = false;
            OS.backend.isOSX = true;
            allowedPaths = ['/Users/safi/External/Configs'];
            const result = await callContent('/users/safi/external/configs/myapp.json');
            expect(result).to.match(/^content-of-/);
            expect(result.toLowerCase()).to.include('/users/safi/external/configs/myapp.json');
        });

        it('keeps Linux case-sensitive: rejects a case-mismatched target', async () => {
            OS.backend.isWindows = false;
            OS.backend.isOSX = false;
            allowedPaths = ['/home/safi/External/Configs'];
            const result = await callContent('/home/safi/external/configs/myapp.json');
            const parsed = JSON.parse(result);
            expect(parsed.error).to.include('not allowed');
        });
    });

    // Item 6 — `ensureWithinWorkspace` is the gate used by getFileDiagnostics
    // for relative path inputs. A sibling that merely shares the workspace
    // root's string prefix must not pass.
    it('FileDiagnosticProvider rejects sibling-prefix path outside the workspace', async () => {
        const diag = container.get(FileDiagnosticProvider);
        const handler = diag.getTool().handler;
        // Workspace root is file:///workspace/project. With a raw string-prefix
        // check the resolved URI file:///workspace/project-other/file.txt
        // would be (incorrectly) admitted.
        const result = await handler(JSON.stringify({ file: '../project-other/file.txt' }), undefined);
        const parsed = JSON.parse(result as string);
        expect(parsed.error).to.include('outside of the workspace');
    });
});

// ── Branch: Multi-root workspace tests ──────────────────────────────────────

describe('WorkspaceFunctionScope Multi-Root Tests', () => {
    let container: Container;
    let workspaceScope: WorkspaceFunctionScope;

    let disableJSDOMInner: () => void;
    before(() => {
        disableJSDOMInner = enableJSDOM();
    });
    after(() => {
        disableJSDOMInner();
    });

    beforeEach(() => {
        container = new Container();
    });

    describe('getRootMapping', () => {
        it('returns single root with its basename', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [{ resource: new URI('file:///home/user/my-project') }],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const mapping = workspaceScope.getRootMapping();
            expect(mapping.size).to.equal(1);
            expect(mapping.get('my-project')?.toString()).to.equal('file:///home/user/my-project');
        });

        it('returns multiple roots with their basenames', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///home/user/frontend') },
                    { resource: new URI('file:///home/user/backend') },
                    { resource: new URI('file:///home/user/shared') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const mapping = workspaceScope.getRootMapping();
            expect(mapping.size).to.equal(3);
            expect(mapping.get('frontend')?.toString()).to.equal('file:///home/user/frontend');
            expect(mapping.get('backend')?.toString()).to.equal('file:///home/user/backend');
            expect(mapping.get('shared')?.toString()).to.equal('file:///home/user/shared');
        });

        it('uses first-wins for duplicate basenames (sorted by URI)', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///alice/app') },
                    { resource: new URI('file:///bob/app') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const mapping = workspaceScope.getRootMapping();
            // Only one entry for 'app' — the first by URI sort order wins.
            // The other root is still reachable via resolveRelativePath fallback.
            expect(mapping.size).to.equal(1);
            expect(mapping.get('app')?.toString()).to.equal('file:///alice/app');
        });
    });

    describe('toWorkspaceRelativePath', () => {
        it('returns root-prefixed path for file in single root', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [{ resource: new URI('file:///home/user/project') }],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const uri = new URI('file:///home/user/project/src/index.ts');
            const result = workspaceScope.toWorkspaceRelativePath(uri);
            expect(result).to.equal('project/src/index.ts');
        });

        it('returns root-prefixed path for file in multi-root workspace', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///home/user/frontend') },
                    { resource: new URI('file:///home/user/backend') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const frontendFile = new URI('file:///home/user/frontend/src/App.tsx');
            expect(workspaceScope.toWorkspaceRelativePath(frontendFile)).to.equal('frontend/src/App.tsx');

            const backendFile = new URI('file:///home/user/backend/src/server.ts');
            expect(workspaceScope.toWorkspaceRelativePath(backendFile)).to.equal('backend/src/server.ts');
        });

        it('returns undefined for file outside workspace', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [{ resource: new URI('file:///home/user/project') }],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const uri = new URI('file:///etc/passwd');
            const result = workspaceScope.toWorkspaceRelativePath(uri);
            expect(result).to.be.undefined;
        });

        it('returns just root name for the root directory itself', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [{ resource: new URI('file:///home/user/project') }],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const uri = new URI('file:///home/user/project');
            const result = workspaceScope.toWorkspaceRelativePath(uri);
            // When the URI is the root itself, it returns just the root name
            expect(result).to.equal('project');
        });
    });

    describe('resolveRelativePath', () => {
        function createScope(roots: string[]): WorkspaceFunctionScope {
            const rootObjects = roots.map(r => ({ resource: new URI(r) }));
            const mockWorkspaceService = {
                tryGetRoots: () => rootObjects,
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;
            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            return container.get(WorkspaceFunctionScope);
        }

        describe('Phase 1 — root+relative', () => {
            it('resolves path with matching root prefix', () => {
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                const result = workspaceScope.resolveRelativePath('backend/src/server.ts');
                expect(result.toString()).to.equal('file:///home/user/backend/src/server.ts');
            });

            it('returns the root URI when path is just a root name', () => {
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                const result = workspaceScope.resolveRelativePath('frontend');
                expect(result.toString()).to.equal('file:///home/user/frontend');
            });

            it('root name takes priority over subdirectory with same name', () => {
                // Root named 'src' exists, AND another root has a 'src/' subdirectory
                workspaceScope = createScope(['file:///home/user/src', 'file:///home/user/app']);
                const result = workspaceScope.resolveRelativePath('src/index.ts');
                expect(result.toString()).to.equal('file:///home/user/src/index.ts');
            });
        });

        describe('Phase 2 — supra-relative', () => {
            it('resolves when root basename appears with matching preceding components', () => {
                workspaceScope = createScope(['file:///home/user/backend']);
                // 'user/backend/src/file.ts' — 'backend' matches root basename, 'user' matches preceding component
                const result = workspaceScope.resolveRelativePath('user/backend/src/file.ts');
                expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts');
            });

            it('resolves with full preceding path match', () => {
                workspaceScope = createScope(['file:///home/user/backend']);
                const result = workspaceScope.resolveRelativePath('home/user/backend/src/file.ts');
                expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts');
            });

            it('does not match when preceding components differ', () => {
                // Must use multi-root so Phase 3 (single-root fallback) doesn't short-circuit
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                // 'other/backend/src/file.ts' — 'other' does NOT match any suffix of 'home/user'
                expect(() => workspaceScope.resolveRelativePath('other/backend/src/file.ts')).to.throw(/Could not resolve path/);
            });

            it('resolves with no preceding components (just root basename at start)', () => {
                // This should be caught by Phase 1 (root+relative) since the first segment IS the root name
                workspaceScope = createScope(['file:///home/user/backend']);
                const result = workspaceScope.resolveRelativePath('backend/src/file.ts');
                expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts');
            });

            it('resolves supra-relative path in multi-root workspace', () => {
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                const result = workspaceScope.resolveRelativePath('user/backend/src/file.ts');
                expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts');
            });
        });

        describe('Phase 3 — single-root fallback', () => {
            it('resolves unprefixed path relative to the single root', () => {
                workspaceScope = createScope(['file:///home/user/project']);
                const result = workspaceScope.resolveRelativePath('src/index.ts');
                expect(result.toString()).to.equal('file:///home/user/project/src/index.ts');
            });

            it('resolves new file path relative to single root', () => {
                workspaceScope = createScope(['file:///home/user/project']);
                const result = workspaceScope.resolveRelativePath('new-file.ts');
                expect(result.toString()).to.equal('file:///home/user/project/new-file.ts');
            });
        });

        describe('Phase 4 — error for unresolvable multi-root paths', () => {
            it('throws when unprefixed path cannot be resolved in multi-root', () => {
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                expect(() => workspaceScope.resolveRelativePath('src/index.ts')).to.throw(/Could not resolve path/);
            });

            it('error message lists available roots', () => {
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                try {
                    workspaceScope.resolveRelativePath('src/index.ts');
                    expect.fail('Expected an error');
                } catch (e: unknown) {
                    const error = e as Error;
                    expect(error.message).to.include('frontend');
                    expect(error.message).to.include('backend');
                }
            });

            it('does not throw when root prefix is provided in multi-root', () => {
                workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']);
                const result = workspaceScope.resolveRelativePath('frontend/src/index.ts');
                expect(result.toString()).to.equal('file:///home/user/frontend/src/index.ts');
            });
        });

        describe('normalization', () => {
            it('normalizes backslash paths', () => {
                workspaceScope = createScope(['file:///home/user/project']);
                const result = workspaceScope.resolveRelativePath('project\\src\\index.ts');
                expect(result.toString()).to.equal('file:///home/user/project/src/index.ts');
            });

            it('resolves .. segments', () => {
                workspaceScope = createScope(['file:///home/user/project']);
                const result = workspaceScope.resolveRelativePath('project/src/../src/index.ts');
                expect(result.toString()).to.equal('file:///home/user/project/src/index.ts');
            });

            it('normalizes redundant separators', () => {
                workspaceScope = createScope(['file:///home/user/project']);
                const result = workspaceScope.resolveRelativePath('project/src//index.ts');
                expect(result.path.toString()).to.include('project/src/index.ts');
            });

            it('handles trailing slashes', () => {
                workspaceScope = createScope(['file:///home/user/project']);
                const result = workspaceScope.resolveRelativePath('project/src/');
                expect(result.path.toString()).to.include('project/src');
            });
        });

        describe('duplicate root basenames', () => {
            it('round-trips the mapped root correctly', () => {
                workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']);
                const mapping = workspaceScope.getRootMapping();
                expect(mapping.size).to.equal(1);
                expect(mapping.get('app')?.toString()).to.equal('file:///workspace/a/app');

                // Round-trip for the mapped root
                const uri = mapping.get('app')!.resolve('src/index.ts');
                const relPath = workspaceScope.toWorkspaceRelativePath(uri)!;
                expect(relPath).to.equal('app/src/index.ts');
                const resolved = workspaceScope.resolveRelativePath(relPath);
                expect(resolved.toString()).to.equal(uri.toString());
            });

            it('unmapped root returns undefined from toWorkspaceRelativePath', () => {
                workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']);
                const uri = new URI('file:///workspace/b/app/src/index.ts');
                const relPath = workspaceScope.toWorkspaceRelativePath(uri);
                expect(relPath).to.be.undefined;
            });

            it('resolves app/path to the mapped root (Phase 1)', () => {
                workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']);
                // 'app' maps to file:///workspace/a/app (first by URI sort)
                const result = workspaceScope.resolveRelativePath('app/src/index.ts');
                expect(result.toString()).to.equal('file:///workspace/a/app/src/index.ts');
            });
        });
    });

    describe('getRootMapping duplicate basename stability', () => {
        it('assigns the same root to a name regardless of tryGetRoots order', () => {
            // First ordering
            const mockWorkspaceServiceA = {
                tryGetRoots: () => [
                    { resource: new URI('file:///workspace/b/app') },
                    { resource: new URI('file:///workspace/a/app') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceServiceA);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            const scopeA = container.get(WorkspaceFunctionScope);
            const mappingA = scopeA.getRootMapping();

            // Reverse ordering in a fresh container
            const container2 = new Container();
            const mockWorkspaceServiceB = {
                tryGetRoots: () => [
                    { resource: new URI('file:///workspace/a/app') },
                    { resource: new URI('file:///workspace/b/app') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container2.bind(WorkspaceService).toConstantValue(mockWorkspaceServiceB);
            container2.bind(FileService).toConstantValue({} as FileService);
            container2.bind(PreferenceService).toConstantValue({ get: () => false });
            container2.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container2.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container2.bind(WorkspaceFunctionScope).toSelf();
            const scopeB = container2.get(WorkspaceFunctionScope);
            const mappingB = scopeB.getRootMapping();

            // Both orderings should produce the same winner for 'app'
            // (first by URI sort order: file:///workspace/a/app)
            expect(mappingA.size).to.equal(1);
            expect(mappingB.size).to.equal(1);
            expect(mappingA.get('app')?.toString()).to.equal(mappingB.get('app')?.toString());
            expect(mappingA.get('app')?.toString()).to.equal('file:///workspace/a/app');
        });
    });

    describe('getContainingRoot', () => {
        it('returns the most specific root for nested roots', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///projects/monorepo') },
                    { resource: new URI('file:///projects/monorepo/packages/shared') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            // File in nested root should resolve to the more specific root
            const fileInNested = new URI('file:///projects/monorepo/packages/shared/src/utils.ts');
            const result = workspaceScope.getContainingRoot(fileInNested);
            expect(result?.toString()).to.equal('file:///projects/monorepo/packages/shared');

            // File in parent root (outside nested) should resolve to parent
            const fileInParent = new URI('file:///projects/monorepo/src/main.ts');
            const resultParent = workspaceScope.getContainingRoot(fileInParent);
            expect(resultParent?.toString()).to.equal('file:///projects/monorepo');
        });

        it('returns undefined for file outside all workspace roots', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///home/user/project') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const externalFile = new URI('file:///tmp/outside.ts');
            const result = workspaceScope.getContainingRoot(externalFile);
            expect(result).to.be.undefined;
        });
    });

    describe('round-trip consistency', () => {
        it('toWorkspaceRelativePath and resolveRelativePath are inverses', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///home/user/frontend') },
                    { resource: new URI('file:///home/user/backend') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            const originalUri = new URI('file:///home/user/backend/src/index.ts');
            const relativePath = workspaceScope.toWorkspaceRelativePath(originalUri)!;
            const resolvedUri = workspaceScope.resolveRelativePath(relativePath);

            expect(resolvedUri.toString()).to.equal(originalUri.toString());
        });

        it('paths from toWorkspaceRelativePath always have a root prefix that resolves directly', () => {
            const mockWorkspaceService = {
                tryGetRoots: () => [
                    { resource: new URI('file:///home/user/frontend') },
                    { resource: new URI('file:///home/user/backend') }
                ],
                onWorkspaceChanged: () => ({ dispose: () => { } })
            } as unknown as WorkspaceService;

            container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
            container.bind(FileService).toConstantValue({} as FileService);
            container.bind(PreferenceService).toConstantValue({ get: () => false });
            container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader());
            container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer());
            container.bind(WorkspaceFunctionScope).toSelf();
            workspaceScope = container.get(WorkspaceFunctionScope);

            // Test for each root
            const roots = workspaceScope.getRootMapping();
            for (const [rootName, rootUri] of roots) {
                const fileUri = rootUri.resolve('src/app.ts');
                const relPath = workspaceScope.toWorkspaceRelativePath(fileUri)!;
                expect(relPath.startsWith(rootName + '/')).to.be.true;
                const resolved = workspaceScope.resolveRelativePath(relPath);
                expect(resolved.toString()).to.equal(fileUri.toString());
            }
        });
    });
});
