import { WORKSPACE_STORAGE_TYPE } from '@learnitive/constants';
import { hasPermission, requestPermission } from '@learnitive/native-fs';
import {
  decideProjectMirrorAction,
  normalizeProjectSlug,
  projectFilePath,
  supportsOpfsProjectStorage,
  type ProjectFilePath,
  type ProjectStorageProvider,
} from '@learnitive/project-storage';
import type {
  ProjectStorageService,
  WorkspaceOpsService,
  WorkspaceStateService,
} from '@learnitive/service-core';
import type { WorkspaceInfo } from '@learnitive/types';

export const PROJECT_STORAGE_MUTATION_EVENT = 'learnitive:project-storage-mutated';
export const PROJECTS_FOLDER_STATE_EVENT = 'learnitive:projects-folder-state-changed';

const HANDLE_DB = 'learnitive-project-folder-handles';
const HANDLE_DB_VERSION = 2;
const HANDLE_STORE = 'roots';
const ROOT_META_DIR = '.learnitive';
const ROOT_MANIFEST = 'root.json';
const ROOT_MANIFEST_BACKUP = 'root.json.bak';
const ROOT_PROJECT_INDEX = 'projects.json';
const ROOT_TOMBSTONES = 'tombstones.json';
const ROOT_TOMBSTONES_BACKUP = 'tombstones.json.bak';
const PROJECT_META_DIR = '.learnitive';
const PROJECT_MANIFEST = 'project.json';
const PROJECT_MANIFEST_BACKUP = 'project.json.bak';
const PROJECT_SYNC = 'sync.json';
const LOCAL_TRASH_DIR = '.learnitive-trash';
const FORMAT_VERSION = 1;

const MIRROR_IGNORED_DIRECTORIES = new Set([
  '.git',
  '.cache',
  '.vite',
  '.venv',
  '__pycache__',
  'bower_components',
  'build',
  'coverage',
  'dist',
  'node_modules',
  'out',
  'target',
  'temp',
  'tmp',
  'vendor',
  'venv',
]);

const encoder = new TextEncoder();

export type ProjectsFolderStatus =
  | 'disconnected'
  | 'permission-required'
  | 'scanning'
  | 'syncing'
  | 'synced'
  | 'pending'
  | 'conflict'
  | 'error';

export type ProjectMirrorStatus =
  | 'browser-only'
  | 'synced'
  | 'syncing'
  | 'pending'
  | 'conflict'
  | 'missing-local'
  | 'recovering'
  | 'error';

export type MirrorConflictResolution = 'browser' | 'local' | 'both';

export type MirrorConflict = {
  id: string;
  projectId: string;
  projectSlug: string;
  path: string;
  baseHash?: string;
  browserHash?: string;
  localHash?: string;
  detectedAt: number;
};

export type MirrorIssue = {
  id: string;
  kind:
    | 'slug-collision'
    | 'slug-mismatch'
    | 'damaged-project-manifest'
    | 'unmanaged-folder'
    | 'sync-error';
  projectId?: string;
  projectSlug?: string;
  folderName?: string;
  message: string;
};

export type ProjectsFolderSnapshot = {
  status: ProjectsFolderStatus;
  connected: boolean;
  handleName?: string;
  rootId?: string;
  lastSuccessfulSyncAt?: number;
  projectCount: number;
  pendingCount: number;
  conflicts: MirrorConflict[];
  issues: MirrorIssue[];
  error?: string;
};

export type ProjectsFolderInspection = {
  kind: 'existing-root' | 'recoverable-root' | 'empty' | 'unmanaged-nonempty';
  rootId?: string;
  projectCount: number;
  entryCount: number;
  message?: string;
};

type ProjectManifest = {
  formatVersion: 1;
  projectId: string;
  projectSlug: string;
  name: string;
  projectModeId?: string;
  projectProfile?: unknown;
  createdAt: number;
  updatedAt: number;
};

type RootManifest = {
  formatVersion: 1;
  kind: 'learnitive-projects-root';
  rootId: string;
  createdAt: number;
  updatedAt: number;
};

type RootProjectIndex = {
  formatVersion: 1;
  projects: Array<{ projectId: string; projectSlug: string }>;
  updatedAt: number;
};

type ProjectTombstone = {
  projectId: string;
  projectSlug: string;
  deletedAt: number;
};

type TombstoneFile = {
  formatVersion: 1;
  tombstones: ProjectTombstone[];
  updatedAt: number;
};

type SyncFileState = {
  hash: string;
  size: number;
};

type ProjectSyncState = {
  formatVersion: 1;
  lastCommonRevision: number;
  updatedAt: number;
  files: Record<string, SyncFileState>;
};

type PendingDeleteState = {
  files: string[];
  prefixes: string[];
};

type RootHandleRecord = {
  id: 'current';
  rootId: string;
  name: string;
  handle: FileSystemDirectoryHandle;
  connectedAt: number;
  lastSuccessfulSyncAt?: number;
  lastError?: string;
  conflicts?: MirrorConflict[];
  issues?: MirrorIssue[];
  pendingDeletes?: Record<string, PendingDeleteState>;
};

export type ProjectMirrorDependencies = {
  workspaceOps: WorkspaceOpsService;
  projectStorage: ProjectStorageService;
  workspaceState: WorkspaceStateService;
};

type LocalProject = {
  directory: FileSystemDirectoryHandle;
  folderName: string;
  manifest: ProjectManifest;
};

type BrowserProjectIdentity = {
  workspace: WorkspaceInfo;
  projectId: string;
  projectSlug: string;
  name: string;
  projectModeId?: string;
  projectProfile?: unknown;
};

type FileSnapshot = {
  path: string;
  bytes: Uint8Array;
  hash: string;
  size: number;
};

export type MirrorDecision = ReturnType<typeof decideProjectMirrorAction>;


