/*
 * Copyright (c) 2010-2026 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.openhab.binding.homeassistant.internal;

import java.lang.ref.WeakReference;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.homeassistant.internal.component.AbstractComponent;
import org.openhab.binding.homeassistant.internal.component.ComponentFactory;
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
import org.openhab.binding.homeassistant.internal.exception.UnsupportedComponentException;
import org.openhab.binding.mqtt.generic.AvailabilityTracker;
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.binding.mqtt.generic.utils.FutureCollector;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;

/**
 * Responsible for subscribing to the HomeAssistant MQTT components wildcard topic, either
 * in a time limited discovery mode or as a background discovery.
 *
 * @author David Graeff - Initial contribution
 */
@NonNullByDefault
public class DiscoverComponents implements MqttMessageSubscriber {
    private final Logger logger = LoggerFactory.getLogger(DiscoverComponents.class);
    private final ThingUID thingUID;
    private final ScheduledExecutorService scheduler;
    private final ChannelStateUpdateListener updateListener;
    private final HomeAssistantChannelLinkageChecker linkageChecker;
    private final AvailabilityTracker tracker;

    protected final CompletableFuture<@Nullable Void> discoverFinishedFuture = new CompletableFuture<>();
    private final Gson gson;
    private final HomeAssistantPythonBridge python;
    private final UnitProvider unitProvider;

    private @Nullable ScheduledFuture<?> stopDiscoveryFuture;
    private WeakReference<@Nullable MqttBrokerConnection> connectionRef = new WeakReference<>(null);
    protected @Nullable ComponentDiscovered discoveredListener;
    private int discoverTime;
    private Set<String> topics = new HashSet<>();
    private final Set<HaID> knownDeviceComponents = new HashSet<>();

    /**
     * Implement this to get notified of new components
     */
    public static interface ComponentDiscovered {
        void componentDiscovered(HaID homeAssistantTopicID, AbstractComponent<?> component);

        void componentRemoved(HaID homeAssistantTopicID);

        void deviceConfigUpdated(HaID homeAssistantTopicID, String configPayload);
    }

    /**
     * Create a new discovery object.
     *
     * @param thingUID The Thing UID to perform the discovery for.
     * @param scheduler A scheduler for timeouts
     * @param channelStateUpdateListener Channel update listener. Usually the handler.
     */
    public DiscoverComponents(ThingUID thingUID, ScheduledExecutorService scheduler,
            ChannelStateUpdateListener channelStateUpdateListener, HomeAssistantChannelLinkageChecker linkageChecker,
            AvailabilityTracker tracker, Gson gson, HomeAssistantPythonBridge python, UnitProvider unitProvider) {
        this.thingUID = thingUID;
        this.scheduler = scheduler;
        this.updateListener = channelStateUpdateListener;
        this.linkageChecker = linkageChecker;
        this.gson = gson;
        this.python = python;
        this.unitProvider = unitProvider;
        this.tracker = tracker;
    }

