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

import { ActionContext } from "#behavior/context/ActionContext.js";
import { NodeLifecycle } from "#node/NodeLifecycle.js";
import { Bytes, deepCopy, InternalError, Logger } from "@matter/general";
import {
    AccessControl,
    AclEndpointContext,
    AclEntry,
    AclList,
    Fabric,
    FabricManager,
    hasLocalActor,
    hasRemoteActor,
    IncomingSubjectDescriptor,
    MessageExchange,
    NodeSession,
    SecureSession,
} from "@matter/protocol";
import {
    CaseAuthenticatedTag,
    ClusterId,
    DeviceTypeId,
    EndpointNumber,
    FabricIndex,
    GroupId,
    NodeId,
    Status,
    StatusResponseError,
    SubjectId,
    TlvTaggedList,
    TlvType,
} from "@matter/types";
import { AccessControl as AccessControlTypes } from "@matter/types/clusters/access-control";
import { AccessControlBehavior } from "./AccessControlBehavior.js";

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

/**
 * This is the default server implementation of AccessControlBehavior.
 *
 * When custom extensions are used, the `extensionEntryValidator` and `extensionEntryAccessCheck` methods can be
 * overridden to implement custom validation and access checks for the extension entries.
 */
export class AccessControlServer extends AccessControlBehavior.with("Extension") {
    declare internal: AccessControlServer.Internal;

