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

import { Summary, SummaryMetadata, TaskContextStorageService } from '@theia/ai-chat/lib/browser/task-context-service';
import { InMemoryTaskContextStorage } from '@theia/ai-chat/lib/browser/task-context-storage-service';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { DisposableCollection, EOL, Emitter, ILogger, Path, PreferenceService, URI, unreachable } from '@theia/core';
import { OpenerService, open } from '@theia/core/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import * as yaml from 'js-yaml';
import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files';
import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from '../common/workspace-preferences';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';

@injectable()
export class TaskContextFileStorageService implements TaskContextStorageService {
    @inject(InMemoryTaskContextStorage) protected readonly inMemoryStorage: InMemoryTaskContextStorage;
    @inject(PreferenceService) protected readonly preferenceService: PreferenceService;
    @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
    @inject(FileService) protected readonly fileService: FileService;
    @inject(OpenerService) protected readonly openerService: OpenerService;
    @inject(ILogger) protected readonly logger: ILogger;
    protected readonly onDidChangeEmitter = new Emitter<void>();
    readonly onDidChange = this.onDidChangeEmitter.event;

    protected sanitizeLabel(label: string): string {
        return label.replace(/^[^\p{L}\p{N}]+/ug, '');
    }

    protected getStorageLocation(): URI | undefined {
        if (!this.workspaceService.opened) { return; }
        const values = this.preferenceService.inspect(TASK_CONTEXT_STORAGE_DIRECTORY_PREF);
        const configuredPath = values?.globalValue === undefined ? values?.defaultValue : values?.globalValue;
        if (!configuredPath || typeof configuredPath !== 'string') { return; }
        const asPath = new Path(configuredPath);
        return asPath.isAbsolute ? new URI(configuredPath) : this.workspaceService.tryGetRoots().at(0)?.resource.resolve(configuredPath);
    }

    @postConstruct()
    protected init(): void {
        this.doInit();
    }

    protected get ready(): Promise<void> {
        return Promise.all([
            this.workspaceService.ready,
            this.preferenceService.ready,
        ]).then(() => undefined);
    }

    protected async doInit(): Promise<void> {
        await this.ready;
        this.watchStorage();
        this.preferenceService.onPreferenceChanged(e => {
            if (e.preferenceName === TASK_CONTEXT_STORAGE_DIRECTORY_PREF) {
                this.watchStorage().catch(error => this.logger.error(error));
            }
        });
    }

    protected toDisposeOnStorageChange?: DisposableCollection;
    protected async watchStorage(): Promise<void> {
        const newStorage = await this.getStorageLocation();
        this.toDisposeOnStorageChange?.dispose();
        this.toDisposeOnStorageChange = undefined;
        if (!newStorage) { return; }
        this.toDisposeOnStorageChange = new DisposableCollection(
            this.fileService.watch(newStorage, { recursive: true, excludes: [] }),
            this.fileService.onDidFilesChange(event => {
                const relevantChanges = event.changes.filter(candidate => newStorage.isEqualOrParent(candidate.resource));
                this.handleChanges(relevantChanges);
            }),
            { dispose: () => this.clearInMemoryStorage() },
        );
        this.cacheNewTasks(newStorage).catch(this.logger.error.bind(this.logger));
    }

    protected async handleChanges(changes: FileChange[]): Promise<void> {
        await Promise.all(changes.map(change => {
            switch (change.type) {
                case FileChangeType.DELETED: return this.deleteFileReference(change.resource);
                case FileChangeType.ADDED:
                case FileChangeType.UPDATED:
                    return this.readFile(change.resource);
                default: return unreachable(change.type);
            }
        }));
    }

    protected clearInMemoryStorage(): void {
        this.inMemoryStorage.clear();
    }

    protected deleteFileReference(uri: URI): boolean {
        if (this.inMemoryStorage.delete(uri.path.base)) {
            return true;
        }
        for (const summary of this.inMemoryStorage.getAll()) {
            if (summary.uri?.isEqual(uri)) {
                return this.inMemoryStorage.delete(summary.id);
            }
        }
        return false;
    }

