import {
  BaseFileSystemError,
  FILE_NOT_FOUND_ERROR,
  IndexedDBFileSystem,
} from '@learnitive/baby-fs';
import {
  copyBytesToArrayBuffer,
  projectDirectoryPath,
  projectFilePath,
  type ProjectDirectoryPath,
  type ProjectEntry,
  type ProjectFilePath,
  type ProjectStorageProvider,
} from '@learnitive/project-storage';

const DIRECTORY_MARKER = '.interactive-directory';

function throwIfAborted(signal?: AbortSignal): void {
  signal?.throwIfAborted();
}

function isFileNotFound(error: unknown): boolean {
  return (
    error instanceof BaseFileSystemError && error.code === FILE_NOT_FOUND_ERROR
  );
}

export class IndexedDbProjectStorage implements ProjectStorageProvider {
  readonly capabilities = {
    directFilesystem: false,
    persistent: true,
    requiresExport: true,
  } as const;

  private readonly fs = new IndexedDBFileSystem();

  constructor(private readonly projectId: string) {
    if (!projectId.trim()) {
      throw new Error('IndexedDB project id cannot be empty');
    }
  }

  async listEntries(signal?: AbortSignal): Promise<ProjectEntry[]> {
    throwIfAborted(signal);
    const rawPaths = await this.fs.opendirRecursive(this.projectId, signal);
    const entries: ProjectEntry[] = [];
    const directories = new Set<ProjectDirectoryPath>();

    for (const rawPath of rawPaths) {
      throwIfAborted(signal);
      const relativePath = this.toRelativePath(rawPath);
      const segments = relativePath.split('/');
      const fileName = segments.at(-1);

      // IndexedDB has no first-class empty directories. Learnitive materializes
      // them with an internal dotfile that the workspace file policy hides
      // from users and ZIP exports. Expose the directory, never the marker.
      if (fileName === DIRECTORY_MARKER) {
        segments.pop();
        if (segments.length > 0) {
          directories.add(projectDirectoryPath(segments.join('/')));
        }
        let current = '';
        for (const segment of segments.slice(0, -1)) {
          current = current ? `${current}/${segment}` : segment;
          directories.add(projectDirectoryPath(current));
        }
        continue;
      }

      const path = projectFilePath(relativePath);
      const stat = await this.fs.stat(rawPath);
      const file = await this.fs.readFile(rawPath);
      entries.push({
        kind: 'file',
        path,
        size: file.size,
        modifiedAt: stat.mtimeMs,
      });

      segments.pop();
      let current = '';
      for (const segment of segments) {
        current = current ? `${current}/${segment}` : segment;
        directories.add(projectDirectoryPath(current));
      }
    }

    for (const path of directories) {
      entries.push({ kind: 'directory', path });
    }

    return entries.sort((a, b) => a.path.localeCompare(b.path));
  }

  async readFile(
    path: ProjectFilePath,
    signal?: AbortSignal,
  ): Promise<Uint8Array | undefined> {
    throwIfAborted(signal);
    try {
      const file = await this.fs.readFile(this.toFsPath(path));
      throwIfAborted(signal);
      return new Uint8Array(await file.arrayBuffer());
    } catch (error) {
      if (isFileNotFound(error)) {
        return undefined;
      }
      throw error;
    }
  }

  async writeFile(
    path: ProjectFilePath,
    data: Uint8Array,
    signal?: AbortSignal,
  ): Promise<void> {
    throwIfAborted(signal);
    const fileName = path.slice(path.lastIndexOf('/') + 1);
    await this.fs.writeFile(
      this.toFsPath(path),
      new File([copyBytesToArrayBuffer(data)], fileName),
    );
    throwIfAborted(signal);
  }

  async createDirectory(
    path: ProjectDirectoryPath,
    signal?: AbortSignal,
  ): Promise<void> {
    throwIfAborted(signal);
    const markerPath = projectFilePath(`${path}${DIRECTORY_MARKER}`);
    // IndexedDBFileSystem.writeFile uses put semantics, so this operation is
    // naturally idempotent when the same empty directory is created twice.
    await this.fs.writeFile(
      this.toFsPath(markerPath),
      new File([], DIRECTORY_MARKER),
    );
    throwIfAborted(signal);
  }

  async deleteFile(
    path: ProjectFilePath,
    signal?: AbortSignal,
  ): Promise<void> {
    throwIfAborted(signal);
    try {
      await this.fs.unlink(this.toFsPath(path));
    } catch (error) {
      if (!isFileNotFound(error)) {
        throw error;
      }
    }
  }

  async deleteDirectory(
    path: ProjectDirectoryPath,
    signal?: AbortSignal,
  ): Promise<void> {
    throwIfAborted(signal);
    const rawPrefix = `${this.projectId}/${path}`;
    const rawPaths = await this.fs.opendirRecursive(this.projectId, signal);
    for (const rawPath of rawPaths) {
      throwIfAborted(signal);
      if (!rawPath.startsWith(rawPrefix)) continue;
      try {
        await this.fs.unlink(rawPath);
      } catch (error) {
        if (!isFileNotFound(error)) throw error;
      }
    }
  }

  async moveFile(
    from: ProjectFilePath,
    to: ProjectFilePath,
    signal?: AbortSignal,
  ): Promise<void> {
    throwIfAborted(signal);
    await this.fs.rename(this.toFsPath(from), this.toFsPath(to));
    throwIfAborted(signal);
  }

  private toFsPath(path: ProjectFilePath): string {
    return `${this.projectId}/${path}`;
  }

  private toRelativePath(fsPath: string): string {
    const prefix = `${this.projectId}/`;
    if (!fsPath.startsWith(prefix)) {
      throw new Error(`IndexedDB project path escaped namespace: ${fsPath}`);
    }
    return fsPath.slice(prefix.length);
  }
}
