import type { AsanagiDbClient } from './client.js';

export const CLASS_FIELD = '_class';
export const KEY_FIELD   = '_key';
export const ID_FIELD    = '_id';

/**
 * A Compository document — a map-like object that also knows its label ({@link clazz}),
 * key field ({@link _key}), and vertex id ({@link _id}).
 *
 * @example
 * const doc = new Document('Article', '_id', 42n)
 *   .add('title', 'Hello')
 *   .add('body',  'World')
 *   .addChild(new Document('Tag').add('name', 'news'));
 *
 * @example
 * // From a plain object (class name inferred from constructor name)
 * const doc = Document.from(article, 'Article');
 *
 * Documents returned by {@link AsanagiDbClient} are pre-bound so you can call
 * {@link save}, {@link fetch}, {@link update}, {@link delete} directly.
 */
export class Document {
  readonly clazz: string;
  readonly _key: string;
  protected _id?: bigint;

  /** @internal */
  _client?: AsanagiDbClient;

  protected readonly _data = new Map<string, unknown>();

  constructor(className = 'Document', keyField = '_id', id?: bigint | number | string) {
    this.clazz = className;
    this._key  = keyField;
    this._id   = id !== undefined ? BigInt(id) : undefined;
    this.syncInternalFields();
  }

  get id(): bigint | undefined { return this._id; }

  // ── Factory ────────────────────────────────────────────────────────────────

  /**
   * Build a Document from a plain object. Class name is taken from
   * `obj.constructor.name` unless overridden.
   */
  static from<T extends object>(
    obj: T,
    className?: string,
    keyField?: string,
    id?: bigint | number,
  ): Document {
    const inferred = (obj as object).constructor?.name ?? 'Document';
    const doc = new Document(className ?? inferred, keyField, id);
    for (const [key, value] of Object.entries(obj)) {
      doc.set(key, value);
    }
    // Pick up _id from the object itself if not passed explicitly
    (doc as any).extractInternalFields();
    doc.syncInternalFields();
    return doc;
  }

  /** Parse a JSON string into a Document. Nested objects become child Documents. */
  static fromJSON(json: string): Document {
    const parsed = JSON.parse(json) as Record<string, unknown>;
    return Document.fromObject(parsed);
  }

  /** @internal */
  static fromObject(obj: Record<string, unknown>): Document {
    const doc = new Document();
    for (const [key, value] of Object.entries(obj)) {
      (doc as any)._data.set(key, Document.convertValue(value));
    }
    (doc as any).extractInternalFields();
    return doc;
  }

  // ── Map-like interface ─────────────────────────────────────────────────────

  get(key: string): unknown          { return this._data.get(key); }
  set(key: string, value: unknown): this { this._data.set(key, value); return this; }
  has(key: string): boolean          { return this._data.has(key); }
  remove(key: string): boolean       { return this._data.delete(key); }
  get size(): number                 { return this._data.size; }

  [Symbol.iterator](): IterableIterator<[string, unknown]> {
    return this._data[Symbol.iterator]();
  }

  keys():   IterableIterator<string>  { return this._data.keys(); }
  values(): IterableIterator<unknown> { return this._data.values(); }
  entries(): IterableIterator<[string, unknown]> { return this._data.entries(); }

  // ── Builder ────────────────────────────────────────────────────────────────

  /** Flow-style setter. */
  add(key: string, value: unknown): this {
    this._data.set(key, value);
    return this;
  }

  /**
   * Attach a child Document under its {@link clazz} key.
   * First child stored directly; second of same class starts an array.
   */
  addChild(child: Document): this {
    const key = child.clazz;
    const existing = this._data.get(key);
    if (existing === undefined) {
      this._data.set(key, child);
    } else if (Array.isArray(existing)) {
      existing.push(child);
    } else {
      this._data.set(key, [existing, child]);
    }
    return this;
  }

  removeChild(child: Document): this {
    const key = child.clazz;
    const existing = this._data.get(key);
    if (Array.isArray(existing)) {
      const filtered = existing.filter((c) => c !== child);
      this._data.set(key, filtered.length === 1 ? filtered[0] : filtered);
    } else {
      this._data.delete(key);
    }
    return this;
  }

  // ── Typed getters ──────────────────────────────────────────────────────────

  getString(key: string): string | undefined {
    const v = this._data.get(key);
    return v !== undefined ? String(v) : undefined;
  }

  getNumber(key: string): number | undefined {
    const v = this._data.get(key);
    return typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : undefined;
  }