function randomId(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

function stringValue(value: unknown): string | undefined {
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function directoryEntries(directory: FileSystemDirectoryHandle): AsyncIterable<readonly [string, FileSystemHandle]> {
  const entries = (directory as FileSystemDirectoryHandle & {
    entries?: () => AsyncIterableIterator<[string, FileSystemHandle]>;
  }).entries;
  if (typeof entries !== 'function') throw new Error('Directory iteration is unavailable in this browser.');
  return entries.call(directory);
}

function shouldMirrorPath(path: string): boolean {
  const normalized = path.replaceAll('\\', '/').replace(/^\/+|\/+$/gu, '');
  if (!normalized) return false;
  const parts = normalized.split('/');
  if (parts[0] === PROJECT_META_DIR) return false;
  return !parts.slice(0, -1).some((part) => MIRROR_IGNORED_DIRECTORIES.has(part.toLowerCase()));
}

function isBrowserWorkspace(workspace: WorkspaceInfo): boolean {
  return workspace.type === WORKSPACE_STORAGE_TYPE.Browser || workspace.type === WORKSPACE_STORAGE_TYPE.BrowserOPFS;
}

function isNormalProjectWorkspace(workspace: WorkspaceInfo): boolean {
  const collaboration = workspace.metadata?.learnitiveCollaboration;
  const joined = collaboration === 'joined'
    || (isPlainObject(collaboration) && collaboration.source === 'joined');
  return isBrowserWorkspace(workspace)
    && workspace.metadata?.learnTemporary !== true
    && !joined;
}

async function digestBytes(bytes: Uint8Array): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', bytes.slice().buffer);
  return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('');
}

function openHandleDb(): Promise<IDBDatabase> {
  if (typeof indexedDB === 'undefined') return Promise.reject(new Error('IndexedDB is unavailable.'));
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(HANDLE_DB, HANDLE_DB_VERSION);
    request.onupgradeneeded = (event) => {
      const db = request.result;
      const transaction = request.transaction;
      if (event.oldVersion < 2 && db.objectStoreNames.contains(HANDLE_STORE) && transaction) {
        const legacyStore = transaction.objectStore(HANDLE_STORE);
        const legacyRequest = legacyStore.getAll();
        legacyRequest.onsuccess = () => {
          const legacyRecords = (legacyRequest.result as Array<Record<string, unknown>>)
            .filter((record) => record?.handle)
            .sort((left, right) => Number(right.connectedAt ?? 0) - Number(left.connectedAt ?? 0));
          db.deleteObjectStore(HANDLE_STORE);
          const store = db.createObjectStore(HANDLE_STORE, { keyPath: 'id' });
          const legacy = legacyRecords[0];
          if (legacy?.handle) {
            store.put({
              id: 'current',
              rootId: String(legacy.rootId || ''),
              name: String(legacy.name || 'Projects Folder'),
              handle: legacy.handle,
              connectedAt: Number(legacy.connectedAt) || Date.now(),
              lastSuccessfulSyncAt: Number(legacy.lastSuccessfulSyncAt) || undefined,
              lastError: typeof legacy.lastError === 'string' ? legacy.lastError : undefined,
              conflicts: Array.isArray(legacy.conflicts) ? legacy.conflicts : [],
              issues: Array.isArray(legacy.issues) ? legacy.issues : [],
              pendingDeletes: legacy.pendingDeletes && typeof legacy.pendingDeletes === 'object' ? legacy.pendingDeletes : {},
            });
          }
        };
        return;
      }
      if (!db.objectStoreNames.contains(HANDLE_STORE)) db.createObjectStore(HANDLE_STORE, { keyPath: 'id' });
    };
    request.onerror = () => reject(request.error ?? new Error('Could not open Projects Folder handle database.'));
    request.onsuccess = () => resolve(request.result);
  });
}

async function getHandleRecord(): Promise<RootHandleRecord | undefined> {
  const db = await openHandleDb();
  try {
    return await new Promise((resolve, reject) => {
      const request = db.transaction(HANDLE_STORE, 'readonly').objectStore(HANDLE_STORE).get('current');
      request.onerror = () => reject(request.error ?? new Error('Could not read Projects Folder handle.'));
      request.onsuccess = () => resolve(request.result as RootHandleRecord | undefined);
    });
  } finally { db.close(); }
}

async function putHandleRecord(record: RootHandleRecord): Promise<void> {
  const db = await openHandleDb();
  try {
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(HANDLE_STORE, 'readwrite');
      tx.objectStore(HANDLE_STORE).put(record);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error ?? new Error('Could not save Projects Folder handle.'));
      tx.onabort = () => reject(tx.error ?? new Error('Could not save Projects Folder handle.'));
    });
  } finally { db.close(); }
}

async function deleteHandleRecord(): Promise<void> {
  const db = await openHandleDb();
  try {
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(HANDLE_STORE, 'readwrite');
      tx.objectStore(HANDLE_STORE).delete('current');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error ?? new Error('Could not disconnect Projects Folder.'));
      tx.onabort = () => reject(tx.error ?? new Error('Could not disconnect Projects Folder.'));
    });
  } finally { db.close(); }
}

async function readFileBytes(directory: FileSystemDirectoryHandle, path: string): Promise<Uint8Array | undefined> {
  const parts = path.replaceAll('\\', '/').split('/').filter(Boolean);
  const fileName = parts.pop();
  if (!fileName) return undefined;
  let current = directory;
  try {
    for (const part of parts) current = await current.getDirectoryHandle(part);
    const handle = await current.getFileHandle(fileName);
    return new Uint8Array(await (await handle.getFile()).arrayBuffer());
  } catch (error) {
    if (error instanceof DOMException && error.name === 'NotFoundError') return undefined;
    throw error;
  }
}

async function readFileText(directory: FileSystemDirectoryHandle, path: string): Promise<string | undefined> {
  const bytes = await readFileBytes(directory, path);
  return bytes ? new TextDecoder().decode(bytes) : undefined;
}

