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

import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js";
import type { Endpoint } from "#endpoint/Endpoint.js";
import { ChangeNotificationService } from "#node/integration/ChangeNotificationService.js";
import {
    AsyncObservable,
    BasicObservable,
    EventEmitter,
    ImplementationError,
    InternalError,
    Logger,
    MaybePromise,
    Observable,
    QuietObservable,
    Time,
    type Observer,
} from "@matter/general";
import { ElementTag, EventElement, EventModel, type AttributeElement, type ValueModel } from "@matter/model";
import { NumberedOccurrence, Occurrence, OccurrenceManager, Val } from "@matter/protocol";
import { ClusterId, EventId, Priority } from "@matter/types";
import type { Behavior } from "./Behavior.js";

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

/**
 * Events emitted by a {@link Behavior}.
 */
export class Events extends EventEmitter {
    #endpoint?: Endpoint;
    #behavior?: Behavior.Type;
    #changes?: ChangeNotificationService;

    setContext(endpoint: Endpoint, behavior: Behavior.Type) {
        this.#endpoint = endpoint;
        this.#behavior = behavior;
    }

    /**
     * Emitted when state associated with this behavior is first mutated by a specific interaction.
     */
    declare interactionBegin: Observable<[context?: ValueSupervisor.Session], MaybePromise>;

    /**
     * Emitted when a mutating interaction completes.
     */
    declare interactionEnd: Observable<[context?: ValueSupervisor.Session], MaybePromise>;

    /**
     * Emitted when the state of this behavior changes at the end after all concrete $Changed events were emitted.
     */
    declare stateChanged: Observable<[context?: ValueSupervisor.Session], MaybePromise>;

    static {
        for (const name of ["interactionBegin", "interactionEnd", "stateChanged"]) {
            Object.defineProperty(this.prototype, name, {
                get(this: EventEmitter) {
                    if (this.hasEvent(name, true)) {
                        return this.getEvent(name);
                    }
                    const event = Observable<[context?: ValueSupervisor.Session], MaybePromise>();
                    this.addEvent(name, event);
                    return event;
                },
                enumerable: true,
            });
        }
    }

    get endpoint() {
        return this.#endpoint;
    }

    get behavior() {
        return this.#behavior;
    }

    get changes() {
        if (
            this.#changes === undefined &&
            this.#endpoint !== undefined &&
            this.#endpoint.env.has(ChangeNotificationService)
        ) {
            this.#changes = this.#endpoint.env.get(ChangeNotificationService);
        }
        return this.#changes;
    }

    override toString() {
        return `${this.#endpoint ?? "?"}.${this.#behavior?.id ?? "?"}.events`;
    }
}

export namespace Events {
    export interface Context {}

    /**
     * Generic type for {@link Endpoint#events}.
     */
    export interface Generic<T extends AsyncObservable = AsyncObservable> extends Record<
        string,
        undefined | Record<string, undefined | T>
    > {}
}

/**
 * An event associated with a specific matter element such as an {@link AttributeElement} or {@link EventElement}.
 */
export class ElementEvent<T extends any[] = any[], S extends ValueModel = ValueModel> extends BasicObservable<
    T,
    MaybePromise<void>
> {
    #schema: S;
    #owner: Events;

    constructor(schema: S, owner: Events) {
        super();

        this.#schema = schema;
        this.#owner = owner;
    }

    /**
     * The element that triggers this event.
     */
    get schema() {
        return this.#schema;
    }

    protected get owner() {
        return this.#owner;
    }
}

/**
 * An {@link  ElementEvent} that emits locally.
 *
 * Observers that throw errors will terminate the emitter.
 */
export class OfflineEvent<T extends any[] = any[], S extends ValueModel = ValueModel> extends ElementEvent<T> {
    constructor(schema: S, owner: Events) {
        super(schema, owner);
    }
}

/**
 * An {@link ElementEvent} that transmits to other Matter nodes.
 *
 * Online events emit externally.  Observers run sequentially and if async register as node activity but otherwise have
 * no affect on the emitter.
 */
export class OnlineEvent<T extends any[] = any[], S extends ValueModel = ValueModel> extends ElementEvent<T> {
    readonly isQuieter: boolean = false;
    readonly #baseOccurrence: Omit<Occurrence, "epochTimestamp" | "payload"> | undefined;
    #occurrenceTrigger?: (payload: any) => void;

    protected override handleError(error: Error, observer: Observer) {
        logger.warn(`Error in ${descriptionOf(this, observer)}`, error);
    }

