import * as operations from '../../json-crdt-patch/operations';
import * as clock from '../../json-crdt-patch/clock';
import { encoder, decoder } from '../codec/structural/binary/shared';
import { ModelApi } from './api';
import { ORIGIN } from '../../json-crdt-patch/constants';
import { randomSessionId } from './util';
import { RootNode, ValNode, VecNode, ObjNode, StrNode, BinNode, ArrNode } from '../nodes';
import { ConNode } from '../nodes/const/ConNode';
import { printTree } from 'tree-dump/lib/printTree';
import { Extensions } from '../extensions/Extensions';
import { AvlMap } from 'sonic-forest/lib/avl/AvlMap';
import { NodeBuilder, s } from '../../json-crdt-patch';
export const UNDEFINED = new ConNode(ORIGIN, undefined);
/**
 * In instance of Model class represents the underlying data structure,
 * i.e. model, of the JSON CRDT document.
 */
export class Model {
    /**
     * Generates a random session ID. Use this method to generate a session ID
     * for a new user. Store the session ID in the user's browser or device once
     * and reuse it for all editing sessions of that user.
     *
     * Generating a new session ID for each editing session will work, however,
     * that is not recommended. If a user generates a new session ID for each
     * editing session, the session clock table will grow indefinitely.
     */
    static sid = randomSessionId;
    /**
     * Use this method to generate a random session ID for an existing document.
     * It checks for the uniqueness of the session ID given the current peers in
     * the document. This reduces the chance of collision substantially.
     *
     * @returns A random session ID that is not used by any peer in the current
     *     document.
     */
    rndSid() {
        const clock = this.clock;
        const sid = clock.sid;
        const peers = clock.peers;
        while (true) {
            const candidate = randomSessionId();
            if (sid !== candidate && !peers.has(candidate))
                return candidate;
        }
    }
    /**
     * Create a new JSON CRDT model. If a schema is provided, the model is
     * strictly typed and the default value of the model is set to the default
     * value of the schema.
     *
     * By default, the model is created with a random session ID and is using
     * a logical clock. It is also possible to create a model which uses a server
     * clock by providing the session ID `SESSION.SERVER` (1).
     *
     * ### Examples
     *
     * Create a basic model, without schema and default value:
     *
     * ```ts
     * const model = Model.create();
     * ```
     *
     * Create a strictly typed model with a schema and default value:
     *
     * ```ts
     * const schema = s.obj({
     *   ticker: s.con<string>('BODEN'),
     *   name: s.str('Jeo Boden'),
     *   tags: s.arr(
     *     s.str('token'),
     *   ),
     * });
     * const model = Model.create(schema);
     * const patch = model.api.flush();
     * ```
     *
     * Create a model with a custom session ID for your logical clock:
     *
     * ```ts
     * const schema = s.str('');
     * const sid = 123456789;
     * const model = Model.create(schema, sid);
     * const patch = model.api.flush();
     * ```
     *
     * The session ID must be at least 65,536 or higher, [see JSON CRDT Patch
     * specification][json-crdt-patch].
     *
     * [json-crdt-patch]: https://jsonjoy.com/specs/json-crdt-patch/patch-document/logical-clock
     *
     * To create a model with a server clock, use the `SESSION.SERVER`, which is
     * equal to 1:
     *
     * ```ts
     * const model = Model.create(undefined, SESSION.SERVER);
     * // or
     * const model = Model.create(undefined, 1);
     * ```
     *
     * Finally, you can create a model with your clock vector:
     *
     * ```ts
     * const clock = new ClockVector(123456789, 1);
     * const model = Model.create(undefined, clock);
     * ```
     *
     * @param schema The schema (typing and default value) to set for this model.
     *     When a schema is provided, the model is strictly typed and the default
     *     value of the model is set to the value of the schema. Also, you MUST
     *     call `model.api.flush()` immediately after creating the model to clear
     *     the change buffer of the patch that was created during the initialization
     *     of the model.
     * @param sidOrClock Session ID to use for local operations. Defaults to a random
     *        session ID generated by {@link Model.sid}.
     * @returns A strictly typed model.
     */
    static create = (schema, sidOrClock = Model.sid()) => {
        const cl = typeof sidOrClock === 'number'
            ? sidOrClock === 1 /* SESSION.SERVER */
                ? new clock.ServerClockVector(1 /* SESSION.SERVER */, 1)
                : new clock.ClockVector(sidOrClock, 1)
            : sidOrClock;
        const model = new Model(cl);
        if (schema !== void 0)
            model.setSchema(schema instanceof NodeBuilder ? schema : s.json(schema), true);
        return model;
    };
    /**
     * Create a CRDT model which uses server clock. In this model a central server
     * timestamps each operation with a sequence number. Each timestamp consists
     * simply of a sequence number, which was assigned by a server. In this model
     * all operations are approved, persisted and re-distributed to all clients by
     * a central server. The session ID part of the clock is the same for all
     * clients, i.e. {@link SESSION.SERVER}.
     *
     * @param time Latest known server sequence number.
     * @returns CRDT model.
     */
    static withServerClock = (schema, time = 1) => Model.create(schema, new clock.ServerClockVector(1 /* SESSION.SERVER */, time));
    /**
     * Decodes a model from a "binary" structural encoding.
     *
     * Use {@link Model.load} instead, if you want to set the session ID of the
     * model and the right schema for the model, during the de-serialization.
     *
     * @param data Binary blob of a model encoded using "binary" structural
     *        encoding.
     * @returns An instance of a model.
     */
    static fromBinary = (data) => {
        return decoder.decode(data);
    };
    /**
     * Un-serializes a model from "binary" structural encoding. The session ID of
     * the model is set to the provided session ID `sid`, or the default session
     * ID of the un-serialized model is used.
     *
     * @param data Binary blob of a model encoded using "binary" structural
     *        encoding.
     * @param sid Session ID to set for the model.
     * @returns An instance of a model.
     */
    static load = (data, sid, schema) => {
        const model = decoder.decode(data);
        if (schema)
            model.setSchema(schema, true);
        if (typeof sid === 'number')
            model.setSid(sid);
        return model;
    };
    /**
     * Instantiates a model from a collection of patches. The patches are applied
     * to the model in the order they are provided. The session ID of the model is
     * set to the session ID of the first patch.
     *
     * @param patches A collection of initial patches to apply to the model.
     * @returns A model with the patches applied.
     */
    static fromPatches(patches) {
        const length = patches.length;
        if (!length)
            throw new Error('NO_PATCHES');
        const first = patches[0];
        const sid = first.getId().sid;
        if (!sid)
            throw new Error('NO_SID');
        const model = Model.create(void 0, sid);
        model.applyBatch(patches);
        return model;
    }
    /**
     * Root of the JSON document is implemented as Last Write Wins Register,
     * so that the JSON document does not necessarily need to be an object. The
     * JSON document can be any JSON value.
     */
    root = new RootNode(this, ORIGIN);
    /**
     * Clock that keeps track of logical timestamps of the current editing session
     * and logical clocks of all known peers.
     */
    clock;
    /**
     * Index of all known node objects (objects, array, strings, values)
     * in this document.
     *
     * @ignore
     */
    index = new AvlMap(clock.compare);
    /**
     * Extensions to the JSON CRDT protocol. Extensions are used to implement
     * custom data types on top of the JSON CRDT protocol.
     *
     * @ignore
     * @todo Allow this to be `undefined`.
     */
    ext = new Extensions();
    constructor(clockVector) {
        this.clock = clockVector;
        if (!clockVector.time)
            clockVector.time = 1;
    }
    /** @ignore */
    _api;
    /**
     * API for applying local changes to the current document.
     */
    get api() {
        if (!this._api)
            this._api = new ModelApi(this);
        return this._api;
    }
    /**
     * Experimental node retrieval API using proxy objects. Returns a strictly
     * typed proxy wrapper around the value of the root node.
     */
    get s() {
        return this.api.s._;
    }
    /**
     * Experimental strictly typed node retrieval API using proxy objects.
     * Automatically resolves nested "val" nodes.
     */
    get $() {
        return this.api.$;
    }
    /**
     * Tracks number of times the `applyPatch` was called.
     *
     * @ignore
     */
    tick = 0;
    /**
     * Applies a batch of patches to the document.
     *
     * @param patches A batch, i.e. an array of patches.
     */
    applyBatch(patches) {
        const length = patches.length;
        for (let i = 0; i < length; i++)
            this.applyPatch(patches[i]);
    }
    /**
     * Callback called before every `applyPatch` call.
     */
    onbeforepatch = undefined;
    /**
     * Callback called after every `applyPatch` call.
     */
    onpatch = undefined;
    /**
     * Works like `applyPatch`, but is intended to be used by the local client
     * for locally generated patches. It checks if the model clock is ahead of
     * the patch clock and rebases the patch if necessary.
     *
     * @param patch A patch to apply to the document.
     */
    applyLocalPatch(patch) {
        const id = patch.getId();
        if (id) {
            const clock = this.clock;
            if (clock.sid === id.sid) {
                const time = clock.time;
                if (time > id.time)
                    patch = patch.rebase(time);
            }
        }
        this.applyPatch(patch);
    }
    /**
     * Applies a single patch to the document. All mutations to the model must go
     * through this method. (With the only exception of local changes through API,
     * which have an alternative path.)
     *
     * @param patch A patch to apply to the document.
     */
    applyPatch(patch) {
        this.onbeforepatch?.(patch);
        const ops = patch.ops;
        const { length } = ops;
        for (let i = 0; i < length; i++)
            this.applyOperation(ops[i]);
        this.tick++;
        this.onpatch?.(patch);
    }
    /**
     * Applies a single operation to the model. All mutations to the model must go
     * through this method.
     *
     * For advanced use only, better use `applyPatch` instead. You MUST increment
     * the `tick` property and call the necessary event emitters manually.
     *
     * @param op Any JSON CRDT Patch operation
     * @ignore
     * @internal
     */
    applyOperation(op) {
        this.clock.observe(op.id, op.span());
        const index = this.index;
        if (op instanceof operations.InsStrOp) {
            const node = index.get(op.obj);
            if (node instanceof StrNode)
                node.ins(op.ref, op.id, op.data);
        }
        else if (op instanceof operations.NewObjOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new ObjNode(this, id));
        }
        else if (op instanceof operations.NewArrOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new ArrNode(this, id));
        }
        else if (op instanceof operations.NewStrOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new StrNode(id));
        }
        else if (op instanceof operations.NewValOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new ValNode(this, id, ORIGIN));
        }
        else if (op instanceof operations.NewConOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new ConNode(id, op.val));
        }
        else if (op instanceof operations.InsObjOp) {
            const node = index.get(op.obj);
            const tuples = op.data;
            const length = tuples.length;
            if (node instanceof ObjNode) {
                for (let i = 0; i < length; i++) {
                    const tuple = tuples[i];
                    const valueNode = index.get(tuple[1]);
                    if (!valueNode)
                        continue;
                    if (node.id.time >= tuple[1].time)
                        continue;
                    const old = node.put(tuple[0] + '', valueNode.id);
                    if (old)
                        this._gcTree(old);
                }
            }
        }
        else if (op instanceof operations.InsVecOp) {
            const node = index.get(op.obj);
            const tuples = op.data;
            const length = tuples.length;
            if (node instanceof VecNode) {
                for (let i = 0; i < length; i++) {
                    const tuple = tuples[i];
                    const valueNode = index.get(tuple[1]);
                    if (!valueNode)
                        continue;
                    if (node.id.time >= tuple[1].time)
                        continue;
                    const old = node.put(Number(tuple[0]), valueNode.id);
                    if (old)
                        this._gcTree(old);
                }
            }
        }
        else if (op instanceof operations.InsValOp) {
            const obj = op.obj;
            const node = obj.sid === 0 /* SESSION.SYSTEM */ && obj.time === 0 /* SYSTEM_SESSION_TIME.ORIGIN */ ? this.root : index.get(obj);
            if (node instanceof ValNode) {
                const newValue = index.get(op.val);
                if (newValue) {
                    const old = node.set(op.val);
                    if (old)
                        this._gcTree(old);
                }
            }
        }
        else if (op instanceof operations.InsArrOp) {
            const node = index.get(op.obj);
            if (node instanceof ArrNode) {
                const nodes = [];
                const data = op.data;
                const length = data.length;
                for (let i = 0; i < length; i++) {
                    const stamp = data[i];
                    const valueNode = index.get(stamp);
                    if (!valueNode)
                        continue;
                    if (node.id.time >= stamp.time)
                        continue;
                    nodes.push(stamp);
                }
                if (nodes.length)
                    node.ins(op.ref, op.id, nodes);
            }
        }
        else if (op instanceof operations.UpdArrOp) {
            const node = index.get(op.obj);
            if (node instanceof ArrNode) {
                const val = op.val;
                if (index.get(val)) {
                    const old = node.upd(op.ref, val);
                    if (old)
                        this._gcTree(old);
                }
            }
        }
        else if (op instanceof operations.DelOp) {
            const node = index.get(op.obj);
            if (node instanceof ArrNode) {
                const length = op.what.length;
                for (let i = 0; i < length; i++) {
                    const span = op.what[i];
                    for (let j = 0; j < span.span; j++) {
                        const id = node.getById(new clock.Timestamp(span.sid, span.time + j));
                        if (id)
                            this._gcTree(id);
                    }
                }
                node.delete(op.what);
            }
            else if (node instanceof StrNode)
                node.delete(op.what);
            else if (node instanceof BinNode)
                node.delete(op.what);
        }
        else if (op instanceof operations.NewBinOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new BinNode(id));
        }
        else if (op instanceof operations.InsBinOp) {
            const node = index.get(op.obj);
            if (node instanceof BinNode)
                node.ins(op.ref, op.id, op.data);
        }
        else if (op instanceof operations.NewVecOp) {
            const id = op.id;
            if (!index.get(id))
                index.set(id, new VecNode(this, id));
        }
    }
    /**
     * Recursively deletes a tree of nodes. Used when root node is overwritten or
     * when object contents of container node (object or array) is removed.
     *
     * @ignore
     */
    _gcTree(value) {
        const isSystemNode = value.sid === 0 /* SESSION.SYSTEM */;
        if (isSystemNode)
            return;
        const node = this.index.get(value);
        if (!node)
            return;
        const api = node.api;
        if (api)
            api.events.handleDelete();
        node.children((child) => this._gcTree(child.id));
        this.index.del(value);
    }
    /**
     * Creates a copy of this model with a new session ID. If the session ID is
     * not provided, a random session ID is generated.
     *
     * @param sessionId Session ID to use for the new model.
     * @returns A copy of this model with a new session ID.
     */
    fork(sessionId = this.rndSid()) {
        const copy = Model.fromBinary(this.toBinary());
        if (copy.clock.sid !== sessionId && copy.clock instanceof clock.ClockVector)
            copy.clock = copy.clock.fork(sessionId);
        copy.ext = this.ext;
        return copy;
    }
    /**
     * Creates a copy of this model with the same session ID.
     *
     * @returns A copy of this model with the same session ID.
     */
    clone() {
        return this.fork(this.clock.sid);
    }
    /**
     * Callback called before model isi reset using the `.reset()` method.
     */
    onbeforereset = undefined;
    /**
     * Callback called after model has been reset using the `.reset()` method.
     */
    onreset = undefined;
    /**
     * Resets the model to equivalent state of another model.
     */
    reset(to) {
        this.onbeforereset?.();
        const index = this.index;
        this.index = new AvlMap(clock.compare);
        const blob = to.toBinary();
        decoder.decode(blob, this);
        this.clock = to.clock.clone();
        this.ext = to.ext.clone();
        const api = this._api;
        if (api) {
            api.flush();
            api.builder.clock = this.clock;
            api.node = this.root;
        }
        index.forEach(({ v: node }) => {
            const api = node.api;
            if (!api)
                return;
            const newNode = this.index.get(node.id);
            if (!newNode) {
                api.events.handleDelete();
                return;
            }
            api.node = newNode;
            newNode.api = api;
        });
        this.tick++;
        this.onreset?.();
    }
    /**
     * Returns the view of the model.
     *
     * @returns JSON/CBOR of the model.
     */
    view() {
        return this.root.view();
    }
    /**
     * Serialize this model using "binary" structural encoding.
     *
     * @returns This model encoded in octets.
     */
    toBinary() {
        return encoder.encode(this);
    }
    /**
     * Strictly types the model and sets the default value of the model, if
     * the document is empty.
     *
     * @param schema The schema to set for this model.
     * @param sid Session ID to use for setting the default value of the document.
     *            Defaults to `SESSION.GLOBAL` (2), which is the default session ID
     *            for all operations operations that are not attributed to a specific
     *            session.
     * @returns Strictly typed model.
     */
    setSchema(schema, useGlobalSession = true) {
        const c = this.clock;
        const isNewDocument = c.time === 1;
        if (isNewDocument) {
            const oldSid = c.sid;
            if (useGlobalSession)
                c.sid = 2 /* SESSION.GLOBAL */;
            this.api.set(schema);
            if (useGlobalSession)
                this.setSid(oldSid);
        }
        return this;
    }
    /**
     * Changes the session ID of the model. By modifying the attached clock vector
     * of the model. Be careful when changing the session ID of the model, as this
     * is an advanced operation.
     *
     * Use the {@link Model.load} method to load a model with the the right session
     * ID, instead of changing the session ID of the model. When in doubt, use the
     * {@link Model.fork} method to create a new model with the right session ID.
     *
     * @param sid The new session ID to set for the model.
     */
    setSid(sid) {
        const cl = this.clock;
        const oldSid = cl.sid;
        if (oldSid !== sid) {
            cl.sid = sid;
            cl.observe(new clock.Timestamp(oldSid, cl.time - 1), 1);
        }
    }
    // ---------------------------------------------------------------- Printable
    toString(tab = '') {
        const nl = () => '';
        const hasExtensions = this.ext.size() > 0;
        return ('model' +
            printTree(tab, [
                (tab) => this.root.toString(tab),
                nl,
                (tab) => {
                    const nodes = [];
                    this.index.forEach((item) => nodes.push(item.v));
                    return (`index (${nodes.length} nodes)` +
                        (nodes.length
                            ? printTree(tab, nodes.map((node) => (tab) => `${node.name()} ${clock.printTs(node.id)}`))
                            : ''));
                },
                nl,
                (tab) => `view${printTree(tab, [(tab) => String(JSON.stringify(this.view(), null, 2)).replace(/\n/g, '\n' + tab)])}`,
                nl,
                (tab) => this.clock.toString(tab),
                hasExtensions ? nl : null,
                hasExtensions ? (tab) => this.ext.toString(tab) : null,
            ]));
    }
}
//# sourceMappingURL=Model.js.map