async function writeFileBytes(directory: FileSystemDirectoryHandle, path: string, bytes: Uint8Array): Promise<void> {
  const parts = path.replaceAll('\\', '/').split('/').filter(Boolean);
  const fileName = parts.pop();
  if (!fileName) throw new Error(`Invalid file path: ${path}`);
  let current = directory;
  for (const part of parts) current = await current.getDirectoryHandle(part, { create: true });
  const handle = await current.getFileHandle(fileName, { create: true });
  const writable = await handle.createWritable();
  try {
    await writable.write(bytes.slice().buffer);
    await writable.close();
  } catch (error) {
    await writable.abort().catch(() => undefined);
    throw error;
  }
}

async function writeTextFile(directory: FileSystemDirectoryHandle, path: string, text: string): Promise<void> {
  await writeFileBytes(directory, path, encoder.encode(text));
}

async function removeFile(directory: FileSystemDirectoryHandle, path: string): Promise<void> {
  const parts = path.replaceAll('\\', '/').split('/').filter(Boolean);
  const fileName = parts.pop();
  if (!fileName) return;
  let current = directory;
  try {
    for (const part of parts) current = await current.getDirectoryHandle(part);
    await current.removeEntry(fileName);
  } catch (error) {
    if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error;
  }
}

async function readJson<T>(directory: FileSystemDirectoryHandle, path: string): Promise<T | undefined> {
  const text = await readFileText(directory, path);
  if (!text) return undefined;
  try { return JSON.parse(text) as T; } catch { return undefined; }
}

async function writeJson(directory: FileSystemDirectoryHandle, path: string, value: unknown): Promise<void> {
  await writeTextFile(directory, path, `${JSON.stringify(value, null, 2)}\n`);
}

async function writeJsonWithBackup(
  directory: FileSystemDirectoryHandle,
  path: string,
  backupPath: string,
  value: unknown,
): Promise<void> {
  const previous = await readFileBytes(directory, path);
  if (previous) await writeFileBytes(directory, backupPath, previous);
  await writeJson(directory, path, value);
  const verify = await readJson<unknown>(directory, path);
  if (!verify) throw new Error(`Could not verify metadata write: ${path}`);
}

function validRootManifest(value: unknown): value is RootManifest {
  if (!isPlainObject(value)) return false;
  return value.formatVersion === FORMAT_VERSION
    && value.kind === 'learnitive-projects-root'
    && Boolean(stringValue(value.rootId));
}

function validProjectManifest(value: unknown): value is ProjectManifest {
  if (!isPlainObject(value)) return false;
  return value.formatVersion === FORMAT_VERSION
    && Boolean(stringValue(value.projectId))
    && Boolean(stringValue(value.projectSlug));
}

function validSyncState(value: unknown): value is ProjectSyncState {
  return isPlainObject(value)
    && value.formatVersion === FORMAT_VERSION
    && typeof value.lastCommonRevision === 'number'
    && isPlainObject(value.files);
}

async function readRootManifest(root: FileSystemDirectoryHandle): Promise<RootManifest | undefined> {
  const value = await readJson<unknown>(root, `${ROOT_META_DIR}/${ROOT_MANIFEST}`)
    ?? await readJson<unknown>(root, `${ROOT_META_DIR}/${ROOT_MANIFEST_BACKUP}`);
  return validRootManifest(value) ? value : undefined;
}

async function writeRootManifest(root: FileSystemDirectoryHandle, manifest: RootManifest): Promise<void> {
  await writeJsonWithBackup(root, `${ROOT_META_DIR}/${ROOT_MANIFEST}`, `${ROOT_META_DIR}/${ROOT_MANIFEST_BACKUP}`, manifest);
}

async function readProjectManifest(directory: FileSystemDirectoryHandle): Promise<ProjectManifest | undefined> {
  const value = await readJson<unknown>(directory, `${PROJECT_META_DIR}/${PROJECT_MANIFEST}`)
    ?? await readJson<unknown>(directory, `${PROJECT_META_DIR}/${PROJECT_MANIFEST_BACKUP}`);
  return validProjectManifest(value) ? value : undefined;
}

async function writeProjectManifest(directory: FileSystemDirectoryHandle, manifest: ProjectManifest): Promise<void> {
  await writeJsonWithBackup(directory, `${PROJECT_META_DIR}/${PROJECT_MANIFEST}`, `${PROJECT_META_DIR}/${PROJECT_MANIFEST_BACKUP}`, manifest);
}

async function readSyncState(directory: FileSystemDirectoryHandle): Promise<ProjectSyncState> {
  const value = await readJson<unknown>(directory, `${PROJECT_META_DIR}/${PROJECT_SYNC}`);
  return validSyncState(value)
    ? value
    : { formatVersion: FORMAT_VERSION, lastCommonRevision: 0, updatedAt: 0, files: {} };
}

async function writeSyncState(directory: FileSystemDirectoryHandle, state: ProjectSyncState): Promise<void> {
  await writeJson(directory, `${PROJECT_META_DIR}/${PROJECT_SYNC}`, state);
}

async function readTombstones(root: FileSystemDirectoryHandle): Promise<TombstoneFile> {
  const value = await readJson<unknown>(root, `${ROOT_META_DIR}/${ROOT_TOMBSTONES}`)
    ?? await readJson<unknown>(root, `${ROOT_META_DIR}/${ROOT_TOMBSTONES_BACKUP}`);
  if (!isPlainObject(value) || !Array.isArray(value.tombstones)) {
    return { formatVersion: FORMAT_VERSION, tombstones: [], updatedAt: 0 };
  }
  const tombstones = value.tombstones.flatMap((item): ProjectTombstone[] => {
    if (!isPlainObject(item)) return [];
    const projectId = stringValue(item.projectId);
    const projectSlug = stringValue(item.projectSlug);
    if (!projectId || !projectSlug) return [];
    return [{ projectId, projectSlug, deletedAt: Number(item.deletedAt) || 0 }];
  });
  return { formatVersion: FORMAT_VERSION, tombstones, updatedAt: Number(value.updatedAt) || 0 };
}

async function writeTombstones(root: FileSystemDirectoryHandle, tombstones: TombstoneFile): Promise<void> {
  await writeJsonWithBackup(root, `${ROOT_META_DIR}/${ROOT_TOMBSTONES}`, `${ROOT_META_DIR}/${ROOT_TOMBSTONES_BACKUP}`, tombstones);
}