  getBigInt(key: string): bigint | undefined {
    const v = this._data.get(key);
    return typeof v === 'bigint' ? v : typeof v === 'number' ? BigInt(v) : undefined;
  }

  getBoolean(key: string): boolean | undefined {
    const v = this._data.get(key);
    return typeof v === 'boolean' ? v : undefined;
  }

  getDocument(key: string): Document | undefined {
    const v = this._data.get(key);
    return v instanceof Document ? v : undefined;
  }

  getDocuments(key: string): Document[] | undefined {
    const v = this._data.get(key);
    return Array.isArray(v) && v.every((x) => x instanceof Document)
      ? (v as Document[])
      : undefined;
  }

  // ── CRUD ──────────────────────────────────────────────────────────────────

  /** Persist this document. Uses {@link clazz} as the vertex label. */
  async save(): Promise<void> {
    if (!this._client) throw new Error('Document is not bound to a client.');
    await this._client.insertOne(this);
  }

  /**
   * Reload this document from the server.
   * Fetches by {@link id} when set, otherwise by the {@link _key} field value.
   */
  async fetch(): Promise<Document | null> {
    if (!this._client) throw new Error('Document is not bound to a client.');
    if (this._id !== undefined) return this._client.findById(this._id);
    const keyValue = this._data.get(this._key);
    if (keyValue === undefined)
      throw new Error(`Document has neither _id nor a '${this._key}' value — cannot fetch.`);
    return this._client.findByKey(this._key, keyValue);
  }

  /** Replace this document on the server by {@link id}. */
  async update(): Promise<void> {
    if (!this._client) throw new Error('Document is not bound to a client.');
    if (this._id === undefined) throw new Error('Document has no _id — cannot update.');
    await this._client.replaceOne(this._id, this);
  }

  /** Delete this document from the server by {@link id}. */
  async delete(): Promise<void> {
    if (!this._client) throw new Error('Document is not bound to a client.');
    if (this._id === undefined) throw new Error('Document has no _id — cannot delete.');
    await this._client.deleteOne(this._id);
  }

  // ── Serialization ──────────────────────────────────────────────────────────

  /** Return a plain object suitable for JSON serialization (including internal fields). */
  toJSON(): Record<string, unknown> {
    const obj: Record<string, unknown> = {};
    for (const [k, v] of this._data) {
      obj[k] = Document.serializeValue(v);
    }
    obj[CLASS_FIELD] = this.clazz;
    obj[KEY_FIELD]   = this._key;
    if (this._id !== undefined) obj[ID_FIELD] = Number(this._id);
    return obj;
  }

  toString(): string { return JSON.stringify(this.toJSON()); }

  // ── Internal helpers ───────────────────────────────────────────────────────

  protected syncInternalFields(): void {
    this._data.set(CLASS_FIELD, this.clazz);
    this._data.set(KEY_FIELD,   this._key);
    if (this._id !== undefined) this._data.set(ID_FIELD, this._id);
  }

  protected extractInternalFields(): void {
    const cls = this._data.get(CLASS_FIELD);
    if (typeof cls === 'string') (this as any).clazz = cls;
    const key = this._data.get(KEY_FIELD);
    if (typeof key === 'string') (this as any)._key = key;
    const id = this._data.get(ID_FIELD);
    if (typeof id === 'number') this._id = BigInt(id);
    else if (typeof id === 'bigint') this._id = id;
  }

  protected static convertValue(value: unknown): unknown {
    if (value === null || value === undefined) return value;
    if (typeof value === 'object' && !Array.isArray(value)) {
      return Document.fromObject(value as Record<string, unknown>);
    }
    if (Array.isArray(value)) {
      const all = value.every((v) => typeof v === 'object' && v !== null && !Array.isArray(v));
      if (all && value.length > 0) {
        return value.map((v) => Document.fromObject(v as Record<string, unknown>));
      }
      return value.map((v) => Document.convertValue(v));
    }
    return value;
  }

  protected static serializeValue(value: unknown): unknown {
    if (value instanceof Document) return value.toJSON();
    if (Array.isArray(value)) return value.map((v) => Document.serializeValue(v));
    if (typeof value === 'bigint') return Number(value);
    return value;
  }
}

/** Result of an insertOne call. */
export interface InsertResult {
  insertedId?: bigint | number;
}

/** Thrown when the server responds with status !== "ok". */
export class AsanagiDbError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'AsanagiDbError';
  }
}

/** @internal */
export interface WireResponse {
  status: string;
  message: unknown;
}
