/**
 * Layer 2: Document — Automerge document management with typed schemas.
 *
 * DocumentId format: "{space}:{module}:{collection}" or "{space}:{module}:{collection}:{itemId}"
 *
 * Granularity principle: one document per "unit of collaboration."
 * Binary blobs (PDFs, .splat, images) stay in blob storage with metadata refs in Automerge.
 */

import * as Automerge from '@automerge/automerge';
import { makeChangeMessage } from './change-message';

// ============================================================================
// TYPES
// ============================================================================

/**
 * Document ID — hierarchical, colon-separated.
 * 3-part: space-level collection (e.g. "demo:notes:items")
 * 4-part: item-level doc (e.g. "demo:work:boards:board-1")
 */
export type DocumentId =
  | `${string}:${string}:${string}`
  | `${string}:${string}:${string}:${string}`;

/**
 * Parse a DocumentId into its components.
 */
export interface ParsedDocumentId {
  space: string;
  module: string;
  collection: string;
  itemId?: string;
}

export function parseDocumentId(id: DocumentId): ParsedDocumentId {
  const parts = id.split(':');
  if (parts.length < 3 || parts.length > 4) {
    throw new Error(`Invalid DocumentId: "${id}" — expected 3 or 4 colon-separated parts`);
  }
  return {
    space: parts[0],
    module: parts[1],
    collection: parts[2],
    itemId: parts[3],
  };
}

export function makeDocumentId(
  space: string,
  module: string,
  collection: string,
  itemId?: string
): DocumentId {
  if (itemId) {
    return `${space}:${module}:${collection}:${itemId}` as DocumentId;
  }
  return `${space}:${module}:${collection}` as DocumentId;
}

/**
 * Schema definition for an Automerge document.
 * Each module/collection pair defines a schema with version + initializer.
 */
export interface DocSchema<T> {
  module: string;
  collection: string;
  version: number;
  /** Create the initial document state */
  init: () => T;
  /** Migrate from an older version (called when loaded doc version < schema version) */
  migrate?: (doc: T, fromVersion: number) => T;
}

/**
 * Metadata stored alongside each document.
 */
export interface DocMeta {
  docId: DocumentId;
  module: string;
  collection: string;
  version: number;
  createdAt: number;
  updatedAt: number;
}

// ============================================================================
// DOCUMENT MANAGER
// ============================================================================

/**
 * DocumentManager — manages multiple Automerge documents in memory.
 *
 * Responsibilities:
 * - Open/create documents with typed schemas
 * - Track open documents and their metadata
 * - Apply changes with Automerge.change()
 * - List documents by space/module
 *
 * Does NOT handle persistence (that's Layer 3) or sync (Layer 4).
 */
export class DocumentManager {
  #docs = new Map<DocumentId, Automerge.Doc<any>>();
  #meta = new Map<DocumentId, DocMeta>();
  #schemas = new Map<string, DocSchema<any>>();
  #changeListeners = new Map<DocumentId, Set<(doc: any) => void>>();

  /**
   * Register a schema so documents can be opened with type safety.
   */
  registerSchema<T>(schema: DocSchema<T>): void {
    const key = `${schema.module}:${schema.collection}`;
    this.#schemas.set(key, schema);
  }

  /**
   * Get the registered schema for a module/collection.
   */
  getSchema(module: string, collection: string): DocSchema<any> | undefined {
    return this.#schemas.get(`${module}:${collection}`);
  }