async function listImmediateDirectories(root: FileSystemDirectoryHandle): Promise<Array<[string, FileSystemDirectoryHandle]>> {
  const rows: Array<[string, FileSystemDirectoryHandle]> = [];
  for await (const [name, handle] of directoryEntries(root)) {
    if (handle.kind === 'directory') rows.push([name, handle as FileSystemDirectoryHandle]);
  }
  return rows;
}

async function countImmediateEntries(root: FileSystemDirectoryHandle): Promise<number> {
  let count = 0;
  for await (const _entry of directoryEntries(root)) count += 1;
  return count;
}

async function scanLocalProjects(root: FileSystemDirectoryHandle): Promise<{
  projects: LocalProject[];
  issues: MirrorIssue[];
}> {
  const projects: LocalProject[] = [];
  const issues: MirrorIssue[] = [];
  for (const [folderName, directory] of await listImmediateDirectories(root)) {
    if (folderName === ROOT_META_DIR || folderName === LOCAL_TRASH_DIR) continue;
    let hasInteractiveMetadata = false;
    try { await directory.getDirectoryHandle(PROJECT_META_DIR); hasInteractiveMetadata = true; } catch { /* unmanaged folder */ }
    const manifest = await readProjectManifest(directory);
    if (!manifest) {
      if (hasInteractiveMetadata) {
        issues.push({
          id: `damaged-project-manifest:${folderName}`,
          kind: 'damaged-project-manifest',
          folderName,
          message: `“${folderName}” contains Learnitive metadata but its project manifest could not be read.`,
        });
      }
      continue;
    }
    projects.push({ directory, folderName, manifest });
  }
  return { projects, issues };
}

async function listDirectoryFiles(
  root: FileSystemDirectoryHandle,
  prefix = '',
): Promise<Map<string, FileSnapshot>> {
  const files = new Map<string, FileSnapshot>();
  for await (const [name, handle] of directoryEntries(root)) {
    const path = prefix ? `${prefix}/${name}` : name;
    if (handle.kind === 'directory') {
      if (prefix === '' && name === PROJECT_META_DIR) continue;
      if (MIRROR_IGNORED_DIRECTORIES.has(name.toLowerCase())) continue;
      const nested = await listDirectoryFiles(handle as FileSystemDirectoryHandle, path);
      for (const [nestedPath, snapshot] of nested) files.set(nestedPath, snapshot);
      continue;
    }
    if (!shouldMirrorPath(path)) continue;
    const file = await (handle as FileSystemFileHandle).getFile();
    const bytes = new Uint8Array(await file.arrayBuffer());
    files.set(path, { path, bytes, hash: await digestBytes(bytes), size: bytes.byteLength });
  }
  return files;
}

async function listProviderFiles(provider: ProjectStorageProvider): Promise<Map<string, FileSnapshot>> {
  const files = new Map<string, FileSnapshot>();
  for (const entry of await provider.listEntries()) {
    if (entry.kind !== 'file') continue;
    const path = String(entry.path);
    if (!shouldMirrorPath(path)) continue;
    const bytes = await provider.readFile(entry.path);
    if (!bytes) continue;
    files.set(path, { path, bytes, hash: await digestBytes(bytes), size: bytes.byteLength });
  }
  return files;
}

function projectManifestFor(identity: BrowserProjectIdentity): ProjectManifest {
  const now = Date.now();
  return {
    formatVersion: FORMAT_VERSION,
    projectId: identity.projectId,
    projectSlug: identity.projectSlug,
    name: identity.name,
    ...(identity.projectModeId ? { projectModeId: identity.projectModeId } : {}),
    ...(identity.projectProfile ? { projectProfile: identity.projectProfile } : {}),
    createdAt: Number(identity.workspace.metadata?.projectCreatedAt) || identity.workspace.lastModified || now,
    updatedAt: now,
  };
}

async function copyDirectoryRecursive(source: FileSystemDirectoryHandle, target: FileSystemDirectoryHandle): Promise<void> {
  for await (const [name, handle] of directoryEntries(source)) {
    if (handle.kind === 'directory') {
      const next = await target.getDirectoryHandle(name, { create: true });
      await copyDirectoryRecursive(handle as FileSystemDirectoryHandle, next);
      continue;
    }
    const file = await (handle as FileSystemFileHandle).getFile();
    await writeFileBytes(target, name, new Uint8Array(await file.arrayBuffer()));
  }
}

function conflictId(projectId: string, path: string): string {
  return `${projectId}:${path}`;
}

function conflictCopyPath(path: string): string {
  const slash = path.lastIndexOf('/');
  const directory = slash >= 0 ? `${path.slice(0, slash + 1)}` : '';
  const file = slash >= 0 ? path.slice(slash + 1) : path;
  const dot = file.lastIndexOf('.');
  const stem = dot > 0 ? file.slice(0, dot) : file;
  const ext = dot > 0 ? file.slice(dot) : '';
  const stamp = new Date().toISOString().replace(/[-:]/gu, '').replace(/\..+$/u, '').replace('T', '-');
  return `${directory}${stem}.local-conflict-${stamp}${ext}`;
}


export type CreatedProjectMirrorResult =
  | { status: 'browser-only' }
  | { status: 'permission-required' }
  | { status: 'synced' }
  | { status: 'error'; error: string };

/**
 * Finishes a newly created Browser project against the optional Projects Folder.
 * The caller can safely await this before navigation: disconnected/permission-lost
 * roots never block Browser creation, while an already-authorized root gets the
 * canonical <projectSlug>/ directory, manifest, and initial file reconciliation
 * immediately instead of waiting for a later coordinator/focus pass.
 */