    @Override
    public synchronized void processMessage(String topic, byte[] payload) {
        if (!topic.endsWith("/config")) {
            return;
        }

        HaID haID = new HaID(topic);
        String config = new String(payload);
        ComponentDiscovered discoveredListener = this.discoveredListener;
        if (config.length() > 0) {
            try {
                List<MqttComponentConfig> parsedComponentConfigs = python.processDiscoveryConfig(haID.toShortTopic(),
                        config);
                boolean migrationMessage = parsedComponentConfigs.stream()
                        .anyMatch(MqttComponentConfig::isMigrateDiscovery);
                if (migrationMessage) {
                    // Just treat a migration message as the component disappearing -
                    // openHAB doesn't destroy Items when a Channel disappears, so we
                    // don't need to worry about keeping a sentinel around for components
                    // that are about to show up again under a device component.
                    if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
                        if (discoveredListener != null) {
                            knownDeviceComponents.forEach(discoveredListener::componentRemoved);
                        }
                        knownDeviceComponents.clear();
                    } else if (discoveredListener != null) {
                        discoveredListener.componentRemoved(haID);
                    }
                    return;
                }

                List<AbstractComponent<?>> components = ComponentFactory.createComponent(thingUID, haID, config,
                        parsedComponentConfigs, updateListener, linkageChecker, tracker, scheduler, gson, python,
                        unitProvider);
                components.forEach(component -> component.setConfigSeen());

                logger.trace("Found Home Assistant component {}", haID);

                if (discoveredListener != null) {
                    if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
                        discoveredListener.deviceConfigUpdated(haID, config);
                        Set<HaID> currentComponents = components.stream().map(AbstractComponent::getHaID)
                                .collect(Collectors.toSet());
                        knownDeviceComponents.stream().filter(component -> !currentComponents.contains(component))
                                .forEach(discoveredListener::componentRemoved);
                        components.stream().filter(component -> !knownDeviceComponents.contains(component.getHaID()))
                                .forEach(component -> discoveredListener.componentDiscovered(component.getHaID(),
                                        component));
                        knownDeviceComponents.clear();
                        knownDeviceComponents.addAll(currentComponents);
                    } else {
                        components.forEach(component -> discoveredListener.componentDiscovered(haID, component));
                    }
                }
            } catch (UnsupportedComponentException e) {
                logger.warn("Home Assistant discovery error: component {} is unsupported", haID.toShortTopic());
            } catch (ConfigurationException e) {
                logger.warn("Home Assistant discovery error: invalid configuration of component {}: {}",
                        haID.toShortTopic(), e.getMessage());
            }
        } else if (discoveredListener != null) {
            if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
                knownDeviceComponents.forEach(discoveredListener::componentRemoved);
                knownDeviceComponents.clear();
            }
            discoveredListener.componentRemoved(haID);
        }
    }

    /**
     * Start a components discovery.
     *
     * <p>
     * We need to consider the case that the remote client is using node IDs
     * and also the case that no node IDs are used.
     * </p>
     *
     * @param connection A MQTT broker connection
     * @param discoverTime The time in milliseconds for the discovery to run. Can be 0 to disable the
     *            timeout.
     *            You need to call {@link #stopDiscovery()} at some
     *            point in that case.
     * @param topicDescriptions Contains the object-id (=device id) and potentially a node-id as well.
     * @param componentsDiscoveredListener Listener for results
     * @return A future that completes normally after the given time in milliseconds or exceptionally on any error.
     *         Completes immediately if the timeout is disabled.
     */
    public CompletableFuture<@Nullable Void> startDiscovery(MqttBrokerConnection connection, int discoverTime,
            Set<HaID> topicDescriptions, ComponentDiscovered componentsDiscoveredListener) {
        this.topics = topicDescriptions.stream().map(id -> id.getTopic("config")).collect(Collectors.toSet());
        this.discoverTime = discoverTime;
        this.discoveredListener = componentsDiscoveredListener;
        this.connectionRef = new WeakReference<>(connection);

        // Subscribe to the wildcard topic and start receive MQTT retained topics
        this.topics.stream().map(t -> connection.subscribe(t, this)).collect(FutureCollector.allOf())
                .thenRun(this::subscribeSuccess).exceptionally(this::subscribeFail);

        return discoverFinishedFuture;
    }

    private void subscribeSuccess() {
        final MqttBrokerConnection connection = connectionRef.get();
        // Set up a scheduled future that will stop the discovery after the given time
        if (connection != null && discoverTime > 0) {
            this.stopDiscoveryFuture = scheduler.schedule(() -> {
                this.stopDiscoveryFuture = null;
                this.topics.stream().forEach(t -> connection.unsubscribe(t, this));
                this.discoveredListener = null;
                discoverFinishedFuture.complete(null);
            }, discoverTime, TimeUnit.MILLISECONDS);
        } else {
            // No timeout -> complete immediately
            discoverFinishedFuture.complete(null);
        }
    }

    private synchronized @Nullable Void subscribeFail(Throwable e) {
        final ScheduledFuture<?> scheduledFuture = this.stopDiscoveryFuture;
        if (scheduledFuture != null) { // Cancel timeout
            scheduledFuture.cancel(false);
            this.stopDiscoveryFuture = null;
        }
        this.discoveredListener = null;
        this.knownDeviceComponents.clear();
        final MqttBrokerConnection connection = connectionRef.get();
        if (connection != null) {
            this.topics.stream().forEach(t -> connection.unsubscribe(t, this));
            connectionRef.clear();
        }
        discoverFinishedFuture.completeExceptionally(e);
        return null;
    }

    /**
     * Stops an ongoing discovery or do nothing if no discovery is running.
     */
    public void stopDiscovery() {
        subscribeFail(new Throwable("Stopped"));
    }
}