  /**
   * Open (or create) a document. If already open, returns the cached instance.
   * If binary data is provided, loads from that; otherwise creates from schema.init().
   */
  open<T extends Record<string, any>>(
    id: DocumentId,
    schema: DocSchema<T>,
    binary?: Uint8Array
  ): Automerge.Doc<T> {
    // Return cached if already open
    const existing = this.#docs.get(id);
    if (existing) return existing as Automerge.Doc<T>;

    let doc: Automerge.Doc<T>;
    const now = Date.now();

    if (binary) {
      // Load from persisted binary
      doc = Automerge.load<T>(binary);

      // Check if migration is needed
      const meta = this.#meta.get(id);
      if (meta && meta.version < schema.version && schema.migrate) {
        doc = Automerge.change(doc, `Migrate ${id} to v${schema.version}`, (d) => {
          schema.migrate!(d, meta.version);
        });
      }
    } else {
      // Create fresh document from schema
      doc = Automerge.init<T>();
      doc = Automerge.change(doc, `Initialize ${id}`, (d) => {
        Object.assign(d, schema.init());
      });
    }

    this.#docs.set(id, doc);
    this.#meta.set(id, {
      docId: id,
      module: schema.module,
      collection: schema.collection,
      version: schema.version,
      createdAt: now,
      updatedAt: now,
    });

    // Register schema if not already
    this.registerSchema(schema);

    return doc;
  }

  /**
   * Get an already-open document.
   */
  get<T>(id: DocumentId): Automerge.Doc<T> | undefined {
    return this.#docs.get(id) as Automerge.Doc<T> | undefined;
  }

  /**
   * Apply a change to a document (Automerge.change wrapper).
   * Notifies change listeners.
   */
  change<T>(
    id: DocumentId,
    message: string,
    fn: (doc: T) => void
  ): Automerge.Doc<T> {
    const doc = this.#docs.get(id);
    if (!doc) {
      throw new Error(`Document not open: ${id}`);
    }

    const updated = Automerge.change(doc, makeChangeMessage(message), fn as any);
    this.#docs.set(id, updated);

    // Update metadata timestamp
    const meta = this.#meta.get(id);
    if (meta) {
      meta.updatedAt = Date.now();
    }

    // Notify listeners
    this.#notifyChange(id, updated);

    return updated as Automerge.Doc<T>;
  }

  /**
   * Replace a document (e.g. after receiving sync data).
   */
  set<T>(id: DocumentId, doc: Automerge.Doc<T>): void {
    this.#docs.set(id, doc);
    const meta = this.#meta.get(id);
    if (meta) {
      meta.updatedAt = Date.now();
    }
    this.#notifyChange(id, doc);
  }

  /**
   * Close a document — remove from in-memory cache.
   */
  close(id: DocumentId): void {
    this.#docs.delete(id);
    this.#meta.delete(id);
    this.#changeListeners.delete(id);
  }

  /**
   * Get document binary for persistence.
   */
  save(id: DocumentId): Uint8Array | null {
    const doc = this.#docs.get(id);
    if (!doc) return null;
    return Automerge.save(doc);
  }

  /**
   * Get metadata for a document.
   */
  getMeta(id: DocumentId): DocMeta | undefined {
    return this.#meta.get(id);
  }

  /**
   * List all open document IDs for a given space and module.
   */
  list(space: string, module?: string): DocumentId[] {
    const results: DocumentId[] = [];
    for (const [id, meta] of this.#meta) {
      const parsed = parseDocumentId(id);
      if (parsed.space !== space) continue;
      if (module && parsed.module !== module) continue;
      results.push(id);
    }
    return results;
  }

  /**
   * List all open document IDs.
   */
  listAll(): DocumentId[] {
    return Array.from(this.#docs.keys());
  }

  /**
   * Subscribe to changes on a document.
   */
  onChange<T>(id: DocumentId, cb: (doc: Automerge.Doc<T>) => void): () => void {
    let listeners = this.#changeListeners.get(id);
    if (!listeners) {
      listeners = new Set();
      this.#changeListeners.set(id, listeners);
    }
    listeners.add(cb);
    return () => { listeners!.delete(cb); };
  }

  #notifyChange(id: DocumentId, doc: any): void {
    const listeners = this.#changeListeners.get(id);
    if (!listeners) return;
    for (const cb of listeners) {
      try { cb(doc); } catch (e) {
        console.error(`[DocumentManager] Change listener error for ${id}:`, e);
      }
    }
  }
}