export async function finalizeCreatedBrowserProject(
  workspaceName: string,
  dependencies: ProjectMirrorDependencies,
): Promise<CreatedProjectMirrorResult> {
  const service = new ProjectMirrorService(dependencies);
  let snapshot: ProjectsFolderSnapshot;
  try {
    snapshot = await service.getSnapshot();
  } catch (error) {
    return { status: 'error', error: error instanceof Error ? error.message : String(error) };
  }
  if (!snapshot.connected) return { status: 'browser-only' };
  if (snapshot.status === 'permission-required') return { status: 'permission-required' };
  try {
    await service.syncProject(workspaceName);
    return { status: 'synced' };
  } catch (error) {
    return { status: 'error', error: error instanceof Error ? error.message : String(error) };
  }
}

export class ProjectsFolderNeedsConfirmationError extends Error {
  constructor(public readonly inspection: ProjectsFolderInspection) {
    super('This folder is not currently an Learnitive Projects Folder.');
    this.name = 'ProjectsFolderNeedsConfirmationError';
  }
}

export class ProjectMirrorService {
  private syncing = false;

  constructor(private readonly dependencies: ProjectMirrorDependencies) {}

  async inspectRoot(handle: FileSystemDirectoryHandle): Promise<ProjectsFolderInspection> {
    const entryCount = await countImmediateEntries(handle);
    const root = await readRootManifest(handle);
    const local = await scanLocalProjects(handle);
    if (root) return { kind: 'existing-root', rootId: root.rootId, projectCount: local.projects.length, entryCount };
    if (local.projects.length) return { kind: 'recoverable-root', projectCount: local.projects.length, entryCount };
    if (!entryCount) return { kind: 'empty', projectCount: 0, entryCount: 0 };
    return { kind: 'unmanaged-nonempty', projectCount: 0, entryCount };
  }

  async connectRoot(
    handle: FileSystemDirectoryHandle,
    options: { allowInitializeNonEmpty?: boolean } = {},
  ): Promise<ProjectsFolderSnapshot> {
    if (!(await requestPermission(handle, 'readwrite'))) throw new Error('Read/write permission was not granted for the selected folder.');
    const inspection = await this.inspectRoot(handle);
    if (inspection.kind === 'unmanaged-nonempty' && !options.allowInitializeNonEmpty) {
      throw new ProjectsFolderNeedsConfirmationError(inspection);
    }

    const existing = await readRootManifest(handle);
    const rootId = existing?.rootId ?? inspection.rootId ?? randomId();
    const now = Date.now();
    await writeRootManifest(handle, {
      formatVersion: FORMAT_VERSION,
      kind: 'learnitive-projects-root',
      rootId,
      createdAt: existing?.createdAt ?? now,
      updatedAt: now,
    });
    const previous = await getHandleRecord().catch(() => undefined);
    const sameRoot = Boolean(previous && previous.rootId === rootId);
    await putHandleRecord({
      id: 'current',
      rootId,
      name: handle.name,
      handle,
      connectedAt: sameRoot ? previous!.connectedAt : now,
      lastSuccessfulSyncAt: sameRoot ? previous?.lastSuccessfulSyncAt : undefined,
      conflicts: sameRoot ? previous?.conflicts ?? [] : [],
      issues: sameRoot ? previous?.issues ?? [] : [],
      pendingDeletes: sameRoot ? previous?.pendingDeletes ?? {} : {},
    });
    try {
      return await this.syncAll();
    } catch (error) {
      // Switching roots is transactional at the browser-handle level. A failed
      // initial reconciliation must not strand the user on a broken new root.
      if (previous) await putHandleRecord(previous).catch(() => undefined);
      else await deleteHandleRecord().catch(() => undefined);
      throw error;
    }
  }

  async disconnectRoot(): Promise<void> {
    await deleteHandleRecord();
  }

  async getSnapshot(): Promise<ProjectsFolderSnapshot> {
    const record = await getHandleRecord().catch(() => undefined);
    if (!record) return { status: 'disconnected', connected: false, projectCount: 0, pendingCount: 0, conflicts: [], issues: [] };
    const browserProjects = (await this.dependencies.workspaceOps.getAllWorkspaces()).filter(isNormalProjectWorkspace);
    let permission = false;
    try { permission = await hasPermission(record.handle, 'readwrite'); } catch { permission = false; }
    if (!permission) {
      return {
        status: 'permission-required',
        connected: true,
        handleName: record.name,
        rootId: record.rootId,
        lastSuccessfulSyncAt: record.lastSuccessfulSyncAt,
        projectCount: browserProjects.length,
        pendingCount: Object.keys(record.pendingDeletes ?? {}).length,
        conflicts: record.conflicts ?? [],
        issues: record.issues ?? [],
        error: record.lastError,
      };
    }
    const conflicts = record.conflicts ?? [];
    const issues = record.issues ?? [];
    return {
      status: conflicts.length ? 'conflict' : record.lastError ? 'error' : Object.keys(record.pendingDeletes ?? {}).length ? 'pending' : 'synced',
      connected: true,
      handleName: record.name,
      rootId: record.rootId,
      lastSuccessfulSyncAt: record.lastSuccessfulSyncAt,
      projectCount: browserProjects.length,
      pendingCount: Object.keys(record.pendingDeletes ?? {}).length,
      conflicts,
      issues,
      error: record.lastError,
    };
  }

  async requestRootPermission(): Promise<ProjectsFolderSnapshot> {
    const record = await getHandleRecord();
    if (!record) return this.getSnapshot();
    if (!(await requestPermission(record.handle, 'readwrite'))) throw new Error('Projects Folder permission was not granted.');
    return this.syncAll();
  }

  async recordBrowserDeletion(workspaceName: string, path: string, directory = false): Promise<void> {
    const record = await getHandleRecord().catch(() => undefined);
    if (!record) return;
    const workspace = await this.dependencies.workspaceOps.getWorkspaceInfo(workspaceName);
    if (!workspace || !isNormalProjectWorkspace(workspace)) return;
    const slug = stringValue(workspace.metadata?.projectSlug) ?? workspace.name;
    const pending = record.pendingDeletes ?? {};
    const state = pending[slug] ?? { files: [], prefixes: [] };
    if (directory) {
      const prefix = path.replace(/\/+$/u, '') + '/';
      if (!state.prefixes.includes(prefix)) state.prefixes.push(prefix);
    } else if (!state.files.includes(path)) state.files.push(path);
    pending[slug] = state;
    await putHandleRecord({ ...record, pendingDeletes: pending });
  }