    constructor(schema: S, owner: Events) {
        super(schema, owner);

        // If it is a "real" Matter event, then we connect this event instance with the OccurrenceManager and
        // ChangeNotificationService
        const eventSchema = this.schema as EventModel;
        if (
            this.schema.tag === ElementTag.Event &&
            this.schema.id !== undefined &&
            this.owner.endpoint !== undefined &&
            this.owner.behavior !== undefined &&
            eventSchema.priority !== undefined
        ) {
            this.#baseOccurrence = {
                eventId: EventId(this.schema.id),
                clusterId: ClusterId(this.owner.behavior.schema.id!),
                endpointId: this.owner.endpoint.number,
                priority: EventElement.PriorityId[eventSchema.priority] as unknown as Priority,
            };
            this.#connectWithOccurenceManager();
        }
    }

    #connectWithOccurenceManager() {
        if (this.owner.endpoint === undefined) {
            throw new InternalError("Events initialized with no assigned endpoint");
        }

        // Obtain occurrence manager.  Events on client nodes will not have one
        const occurrenceManager = this.owner.endpoint.env.maybeGet(OccurrenceManager);
        if (occurrenceManager === undefined) {
            return;
        }

        const trigger = (payload?: any) => {
            const occurrence = occurrenceManager.add({
                ...this.#baseOccurrence!,
                epochTimestamp: Time.nowMs,
                payload,
            });

            if (MaybePromise.is(occurrence)) {
                this.owner.endpoint!.env.runtime.add(occurrence.then(this.#broadcast.bind(this)));
            } else {
                this.#broadcast(occurrence);
            }
        };
        this.online.on(trigger as unknown as Observer<T, void>);
        this.#occurrenceTrigger = trigger;
    }

    #broadcast({ number, epochTimestamp: timestamp, priority, payload }: NumberedOccurrence) {
        this.owner.changes?.broadcastEvent(this.owner.endpoint!, this.owner.behavior!, this.schema as EventModel, {
            number,
            timestamp,
            priority,
            payload: payload as Val.Struct | undefined,
        });
    }

    /**
     * An {@link Observable} that emits only those events conveyed between nodes.
     *
     * Normally this is the {@link OnlineEvent}, but in the case of server-side elements that are
     * {@link Quality.quieter} this is {@link quiet}.
     */
    get online(): AsyncObservable<T> {
        return this;
    }

    /**
     * A quieter version of the {@link OnlineEvent}.
     *
     * Throws if the node is not a server or the associated element is not {@link Quality.quieter}.
     *
     * By default emits latest changes once per second but you can reconfigure via {@link QuietObservable} properties
     * and/or trigger emits using {@link QuietObservable.emitNow} and {@link QuietObservable.emitSoon}.
     */
    get quiet(): QuietObservable<T, MaybePromise<void>> {
        throw new ImplementationError(`Matter does not define ${this} with "quieter" (Q) quality`);
    }

    override toString() {
        const base = `${this.owner.toString()}.${this.schema.propertyName}`;
        if (this.schema.tag === ElementTag.Attribute || this.schema.tag === ElementTag.Field) {
            return `${base}$Changed`;
        }
        return base;
    }

    override [Symbol.dispose]() {
        if (this.#occurrenceTrigger) {
            this.online.off(this.#occurrenceTrigger as unknown as Observer<T, void>);
            this.#occurrenceTrigger = undefined;
        }
    }
}

/**
 * An {@link OnlineEvent} for elements marked with {@link Quality#quieter}.
 *
 * Quiet events provide a second observable for {@link online} that implements configurable rate limiting.
 */
export class QuietEvent<T extends any[] = any[], S extends ValueModel = ValueModel> extends OnlineEvent<T, S> {
    override readonly isQuieter = true;

    #quiet: QuietObservable<T, MaybePromise<void>>;

    constructor(schema: S, owner: Events, config?: QuietObservable.Configuration<T>) {
        super(schema, owner);
        this.#quiet = new QuietObservable({
            shouldEmit(...args: T) {
                const [newValue, oldValue] = args;

                return oldValue === null || (newValue === null && oldValue !== newValue) ? "now" : true;
            },

            ...config,

            source: this,
        });
    }

    override get online(): AsyncObservable {
        return this.#quiet;
    }

    override get quiet() {
        return this.#quiet;
    }

    override [Symbol.dispose]() {
        this.#quiet[Symbol.dispose]();
        super[Symbol.dispose]();
    }
}

function descriptionOf(observable: AsyncObservable, observer: Observer) {
    let desc = `${observable} observer`;
    if (observer.name) {
        desc = `${desc} ${observer.name}`;
    }
    return desc;
}