    protected async cacheNewTasks(storageLocation: URI): Promise<void> {
        const contents = await this.fileService.resolve(storageLocation).catch(() => undefined);
        if (!contents?.children?.length) { return; }
        await Promise.all(contents.children.map(child => this.readFile(child.resource)));
        this.onDidChangeEmitter.fire();
    }

    protected async readFile(uri: URI): Promise<void> {
        const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined);
        if (content === undefined) { return; }
        const { frontmatter, body } = this.maybeReadFrontmatter(content);
        const rawLabel = frontmatter?.label || uri.path.base.slice(0, (-1 * uri.path.ext.length) || uri.path.base.length);
        const summary = {
            ...frontmatter,
            summary: body,
            label: this.sanitizeLabel(rawLabel),
            uri,
            id: frontmatter?.id || frontmatter?.sessionId || uri.path.base
        };
        const existingSummary = !frontmatter?.id && summary.sessionId && this.getAll().find(candidate => candidate.sessionId === summary.sessionId);
        if (existingSummary) {
            summary.id = existingSummary.id;
        }
        this.inMemoryStorage.store(summary);
    }

    async store(summary: Summary): Promise<void> {
        await this.ready;
        const label = this.sanitizeLabel(summary.label);
        const storageLocation = this.getStorageLocation();
        if (storageLocation) {
            const frontmatter = {
                id: summary.id,
                sessionId: summary.sessionId,
                date: new Date().toISOString(),
                label,
            };
            const derivedName = label.trim().replace(/[^\p{L}\p{N}]/ug, '-').replace(/^-+|-+$/g, '');
            const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md';
            const content = yaml.dump(frontmatter).trim() + `${EOL}---${EOL}` + summary.summary;
            const uri = storageLocation.resolve(filename);
            summary.uri = uri;
            await this.fileService.writeFile(uri, BinaryBuffer.fromString(content));
        }
        this.inMemoryStorage.store({ ...summary, label });
        this.onDidChangeEmitter.fire();
    }

    getAll(): Summary[] {
        return this.inMemoryStorage.getAll();
    }

    async get(identifier: string): Promise<Summary | undefined> {
        const cached = this.inMemoryStorage.get(identifier);
        if (!cached?.uri) {
            return cached;
        }
        // Read fresh content from disk
        const content = await this.fileService.read(cached.uri).then(read => read.value).catch(reason => {
            this.logger.error(`Failed to read file ${cached.uri}: ${reason}`);
            return undefined;
        });
        if (content === undefined) {
            return cached; // Fall back to cache if read fails
        }
        const { body } = this.maybeReadFrontmatter(content);
        return { ...cached, summary: body };
    }

    async delete(identifier: string): Promise<boolean> {
        const summary = this.inMemoryStorage.get(identifier);
        if (summary?.uri) {
            await this.fileService.delete(summary.uri);
        }
        this.inMemoryStorage.delete(identifier);
        if (summary) {
            this.onDidChangeEmitter.fire();
        }
        return !!summary;
    }

    protected maybeReadFrontmatter(content: string): { body: string, frontmatter: SummaryMetadata | undefined } {
        const frontmatterEnd = content.indexOf('---');
        if (frontmatterEnd !== -1) {
            try {
                const frontmatter = yaml.load(content.slice(0, frontmatterEnd));
                if (this.hasLabel(frontmatter)) {
                    return { frontmatter, body: content.slice(frontmatterEnd + 3).trim() };
                }
            } catch { /* Probably not frontmatter, then. */ }
        }
        return { body: content, frontmatter: undefined };
    }

    protected hasLabel(candidate: unknown): candidate is SummaryMetadata {
        return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate) && 'label' in candidate && typeof candidate.label === 'string';
    }

    async open(identifier: string): Promise<void> {
        const summary = await this.get(identifier);
        if (!summary) {
            throw new Error('Unable to open requested task context: none found with specified identifier.');
        }
        await (summary.uri ? open(this.openerService, summary.uri) : this.inMemoryStorage.open(identifier));
    }
}