    override initialize() {
        // Spec 1.5.1 tightened constraints to "4 to 65534" / "3 to 65534" — ensure valid defaults
        if (!this.state.subjectsPerAccessControlEntry) {
            this.state.subjectsPerAccessControlEntry = 4;
        }
        if (!this.state.targetsPerAccessControlEntry) {
            this.state.targetsPerAccessControlEntry = 4;
        }
        if (!this.state.accessControlEntriesPerFabric) {
            this.state.accessControlEntriesPerFabric = 4;
        }

        this.reactTo(this.events.acl$Changing, this.#validateAccessControlListChanges); // Enhanced Validation
        this.reactTo(this.events.acl$Changed, this.#handleAccessControlListChange); // Event handling for changes
        if (this.state.extension !== undefined) {
            this.maybeReactTo(this.events.extension$Changing, this.#validateAccessControlExtensionChanges); // Enhanced Validation
            this.maybeReactTo(this.events.extension$Changed, this.#handleAccessControlExtensionChange); // Event handling for changes
        }

        const lifecycle = this.endpoint.lifecycle as NodeLifecycle;
        this.reactTo(lifecycle.online, this.#online);
    }

    #online() {
        const aclsForFabric = this.#mapFabricAcls();

        // Initialize the ACL managers for each fabric
        const fabrics = this.env.get(FabricManager);
        for (const fabric of fabrics) {
            const fabricAcls = aclsForFabric.get(fabric.fabricIndex) ?? [];

            if (!fabricAcls.length) {
                // Handle Backward compatibility to Matter.js before 0.9.1 and add the missing ACL entry if no entry was set
                // so far by the controller
                const fallbackAcl: AccessControlTypes.AccessControlEntry = {
                    privilege: AccessControlTypes.AccessControlEntryPrivilege.Administer,
                    authMode: AccessControlTypes.AccessControlEntryAuthMode.Case,
                    subjects: [fabric.rootNodeId],
                    targets: null, // entire node
                    fabricIndex: fabric.fabricIndex,
                };
                this.state.acl.push(fallbackAcl);
                fabricAcls.push(fallbackAcl);
            }
            fabric.accessControl.aclList = fabricAcls;
            fabric.accessControl.extensionEntryAccessCheck = this.extensionEntryAccessCheck.bind(this);
        }

        // TODO handle delete fabric more generically later to remove fabric scoped data
        this.reactTo(fabrics.events.replaced, this.#updateFabricAcls);
        this.reactTo(fabrics.events.added, this.#updateFabricAcls);

        this.reactTo(this.events.interactionBegin, this.#handleInteractionBegin);
        this.reactTo(this.events.interactionEnd, this.#handleInteractionEnd);

        this.reactTo(this.events.acl$Changed, this.#updateAccessControlList);

        this.internal.initialized = true;
    }

    addDefaultCaseAcl(fabric: Fabric, subjects: SubjectId[]) {
        const entry = {
            fabricIndex: fabric.fabricIndex,
            privilege: AccessControlTypes.AccessControlEntryPrivilege.Administer,
            authMode: AccessControlTypes.AccessControlEntryAuthMode.Case,
            subjects: subjects as NodeId[],
            targets: null, // entire node
        };
        this.state.acl.push(entry);
        this.#updateFabricAcls(fabric);
        // The update of the ACL is not validated because it has no assigned sess/fabric (and even then would use
        //  the wrong one), so we trigger the event ourself.
        this.events.accessControlEntryChanged?.emit(
            {
                changeType: AccessControlTypes.ChangeType.Added,
                adminNodeId: null, // When we add it, it is always from a PASE session
                adminPasscodeId: 0, // When we add it, it is always from a PASE session
                latestValue: entry,
                fabricIndex: fabric.fabricIndex,
            },
            this.context,
        );
    }

    #validateAccessControlListChanges(
        value: AccessControlTypes.AccessControlEntry[],
        _oldValue: AccessControlTypes.AccessControlEntry[],
    ) {
        const { context } = this;

        if (!hasRemoteActor(context)) {
            return;
        }

        const relevantFabricIndex = context.session.associatedFabric.fabricIndex;

        if (relevantFabricIndex === undefined) {
            return;
        }
        if (context !== undefined && context.exchange !== undefined) {
            const delayedChangeExchange = this.internal.aclUpdateDelayed.get(relevantFabricIndex);
            if (delayedChangeExchange !== undefined && delayedChangeExchange !== context.exchange) {
                // We are in a delayed ACL update with another exchange, so we do not process this one with Busy error
                // This is formally not in specification, but chip also does this that way
                logger.debug(
                    "Decline parallel ACL changes from multiple exchanges",
                    context.exchange.id,
                    "vs.",
                    delayedChangeExchange.id,
                );
                throw new StatusResponseError("Parallel ACL change from multiple exchanges", Status.Busy);
            }
        }

        const fabricAcls = value.filter(entry => entry.fabricIndex === relevantFabricIndex);
        if (fabricAcls.length > this.state.accessControlEntriesPerFabric) {
            throw new StatusResponseError("AccessControlEntriesPerFabric exceeded", Status.ResourceExhausted);
        }

        for (const entry of fabricAcls) {
            // We need to convert empty arrays to null because expected by chip tests
            if (entry.subjects !== null && entry.subjects.length === 0) {
                entry.subjects = null;
            }
            if (entry.targets !== null && entry.targets.length === 0) {
                entry.targets = null;
            }
            const { privilege, subjects, targets, authMode } = entry;
            if (subjects !== null && subjects.length > this.state.subjectsPerAccessControlEntry) {
                throw new StatusResponseError("SubjectsPerAccessControlEntry exceeded", Status.ResourceExhausted);
            }

            if (targets !== null && targets.length > this.state.targetsPerAccessControlEntry) {
                throw new StatusResponseError("TargetsPerAccessControlEntry exceeded", Status.ResourceExhausted);
            }

            if (authMode === AccessControlTypes.AccessControlEntryAuthMode.Pase) {
                throw new StatusResponseError("AuthMode for ACL must not be PASE", Status.ConstraintError);
            } else if (authMode === AccessControlTypes.AccessControlEntryAuthMode.Case) {
                if (subjects !== null) {
                    for (const subject of subjects) {
                        if (NodeId.isCaseAuthenticatedTag(subject)) {
                            const cat = NodeId.extractAsCaseAuthenticatedTag(subject);
                            if (CaseAuthenticatedTag.getVersion(cat) === 0) {
                                throw new StatusResponseError(
                                    "CaseAuthenticatedTag version 0 is not allowed",
                                    Status.ConstraintError,
                                );
                            }
                        } else if (!NodeId.isOperationalNodeId(subject)) {
                            throw new StatusResponseError(
                                "Subject must be a valid OperationalNodeId or CaseAuthenticatedTag",
                                Status.ConstraintError,
                            );
                        }
                    }
                }
            } else if (authMode === AccessControlTypes.AccessControlEntryAuthMode.Group) {
                if (privilege === AccessControlTypes.AccessControlEntryPrivilege.Administer) {
                    throw new StatusResponseError(
                        "Group ACLs must not have Administer privilege",
                        Status.ConstraintError,
                    );
                }

                if (subjects !== null) {
                    for (const subject of subjects) {
                        if (GroupId(Number(subject)) === GroupId.NO_GROUP_ID) {
                            throw new StatusResponseError(
                                "Subject must be a valid GroupId for Group ACLs",
                                Status.ConstraintError,
                            );
                        }
                    }
                }
                // TODO For Group authentication, the Group ID identifies the required group, as defined in the Group Key Management Cluster.
            }

            if (targets !== null) {
                for (const target of targets) {
                    const { cluster, endpoint, deviceType } = target;
                    if (deviceType !== null && endpoint !== null) {
                        throw new StatusResponseError(
                            "DeviceType and Endpoint are mutually exclusive",
                            Status.ConstraintError,
                        );
                    }
                    if (cluster === null && endpoint === null && deviceType === null) {
                        throw new StatusResponseError("At least one field must be present", Status.ConstraintError);
                    }
                    if (cluster !== null && !ClusterId.isValid(cluster)) {
                        throw new StatusResponseError("Cluster must be a valid ClusterId", Status.ConstraintError);
                    }
                    if (endpoint !== null && !EndpointNumber.isValid(endpoint)) {
                        throw new StatusResponseError(
                            "Endpoint must be a valid OperationalNodeId",
                            Status.ConstraintError,
                        );
                    }
                    if (deviceType !== null && !DeviceTypeId.isValid(deviceType)) {
                        throw new StatusResponseError("DeviceType must be a valid DeviceType", Status.ConstraintError);
                    }
                }
            }
        }
    }

    /**
     * Determine and return the adminPassCodeId and adminNodeId from the node that initiated the ACL change.
     * it is either the root node of the fabric or 0 if the session is undefined or a PASE session.
     */
    #adminDataFromSession(session: SecureSession | undefined) {
        if (session === undefined || (NodeSession.is(session) && session.isPase)) {
            return { adminPasscodeId: 0, adminNodeId: null };
        }
        const adminNodeId = session?.associatedFabric.rootNodeId;
        if (adminNodeId === undefined) {
            throw new InternalError("Admin Node ID is undefined, should never happen");
        }
        return { adminPasscodeId: null, adminNodeId };
    }

    #handleAccessControlListChange(
        value: AccessControlTypes.AccessControlEntry[],
        oldValue: AccessControlTypes.AccessControlEntry[],
    ) {
        if (!this.internal.initialized) {
            return; // Too early to send events
        }

        const session = hasRemoteActor(this.context) ? this.context.session : undefined;

        // TODO: This might be not really correct for local ACL changes because there the session fabric could be
        //  different which would lead to missing events
        const relevantFabricIndex = session?.associatedFabric.fabricIndex;

        if (relevantFabricIndex === undefined || this.events.accessControlEntryChanged === undefined) {
            return;
        }
        const { adminPasscodeId, adminNodeId } = this.#adminDataFromSession(session);
        const fabricAcls = value.filter(entry => entry.fabricIndex === relevantFabricIndex);
        const oldFabricAcls = oldValue.filter(entry => entry.fabricIndex === relevantFabricIndex);

        let i = 0;
        for (; i < fabricAcls.length; i++) {
            const changeType =
                oldFabricAcls[i] === undefined
                    ? AccessControlTypes.ChangeType.Added
                    : fabricAcls[i] === undefined
                      ? AccessControlTypes.ChangeType.Removed
                      : AccessControlTypes.ChangeType.Changed;
            this.events.accessControlEntryChanged.emit(
                {
                    changeType,
                    adminNodeId,
                    adminPasscodeId,
                    latestValue:
                        (changeType === AccessControlTypes.ChangeType.Removed ? oldFabricAcls[i] : fabricAcls[i]) ??
                        null,
                    fabricIndex: relevantFabricIndex,
                },
                this.context,
            );
        }
        if (oldFabricAcls.length > i) {
            for (let j = oldFabricAcls.length - 1; j >= i; j--) {
                this.events.accessControlEntryChanged.emit(
                    {
                        changeType: AccessControlTypes.ChangeType.Removed,
                        adminNodeId,
                        adminPasscodeId,
                        latestValue: oldValue[j],
                        fabricIndex: relevantFabricIndex,
                    },
                    this.context,
                );
            }
        }
    }

    #validateAccessControlExtensionChanges(value: AccessControlTypes.AccessControlExtension[]) {
        if (!hasRemoteActor(this.context)) {
            return;
        }

        const relevantFabricIndex = this.context.session.associatedFabric.fabricIndex;

        const fabricExtensions = value.filter(entry => entry.fabricIndex === relevantFabricIndex);

        if (fabricExtensions.length === 0) {
            return;
        }
        if (fabricExtensions.length > 1) {
            throw new StatusResponseError("Extension list must contain a single entry", Status.ConstraintError);
        }

        // we have exactly one entry
        this.extensionEntryValidator(fabricExtensions[0]);
    }

    #handleAccessControlExtensionChange(
        value: AccessControlTypes.AccessControlExtension[],
        oldValue: AccessControlTypes.AccessControlExtension[],
    ) {
        if (!this.internal.initialized) {
            return; // Too early to send events
        }

        const session = hasRemoteActor(this.context) ? this.context.session : undefined;

        // TODO: This might be not really correct for local ACL changes because there the session fabric could be
        //  different which would lead to missing events of the relevant entries
        const relevantFabricIndex = session?.associatedFabric.fabricIndex;

        if (relevantFabricIndex === undefined || this.events.accessControlExtensionChanged === undefined) {
            return;
        }
        const { adminPasscodeId, adminNodeId } = this.#adminDataFromSession(session);

        const fabricExtensions = value.filter(entry => entry.fabricIndex === relevantFabricIndex);
        const oldFabricExtensions = oldValue.filter(entry => entry.fabricIndex === relevantFabricIndex);

        const changeType =
            fabricExtensions.length > oldFabricExtensions.length
                ? AccessControlTypes.ChangeType.Added
                : fabricExtensions.length < oldFabricExtensions.length
                  ? AccessControlTypes.ChangeType.Removed
                  : AccessControlTypes.ChangeType.Changed;

        this.events.accessControlExtensionChanged.emit(
            {
                changeType,
                adminNodeId,
                adminPasscodeId,
                latestValue:
                    (changeType === AccessControlTypes.ChangeType.Removed
                        ? oldFabricExtensions[0]
                        : fabricExtensions[0]) ?? null,
                fabricIndex: relevantFabricIndex,
            },
            this.context,
        );
    }

    /**
     * This method allows to implement the validation of manufacturer specific ACL extensions when an extension entry is
     * added or changed. The default implementation checks whether the extension is a valid TLV and possible to decode.
     *
     * In case of an Error throws StatusResponseError.
     *
     * Override this method in your own behavior to implement custom validation.
     */
    protected extensionEntryValidator(extension: AccessControlTypes.AccessControlExtension) {
        const { data } = extension;
        const extensionBytes = Bytes.of(data);
        if (
            extensionBytes.length < 2 ||
            extensionBytes[0] !== TlvType.List ||
            extensionBytes[extensionBytes.length - 1] !== TlvType.EndOfContainer
        ) {
            // Easier to check that way that it is an Listen without any tags in general
            throw new StatusResponseError("Extension must be a valid TLV", Status.ConstraintError);
        }
        try {
            TlvTaggedList({} /* No fields, sufficient for validation */, true).decode(data);
        } catch (error) {
            logger.debug(`Extension TLV decoding failed:`, error);
            throw new StatusResponseError("Extension must be a valid TLV", Status.ConstraintError);
        }
    }

    /**
     * This method allows to implement the validation of manufacturer specific ACL extensions when access control is
     * checked to decide if access is allowed or not.
     * The default implementation always returns true. Override this method in your own behavior to implement custom
     * validation.
     */
    protected extensionEntryAccessCheck(
        _aclList: AclList,
        _aclEntry: AclEntry,
        _subjectDesc: IncomingSubjectDescriptor,
        _endpoint: AclEndpointContext,
        _clusterId: ClusterId,
    ) {
        return true;
    }

    /** A fabric was added or updated, so we need to initialize the ACL for this fabric */
    #updateFabricAcls(fabric: Fabric) {
        const fabricIndex = fabric.fabricIndex;
        fabric.accessControl.aclList = deepCopy(this.state.acl).filter(entry => entry.fabricIndex === fabricIndex);
    }

    /**
     * When beginning an interaction for an online session, we register the potential ACL change for the associated
     * fabric index. If ACL data are really changed later, the exchange gets added then.
     */
    #handleInteractionBegin(session?: AccessControl.Session) {
        if (hasRemoteActor(session)) {
            this.#prepareAclUpdateFor(session.fabric);
        }
    }

    /**
     * When an interaction is finished, we check if there was a delayed ACL update for the associated fabric and apply
     * it to the manager. For this we check if we have an exchange stored because otherwise the interaction was in fact
     * not changing the ACL.
     */
    #handleInteractionEnd(session?: AccessControl.Session) {
        if (hasRemoteActor(session)) {
            if (this.internal.aclUpdateDelayed.get(session.fabric) !== undefined) {
                this.#applyDelayedAclUpdateFor(session.fabric);
            }
        }
    }

    /** The ACL list was changed, so we need to determine if and when to apply the update to the ACL manager */
    #updateAccessControlList(
        acl: AccessControlTypes.AccessControlEntry[],
        _oldAcl: AccessControlTypes.AccessControlEntry[],
        context?: ActionContext,
    ) {
        if (hasLocalActor(context)) {
            // local or offline ACL change, so we update all fabrics because we do not know better
            this.#updateAllFabricsAcls();
        } else {
            const fabric = context.session?.associatedFabric;
            if (fabric === undefined || fabric.fabricIndex === undefined || context.exchange === undefined) {
                throw new InternalError("We require a fabric bound online session to write ACL changes");
            }
            this.#handleFabricAclUpdate(fabric, acl, context.exchange);
        }
    }