  async syncAll(): Promise<ProjectsFolderSnapshot> {
    if (this.syncing) return this.getSnapshot();
    this.syncing = true;
    try {
      const record = await getHandleRecord().catch(() => undefined);
      if (!record) return { status: 'disconnected', connected: false, projectCount: 0, pendingCount: 0, conflicts: [], issues: [] };
      if (!(await hasPermission(record.handle, 'readwrite'))) return this.getSnapshot();
      const root = record.handle;
      const rootManifest = await readRootManifest(root);
      const now = Date.now();
      if (!rootManifest) {
        await writeRootManifest(root, {
          formatVersion: FORMAT_VERSION,
          kind: 'learnitive-projects-root',
          rootId: record.rootId,
              createdAt: record.connectedAt,
          updatedAt: now,
        });
      }

      const tombstoneFile = await readTombstones(root);
      const tombstonedIds = new Set(tombstoneFile.tombstones.map((item) => item.projectId));
      const localScan = await scanLocalProjects(root);
      const localById = new Map(localScan.projects.map((project) => [project.manifest.projectId, project]));
      const localBySlug = new Map(localScan.projects.map((project) => [project.manifest.projectSlug, project]));
      let browserProjects = (await this.dependencies.workspaceOps.getAllWorkspaces()).filter(isNormalProjectWorkspace);
      const identities = await Promise.all(browserProjects.map((workspace) => this.ensureWorkspaceIdentity(workspace)));
      const browserById = new Map(identities.map((identity) => [identity.projectId, identity]));
      const browserBySlug = new Map(identities.map((identity) => [identity.projectSlug, identity]));
      const issues: MirrorIssue[] = [...localScan.issues];
      const conflicts: MirrorConflict[] = [];
      const mutableRecord: RootHandleRecord = { ...record, pendingDeletes: { ...(record.pendingDeletes ?? {}) } };

      // Browser projects are canonical working copies. Create/reconcile their
      // matching local child directory, migrating an old child slug by stable ID.
      for (const identity of identities) {
        const byId = localById.get(identity.projectId);
        const bySlug = localBySlug.get(identity.projectSlug);
        if (bySlug && bySlug.manifest.projectId !== identity.projectId) {
          issues.push({
            id: `slug-collision:${identity.projectSlug}`,
            kind: 'slug-collision',
            projectId: identity.projectId,
            projectSlug: identity.projectSlug,
            folderName: bySlug.folderName,
            message: `A different project already uses the local folder “${identity.projectSlug}”.`,
          });
          continue;
        }

        let local = byId;
        if (local && local.folderName !== identity.projectSlug) {
          // Safe slug migration: create the canonical target, sync Browser into it,
          // and only remove the old directory after the new copy verifies.
          const target = await root.getDirectoryHandle(identity.projectSlug, { create: true });
          const migrationConflicts = await this.reconcileProject(identity, target, mutableRecord);
          conflicts.push(...migrationConflicts);
          if (!migrationConflicts.length) {
            await root.removeEntry(local.folderName, { recursive: true }).catch(() => undefined);
            local = { directory: target, folderName: identity.projectSlug, manifest: projectManifestFor(identity) };
            localById.set(identity.projectId, local);
            localBySlug.delete(byId?.manifest.projectSlug ?? '');
            localBySlug.set(identity.projectSlug, local);
          } else {
            issues.push({
              id: `slug-mismatch:${identity.projectId}`,
              kind: 'slug-mismatch',
              projectId: identity.projectId,
              projectSlug: identity.projectSlug,
              folderName: byId?.folderName,
              message: `The project folder name no longer matches the project slug “${identity.projectSlug}”.`,
            });
          }
          continue;
        }

        if (!local) {
          const directory = await root.getDirectoryHandle(identity.projectSlug, { create: true });
          local = { directory, folderName: identity.projectSlug, manifest: projectManifestFor(identity) };
          localById.set(identity.projectId, local);
          localBySlug.set(identity.projectSlug, local);
        }
        conflicts.push(...await this.reconcileProject(identity, local.directory, mutableRecord));
      }

      // Recover valid local projects absent from Browser. Recovery bypasses the
      // project creation quota because it restores existing user data.
      for (const local of localScan.projects) {
        if (tombstonedIds.has(local.manifest.projectId)) continue;
        if (browserById.has(local.manifest.projectId)) continue;
        const slugOccupant = browserBySlug.get(local.manifest.projectSlug);
        if (slugOccupant && slugOccupant.projectId !== local.manifest.projectId) {
          issues.push({
            id: `slug-collision:${local.manifest.projectId}`,
            kind: 'slug-collision',
            projectId: local.manifest.projectId,
            projectSlug: local.manifest.projectSlug,
            folderName: local.folderName,
            message: `The local project “${local.manifest.projectSlug}” conflicts with another Browser project using the same slug.`,
          });
          continue;
        }
        const recovered = await this.recoverLocalProject(local);
        const identity = await this.ensureWorkspaceIdentity(recovered);
        browserById.set(identity.projectId, identity);
        browserBySlug.set(identity.projectSlug, identity);
        identities.push(identity);
      }

      browserProjects = (await this.dependencies.workspaceOps.getAllWorkspaces()).filter(isNormalProjectWorkspace);
      const finalIdentities = await Promise.all(browserProjects.map((workspace) => this.ensureWorkspaceIdentity(workspace)));
      await writeJson(root, `${ROOT_META_DIR}/${ROOT_PROJECT_INDEX}`, {
        formatVersion: FORMAT_VERSION,
        projects: finalIdentities.map((identity) => ({ projectId: identity.projectId, projectSlug: identity.projectSlug })),
        updatedAt: Date.now(),
      } satisfies RootProjectIndex);

      const nextRecord: RootHandleRecord = {
        ...mutableRecord,
        conflicts,
        issues,
        lastSuccessfulSyncAt: Date.now(),
        lastError: undefined,
      };
      await putHandleRecord(nextRecord);
      await this.dependencies.workspaceState.refreshWorkspacesNow();
      return this.getSnapshot();
    } catch (error) {
      const record = await getHandleRecord().catch(() => undefined);
      if (record) await putHandleRecord({ ...record, lastError: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
      throw error;
    } finally {
      this.syncing = false;
    }
  }

  async syncProject(workspaceName: string): Promise<void> {
    const record = await getHandleRecord().catch(() => undefined);
    if (!record || !(await hasPermission(record.handle, 'readwrite'))) return;
    const workspace = await this.dependencies.workspaceOps.getWorkspaceInfo(workspaceName);
    if (!workspace || !isNormalProjectWorkspace(workspace)) return;
    const identity = await this.ensureWorkspaceIdentity(workspace);
    const directory = await record.handle.getDirectoryHandle(identity.projectSlug, { create: true });
    const mutableRecord = { ...record, pendingDeletes: { ...(record.pendingDeletes ?? {}) } };
    const conflicts = await this.reconcileProject(identity, directory, mutableRecord);
    const unrelated = (record.conflicts ?? []).filter((conflict) => conflict.projectId !== identity.projectId);
    await putHandleRecord({
      ...mutableRecord,
      conflicts: [...unrelated, ...conflicts],
      lastSuccessfulSyncAt: Date.now(),
      lastError: undefined,
    });
  }

  async trashProject(workspace: WorkspaceInfo): Promise<void> {
    const record = await getHandleRecord().catch(() => undefined);
    if (!record || !(await hasPermission(record.handle, 'readwrite'))) return;
    const identity = await this.ensureWorkspaceIdentity(workspace);
    const tombstones = await readTombstones(record.handle);
    if (!tombstones.tombstones.some((item) => item.projectId === identity.projectId)) {
      tombstones.tombstones.push({ projectId: identity.projectId, projectSlug: identity.projectSlug, deletedAt: Date.now() });
      tombstones.updatedAt = Date.now();
      await writeTombstones(record.handle, tombstones);
    }
    try {
      const source = await record.handle.getDirectoryHandle(identity.projectSlug);
      const trashRoot = await record.handle.getDirectoryHandle(LOCAL_TRASH_DIR, { create: true });
      const trashName = `${identity.projectSlug}--${identity.projectId.slice(0, 8)}--${Date.now()}`;
      const target = await trashRoot.getDirectoryHandle(trashName, { create: true });
      await copyDirectoryRecursive(source, target);
      await record.handle.removeEntry(identity.projectSlug, { recursive: true });
    } catch (error) {
      if (!(error instanceof DOMException && error.name === 'NotFoundError')) {
        // Tombstone already protects recovery. Keep the local bytes if the move
        // cannot complete so a filesystem problem never causes data loss.
      }
    }
  }

  async clearProjectTombstone(projectId: string): Promise<void> {
    const record = await getHandleRecord().catch(() => undefined);
    if (!record || !(await hasPermission(record.handle, 'readwrite'))) return;
    const tombstones = await readTombstones(record.handle);
    const next = tombstones.tombstones.filter((item) => item.projectId !== projectId);
    if (next.length === tombstones.tombstones.length) return;
    await writeTombstones(record.handle, { formatVersion: FORMAT_VERSION, tombstones: next, updatedAt: Date.now() });
  }

  async resolveConflict(conflictIdValue: string, resolution: MirrorConflictResolution): Promise<void> {
    const record = await getHandleRecord();
    if (!record || !(await hasPermission(record.handle, 'readwrite'))) throw new Error('Projects Folder access is required.');
    const conflict = (record.conflicts ?? []).find((item) => item.id === conflictIdValue);
    if (!conflict) return;
    const workspace = await this.dependencies.workspaceOps.getWorkspaceInfo(conflict.projectSlug);
    if (!workspace) throw new Error(`Browser project is unavailable: ${conflict.projectSlug}`);
    const identity = await this.ensureWorkspaceIdentity(workspace);
    if (identity.projectId !== conflict.projectId) throw new Error('Project identity changed while resolving the conflict.');
    const directory = await record.handle.getDirectoryHandle(identity.projectSlug);
    const provider = await this.dependencies.projectStorage.getProvider(identity.projectSlug);
    const browserBytes = await provider.readFile(projectFilePath(conflict.path));
    const localBytes = await readFileBytes(directory, conflict.path);

    if (resolution === 'browser') {
      if (browserBytes) await writeFileBytes(directory, conflict.path, browserBytes);
      else await removeFile(directory, conflict.path);
    } else if (resolution === 'local') {
      if (localBytes) await provider.writeFile(projectFilePath(conflict.path), localBytes);
      else await provider.deleteFile(projectFilePath(conflict.path));
    } else {
      if (localBytes) {
        const copyPath = conflictCopyPath(conflict.path);
        await provider.writeFile(projectFilePath(copyPath), localBytes);
        await writeFileBytes(directory, copyPath, localBytes);
      }
      if (browserBytes) await writeFileBytes(directory, conflict.path, browserBytes);
      else await removeFile(directory, conflict.path);
    }
    await this.syncProject(identity.projectSlug);
  }

  private async ensureWorkspaceIdentity(workspace: WorkspaceInfo): Promise<BrowserProjectIdentity> {
    let projectId = stringValue(workspace.metadata?.projectId);
    let projectSlug = stringValue(workspace.metadata?.projectSlug);
    let changed = false;
    if (!projectId) { projectId = randomId(); changed = true; }
    const canonical = normalizeProjectSlug(projectSlug ?? workspace.name, { fallback: workspace.name || 'project' });
    if (projectSlug !== canonical) { projectSlug = canonical; changed = true; }
    if (workspace.name !== projectSlug) {
      // Workspace name remains the Browser project path authority. Existing
      // projects are normalized to that current slug without renaming behind
      // the user's back.
      projectSlug = workspace.name;
      changed = true;
    }
    const createdAt = Number(workspace.metadata?.projectCreatedAt) || workspace.lastModified || Date.now();
    if (!workspace.metadata?.projectCreatedAt) changed = true;
    if (changed) {
      await this.dependencies.workspaceOps.updateWorkspaceMetadata(workspace.name, (metadata) => ({
        ...metadata,
        projectId,
        projectSlug,
        projectCreatedAt: createdAt,
      }));
      workspace = (await this.dependencies.workspaceOps.getWorkspaceInfo(workspace.name)) ?? workspace;
    }
    return {
      workspace,
      projectId,
      projectSlug,
      name: stringValue(workspace.metadata?.projectDisplayName) ?? workspace.name,
      projectModeId: stringValue(workspace.metadata?.projectModeId),
      projectProfile: workspace.metadata?.projectProfile,
    };
  }

  private async recoverLocalProject(local: LocalProject): Promise<WorkspaceInfo> {
    const slug = normalizeProjectSlug(local.manifest.projectSlug, { fallback: local.folderName || 'project' });
    if (slug !== local.folderName) {
      throw new Error(`Project folder “${local.folderName}” does not match its project slug “${slug}”.`);
    }
    const existing = await this.dependencies.workspaceOps.getWorkspaceInfo(slug);
    if (existing) return existing;
    const type = supportsOpfsProjectStorage() ? WORKSPACE_STORAGE_TYPE.BrowserOPFS : WORKSPACE_STORAGE_TYPE.Browser;
    const metadata: Record<string, unknown> = {
      product: 'learnitive',
      projectId: local.manifest.projectId,
      projectSlug: slug,
      projectDisplayName: local.manifest.name || slug,
      projectCreatedAt: local.manifest.createdAt,
      recoveredFromProjectsFolder: true,
      ...(local.manifest.projectModeId ? { projectModeId: local.manifest.projectModeId } : {}),
      ...(local.manifest.projectProfile ? { projectProfile: local.manifest.projectProfile } : {}),
    };
    const created = await this.dependencies.workspaceOps.createWorkspaceInfo({ name: slug, type, metadata }, { bypassProjectLimit: true });
    if (!created) throw new Error(`Could not restore Browser project “${slug}”.`);
    const provider = await this.dependencies.projectStorage.getProvider(slug);
    const files = await listDirectoryFiles(local.directory);
    for (const file of files.values()) await provider.writeFile(projectFilePath(file.path), file.bytes);
    if (!local.manifest.projectProfile) {
      const targets = await this.dependencies.projectStorage.detectProjectTargets(slug).catch(() => []);
      if (targets[0]?.profile) await this.dependencies.projectStorage.updateProjectProfile(slug, targets[0].profile);
    }
    return (await this.dependencies.workspaceOps.getWorkspaceInfo(slug)) ?? created;
  }

  private async reconcileProject(
    identity: BrowserProjectIdentity,
    directory: FileSystemDirectoryHandle,
    record: RootHandleRecord,
  ): Promise<MirrorConflict[]> {
    const manifest = projectManifestFor(identity);
    const provider = await this.dependencies.projectStorage.getProvider(identity.projectSlug);
    const browser = await listProviderFiles(provider);
    const local = await listDirectoryFiles(directory);
    const sync = await readSyncState(directory);
    const conflicts: MirrorConflict[] = [];
    const nextFiles: Record<string, SyncFileState> = { ...sync.files };
    const pending = record.pendingDeletes ?? {};
    const deleteState = pending[identity.projectSlug] ?? { files: [], prefixes: [] };
    const pendingFiles = new Set(deleteState.files);
    const pendingPrefixes = deleteState.prefixes;
    const paths = new Set([...Object.keys(sync.files), ...browser.keys(), ...local.keys()]);

    for (const path of [...paths].sort()) {
      const browserFile = browser.get(path);
      const localFile = local.get(path);
      const baseHash = sync.files[path]?.hash;
      const deletionIntent = pendingFiles.has(path) || pendingPrefixes.some((prefix) => path.startsWith(prefix));
      const decision = decideProjectMirrorAction({
        baseHash,
        browserHash: browserFile?.hash,
        localHash: localFile?.hash,
        browserDeletionIntent: deletionIntent,
      });

      if (decision === 'conflict') {
        conflicts.push({
          id: conflictId(identity.projectId, path),
          projectId: identity.projectId,
          projectSlug: identity.projectSlug,
          path,
          baseHash,
          browserHash: browserFile?.hash,
          localHash: localFile?.hash,
          detectedAt: Date.now(),
        });
        continue;
      }

      if (decision === 'browser-to-local' && browserFile) {
        await writeFileBytes(directory, path, browserFile.bytes);
        nextFiles[path] = { hash: browserFile.hash, size: browserFile.size };
      } else if (decision === 'local-to-browser' && localFile) {
        await provider.writeFile(projectFilePath(path), localFile.bytes);
        nextFiles[path] = { hash: localFile.hash, size: localFile.size };
      } else if (decision === 'delete-local') {
        await removeFile(directory, path);
        delete nextFiles[path];
        pendingFiles.delete(path);
      } else if (decision === 'same') {
        const file = browserFile ?? localFile;
        if (file) nextFiles[path] = { hash: file.hash, size: file.size };
        else delete nextFiles[path];
      }
    }

    const remainingPrefixes = pendingPrefixes.filter((prefix) => [...browser.keys()].some((path) => path.startsWith(prefix)));
    if (pendingFiles.size || remainingPrefixes.length) {
      pending[identity.projectSlug] = { files: [...pendingFiles], prefixes: remainingPrefixes };
    } else delete pending[identity.projectSlug];
    record.pendingDeletes = pending;

    await writeProjectManifest(directory, manifest);
    await writeSyncState(directory, {
      formatVersion: FORMAT_VERSION,
      lastCommonRevision: sync.lastCommonRevision + 1,
      updatedAt: Date.now(),
      files: nextFiles,
    });
    return conflicts;
  }
}
