/**
 * @license
 * Copyright 2022-2026 Matter.js Authors
 * SPDX-License-Identifier: Apache-2.0
 */

import type { Endpoint } from "#endpoint/Endpoint.js";
import { type ServerNode } from "#node/ServerNode.js";
import { IdentityConflictError } from "#node/server/IdentityService.js";
import type { StorageContext } from "@matter/general";
import { ImplementationError, InternalError, Logger } from "@matter/general";
import { ServerEndpointStore } from "./ServerEndpointStore.js";

const NEXT_NUMBER_KEY = "__nextNumber__";

const logger = Logger.get("EndpointStoreService");

/**
 * Manages {@link ServerEndpointStore}s for a {@link ServerNode}.
 */
export class ServerEndpointStores {
    #storage?: StorageContext;
    #allocatedNumbers = new Set<number>();
    #preAllocatedNumbers = new Set<number>();
    #persistedNextNumber?: number;
    #numbersPersisted?: Promise<void>;
    #numbersToPersist?: Array<ServerEndpointStore>;
    #nextNumber = 1;
    #root?: ServerEndpointStore;

    async load(storage: StorageContext) {
        this.#storage = storage;

        // Load next number with excessive validation for the off-chance it somehow gets corrupted
        this.#nextNumber = (await this.#storage.get(NEXT_NUMBER_KEY, 1)) % 0xffff;

        if (this.#nextNumber < 1) {
            this.#nextNumber = 1;
        } else {
            this.#persistedNextNumber = this.#nextNumber;
        }

        // Preload stores so we can access synchronously going forward
        this.#root = new ServerEndpointStore(this.#storage);
        await this.#root.load();

        // Ensure all known numbers are allocated.  This protects them when unknown endpoints initialize before known
        // endpoints and #nextNumber is somehow invalid
        this.#root.visit(({ number }) => {
            if (number !== undefined) {
                this.#preAllocatedNumbers.add(number);
            }
        });
    }

    async erase() {
        const storage = this.#storage;
        if (!storage) {
            return;
        }

        await this.#awaitPendingPersistence();

        this.#storage = undefined;

        await storage.clearAll();

        this.#allocatedNumbers = new Set();
        this.#nextNumber = 1;
        this.#persistedNextNumber = undefined;

        await this.load(storage);
    }

    async close() {
        await this.#awaitPendingPersistence();
    }

    async #awaitPendingPersistence() {
        if (this.#numbersPersisted) {
            await this.#numbersPersisted;
        }
    }

    #premature(what: string): never {
        throw new InternalError(`${what} prior to storage initialization`);
    }

    /**
     * Allocate an endpoint number.
     *
     * Either allocates a new number for a {@link Endpoint} or reserves the endpoint's number.  If the {@link Endpoint}
     * already has a number but it is allocated to a different endpoint it is an error.
     *
     * We must persist the assigned number and next endpoint number.  We are fairly resilient to the small chance that
     * persistence fails so we persist lazily and return synchronously.
     */
    assignNumber(endpoint: Endpoint) {
        const store = this.storeForEndpoint(endpoint);

        if (endpoint.lifecycle.hasNumber) {
            // Reserve number
            if (this.#allocatedNumbers.has(endpoint.number)) {
                if (store.number !== endpoint.number) {
                    throw new IdentityConflictError(
                        `Endpoint ${endpoint.id} number ${endpoint.number} is allocated to another endpoint`,
                    );
                }
                return;
            }
        } else {
            // Allocate number
            const knownNumber = store.number;
            if (knownNumber) {
                if (this.#allocatedNumbers.has(knownNumber)) {
                    logger.warn(`Stored number ${knownNumber} is already allocated to another endpoint, ignoring`);
                } else {
                    this.#preAllocatedNumbers.delete(knownNumber);
                    this.#allocatedNumbers.add(knownNumber);
                    endpoint.number = knownNumber;
                    return;
                }
            }

            const startNumber = this.#nextNumber;

            while (
                this.#nextNumber < 1 ||
                this.#allocatedNumbers.has(this.#nextNumber) ||
                this.#preAllocatedNumbers.has(this.#nextNumber)
            ) {
                this.#nextNumber = (this.#nextNumber + 1) % 0xffff;
                if (this.#nextNumber === startNumber) {
                    throw new ImplementationError(
                        "Cannot add additional endpoints because endpoint numbers are exhausted",
                    );
                }
            }

            const number = this.#nextNumber++;
            endpoint.number = number;
        }

        this.#allocatedNumbers.add(endpoint.number);
        store.number = endpoint.number;
        this.#persistNumber(store);
    }

    /**
     * Obtain the store for a single {@link Endpoint}.
     *
     * These stores are cached internally by ID.
     */
    storeForEndpoint(endpoint: Endpoint): ServerEndpointStore {
        if (endpoint.maybeNumber === 0) {
            if (this.#root === undefined) {
                this.#premature("Root store accessed");
            }
            return this.#root;
        }

        if (!endpoint.owner) {
            throw new InternalError(
                "Endpoint storage inaccessible because endpoint is not a node and is not owned by another endpoint",
            );
        }

        return this.storeForEndpoint(endpoint.owner).childStoreFor(endpoint);
    }

    /**
     * Deactivate the store for a single {@link Endpoint}. This puts the endpoint number back into pre-allocated state
     */
    deactivateStoreForEndpoint(endpoint: Endpoint) {
        if (endpoint.maybeNumber === 0) {
            throw new InternalError("Cannot deactivate root node store");
        }

        if (!this.#allocatedNumbers.has(endpoint.number)) {
            return;
        }
        this.#allocatedNumbers.delete(endpoint.number);
        this.#preAllocatedNumbers.add(endpoint.number);
    }

    /**
     * Erase storage for a single {@link Endpoint}.
     */
    async eraseStoreForEndpoint(endpoint: Endpoint) {
        if (!endpoint.owner) {
            throw new InternalError(
                "Endpoint storage inaccessible because endpoint is not a node and is not owned by another endpoint",
            );
        }

        await this.storeForEndpoint(endpoint.owner).eraseChildStoreFor(endpoint);

        this.#allocatedNumbers.delete(endpoint.number);
        this.#preAllocatedNumbers.delete(endpoint.number);
    }

    /**
     * Lazily persist a newly allocated number and the next number.
     */
    #persistNumber(store: ServerEndpointStore) {
        // If there's already a set of numbers to persist there will be an outstanding promise that will do the work
        // for us
        if (this.#numbersToPersist) {
            this.#numbersToPersist.push(store);
            return;
        }

        this.#numbersToPersist = [store];

        const numberPersister = async () => {
            const numbersToPersist = this.#numbersToPersist;
            if (!numbersToPersist) {
                return;
            }

            this.#numbersToPersist = undefined;
            for (const store of numbersToPersist) {
                await store.saveNumber();
            }

            if (this.#nextNumber !== this.#persistedNextNumber) {
                if (this.#storage === undefined) {
                    this.#premature("Number persistence");
                }
                await this.#storage.set(NEXT_NUMBER_KEY, this.#nextNumber);
                this.#persistedNextNumber = this.#nextNumber;
            }
        };

        // There is a very small chance that there is an outstanding worker that is persisting numbers but hasn't yet
        // completed.  If this is the case then wait our turn.  Otherwise there's an even smaller chance that
        // this.#nextNumber gets persisted in the wrong order
        if (this.#numbersPersisted) {
            this.#numbersPersisted = this.#numbersPersisted.then(numberPersister);
        } else {
            this.#numbersPersisted = numberPersister();
        }
    }
}