    /**
     * Handles the ACL update for a specific fabric. If an exchange is present, we delay the update until the
     * interaction is finished.
     */
    #handleFabricAclUpdate(fabric: Fabric, acl: AccessControlTypes.AccessControlEntry[], exchange: MessageExchange) {
        const fabricIndex = fabric.fabricIndex;
        if (this.internal.aclUpdateDelayed.has(fabricIndex)) {
            // We have registered an interaction for this fabric, so we delay the update
            logger.debug(
                "ACL attribute updated, but interaction still in progress, delaying update of ACL manager for FabricIndex",
                fabricIndex,
            );
            this.#delayAclUpdateFor(fabricIndex, exchange, acl);
        } else {
            // No interaction registered, so we apply directly because local/offline change
            logger.debug("ACL attribute updated, applying update to ACL manager", fabricIndex);

            fabric.accessControl.aclList = deepCopy(acl).filter(entry => entry.fabricIndex === fabricIndex);
        }
    }

    #mapFabricAcls() {
        const acl = deepCopy(this.state.acl);
        const aclsForFabric = new Map<FabricIndex, AccessControlTypes.AccessControlEntry[]>();
        // Collect ACLs for each fabric
        for (const entry of acl) {
            const { fabricIndex } = entry;
            const acls = aclsForFabric.get(fabricIndex) ?? [];
            acls.push(entry);
            aclsForFabric.set(fabricIndex, acls);
        }
        return aclsForFabric;
    }

    /** Update all fabrics with the current ACL list */
    #updateAllFabricsAcls() {
        const aclsForFabric = this.#mapFabricAcls();

        const fabrics = this.env.get(FabricManager);
        for (const fabric of fabrics) {
            // Update all Fabrics and set the ACL list for each fabric, empty ACLs when none are present
            fabric.accessControl.aclList = aclsForFabric.get(fabric.fabricIndex) ?? [];
        }
    }

    /**
     * Register a potential change of ACL for a specific fabric index. if changes happened is checked when interaction
     * ends.
     */
    #prepareAclUpdateFor(fabricIndex: FabricIndex) {
        if (!this.internal.aclUpdateDelayed.has(fabricIndex)) {
            logger.info("Register ACL update to be delayed for fabricIndex", fabricIndex);
            this.internal.aclUpdateDelayed.set(fabricIndex, undefined);
        }
    }

    /**
     * Register a concrete change of ACL for a specific fabric index. The exchange allows to also limit ACL changes to
     * that exchange until interaction is finished.
     */
    #delayAclUpdateFor(
        fabricIndex: FabricIndex,
        exchange: MessageExchange,
        acl: AccessControlTypes.AccessControlEntry[],
    ) {
        if (!this.internal.aclUpdateDelayed.has(fabricIndex)) {
            logger.info("Register ACL update to be delayed for fabricIndex", fabricIndex);
        }
        this.internal.aclUpdateDelayed.set(fabricIndex, exchange);
        this.internal.delayedAclData.set(
            fabricIndex,
            deepCopy(acl).filter(entry => entry.fabricIndex === fabricIndex),
        );
    }

    /** Applies the delayed ACL update for a specific fabric index, if existing */
    #applyDelayedAclUpdateFor(fabricIndex: FabricIndex) {
        const updateDelayed = !!this.internal.aclUpdateDelayed.get(fabricIndex);
        const delayedData = this.internal.delayedAclData.get(fabricIndex);

        this.internal.delayedAclData.delete(fabricIndex);
        this.internal.aclUpdateDelayed.delete(fabricIndex);
        if (updateDelayed && delayedData !== undefined) {
            this.env.get(FabricManager).for(fabricIndex).accessControl.aclList = delayedData;
        }
    }
}

export namespace AccessControlServer {
    export class Internal {
        /** Is the cluster logic initialized? Used to block events before full initialization. */
        initialized = false;

        /**
         * When an online and potentially chunked ACL writing happens, we will delay the update and store the exchange
         * used for the writing. With this we also verify that concurrent writes are blocked and will not mix the data.
         */
        aclUpdateDelayed = new Map<FabricIndex, MessageExchange | undefined>();

        /** Latest delayed data of acl attribute */
        delayedAclData = new Map<FabricIndex, AccessControlTypes.AccessControlEntry[]>();
    }

    export declare const ExtensionInterface: {
        extensionEntryValidator: (extension: AccessControlTypes.AccessControlExtension) => void;
        extensionEntryAccessCheck: (
            aclList: AclList,
            aclEntry: AclEntry,
            subjectDesc: IncomingSubjectDescriptor,
            endpoint: AclEndpointContext,
            clusterId: ClusterId,
        ) => boolean;
    };
}
