/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree.
 */

package com.facebook.openwifirrm.modules;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.facebook.openwifirrm.DeviceConfig;
import com.facebook.openwifirrm.DeviceDataManager;
import com.facebook.openwifirrm.RRMConfig.ModuleConfig.ConfigManagerParams;
import com.facebook.openwifirrm.ucentral.UCentralApConfiguration;
import com.facebook.openwifirrm.ucentral.UCentralClient;
import com.facebook.openwifirrm.ucentral.UCentralUtils;
import com.facebook.openwifirrm.ucentral.gw.models.DeviceWithStatus;

/**
 * Device configuration manager module.
 */
public class ConfigManager implements Runnable {
	private static final Logger logger =
		LoggerFactory.getLogger(ConfigManager.class);

	/** The module parameters. */
	private final ConfigManagerParams params;

	/** The device data manager. */
	private final DeviceDataManager deviceDataManager;

	/** The uCentral client. */
	private final UCentralClient client;

	/** Runtime per-device data. */
	private class DeviceData {
		/** Last received device config. */
		public UCentralApConfiguration config;

		/** Last config time (in monotonic ns). */
		public Long lastConfigTimeNs;
	}

	/** Map from device serial number to runtime data. */
	private Map<String, DeviceData> deviceDataMap = new TreeMap<>();

	/** The main thread reference (i.e. where {@link #run()} is invoked). */
	private Thread mainThread;

	/** Was the main thread interrupt generated by {@link #wakeUp()}? */
	private final AtomicBoolean wakeupFlag = new AtomicBoolean(false);

	/** Is the main thread sleeping? */
	private final AtomicBoolean sleepingFlag = new AtomicBoolean(false);

	/**
	 * Thread-safe set of zones for which manual config updates have been
	 * requested.
	 */
	private Set<String> zonesToUpdate = ConcurrentHashMap.newKeySet();

	/** Config listener interface. */
	public interface ConfigListener {
		/**
		 * Receive a new device config.
		 *
		 * The listener should NOT modify the "config" parameter.
		 */
		void receiveDeviceConfig(
			String serialNumber,
			UCentralApConfiguration config
		);

		/**
		 * Process a received device config.
		 *
		 * The listener should modify the "config" parameter as necessary and
		 * return true if any changes were made, otherwise return false.
		 */
		boolean processDeviceConfig(
			String serialNumber,
			UCentralApConfiguration config
		);
	}

	/** State listeners. */
	private Map<String, ConfigListener> configListeners = new TreeMap<>();

	/** Constructor. */
	public ConfigManager(
		ConfigManagerParams params,
		DeviceDataManager deviceDataManager,
		UCentralClient client
	) {
		this.params = params;
		this.deviceDataManager = deviceDataManager;
		this.client = client;

		// Apply RRM parameters
		addConfigListener(
			getClass().getSimpleName(),
			new ConfigListener() {
				@Override
				public void receiveDeviceConfig(
					String serialNumber,
					UCentralApConfiguration config
				) {
					// do nothing
				}

				@Override
				public boolean processDeviceConfig(
					String serialNumber,
					UCentralApConfiguration config
				) {
					return applyRRMConfig(serialNumber, config);
				}
			}
		);
	}

	@Override
	public void run() {
		this.mainThread = Thread.currentThread();

		// Run application logic in a periodic loop
		while (!Thread.currentThread().isInterrupted()) {
			try {
				runImpl();
				sleepingFlag.set(true);
				Thread.sleep(params.updateIntervalMs);
				wakeupFlag.set(false);
			} catch (InterruptedException e) {
				if (wakeupFlag.getAndSet(false)) {
					// Intentional interrupt, clear flag
					logger.debug("Interrupted by wakeup!");
					Thread.interrupted();
					continue;
				} else {
					logger.error("Interrupted!", e);
					break;
				}
			} finally {
				sleepingFlag.set(false);
			}
		}
		logger.error("Thread terminated!");
	}

	/** Run single iteration of application logic. */
	private void runImpl() {
		while (!client.isInitialized()) {
			logger.trace("Waiting for ucentral client");
			try {
				Thread.sleep(2000);
			} catch (InterruptedException e) {
				Thread.currentThread().interrupt();
				return;
			}
		}
		client.refreshAccessToken();

		// Fetch device list
		List<DeviceWithStatus> devices = client.getDevices();
		if (devices == null) {
			logger.error("Failed to fetch devices!");
			return;
		}
		logger.debug("Received device list of size = {}", devices.size());

		long now = System.nanoTime();

		// Apply any config updates locally
		List<String> devicesNeedingUpdate = new ArrayList<>();
		final long CONFIG_DEBOUNCE_INTERVAL_NS =
			params.configDebounceIntervalSec * 1_000_000_000L;
		Set<String> zonesToUpdateCopy = new HashSet<>(zonesToUpdate);
		// use removeAll() instead of clear() in case items are added between
		// the previous line and the following line
		zonesToUpdate.removeAll(zonesToUpdateCopy);
		for (DeviceWithStatus device : devices) {
			// Update config structure
			DeviceData data = deviceDataMap.computeIfAbsent(
				device.serialNumber,
				k -> new DeviceData()
			);
			// Update the device only when it is still connected
			if (!device.connected) {
				logger.info(
					"Device {} is disconnected, skipping...",
					device.serialNumber
				);
				continue;
			}
			data.config = new UCentralApConfiguration(device.configuration);

			// Call receive listeners
			for (ConfigListener listener : configListeners.values()) {
				listener.receiveDeviceConfig(device.serialNumber, data.config);
			}
			// Check if there are requested updates for this zone
			String deviceZone =
				deviceDataManager.getDeviceZone(device.serialNumber);
			boolean isEvent = zonesToUpdateCopy.contains(deviceZone);
			if (params.configOnEventOnly && !isEvent) {
				logger.debug(
					"Skipping config for {} (zone not marked for updates)",
					device.serialNumber
				);
				continue;
			}

			// Check if pushing config is enabled in device config
			if (!isDeviceConfigEnabled(device.serialNumber)) {
				logger.debug(
					"Skipping config for {} (disabled in device config)",
					device.serialNumber
				);
				continue;
			}

			// Call processing listeners
			boolean modified = false;
			for (ConfigListener listener : configListeners.values()) {
				boolean wasModified = listener.processDeviceConfig(
					device.serialNumber,
					data.config
				);
				if (wasModified) {
					modified = true;
				}
			}

			// Queue config update if past debounce interval
			if (modified) {
				if (
					data.lastConfigTimeNs != null &&
						now - data.lastConfigTimeNs <
							CONFIG_DEBOUNCE_INTERVAL_NS
				) {
					logger.debug(
						"Skipping config for {} (last configured {}s ago)",
						device.serialNumber,
						(now - data.lastConfigTimeNs) / 1_000_000_000L
					);
					continue;
				} else {
					devicesNeedingUpdate.add(device.serialNumber);
				}
			}
		}

		final boolean shouldUpdate = !zonesToUpdateCopy.isEmpty();
		// Send config changes to devices
		if (!params.configEnabled) {
			logger.trace("Config changes are disabled.");
		} else if (devicesNeedingUpdate.isEmpty()) {
			logger.debug("No device configs to send.");
		} else if (params.configOnEventOnly && !shouldUpdate) {
			// shouldn't happen
			logger.error(
				"ERROR!! {} device(s) queued for config update, but no zones queued for update.",
				devicesNeedingUpdate.size()
			);
		} else {
			logger.info(
				"Sending config to {} device(s): {}",
				devicesNeedingUpdate.size(),
				String.join(", ", devicesNeedingUpdate)
			);
			for (String serialNumber : devicesNeedingUpdate) {
				DeviceData data = deviceDataMap.get(serialNumber);
				logger.info(
					"Device {}: sending new configuration...",
					serialNumber
				);
				data.lastConfigTimeNs = System.nanoTime();
				client.configure(serialNumber, data.config.toString());
			}
		}
	}

	/** Return whether the given device has pushing device config enabled. */
	private boolean isDeviceConfigEnabled(String serialNumber) {
		DeviceConfig deviceConfig =
			deviceDataManager.getDeviceConfig(serialNumber);
		if (deviceConfig == null) {
			return false;
		}
		return deviceConfig.enableConfig;
	}

	/**
	 * Apply RRM parameters to the device.
	 *
	 * If any changes are needed, modify the config and return true.
	 * Otherwise, return false.
	 */
	private boolean applyRRMConfig(
		String serialNumber,
		UCentralApConfiguration config
	) {
		DeviceConfig deviceConfig =
			deviceDataManager.getDeviceConfig(serialNumber);
		if (deviceConfig == null || !deviceConfig.enableRRM) {
			return false;
		}

		boolean modified = false;

		// Apply channel config
		Map<String, Integer> channelList = new HashMap<>();
		if (deviceConfig.autoChannels != null) {
			channelList.putAll(deviceConfig.autoChannels);
		}
		if (deviceConfig.userChannels != null) {
			channelList.putAll(deviceConfig.userChannels);
		}
		if (!channelList.isEmpty()) {
			modified |= UCentralUtils.setRadioConfigField(
				serialNumber,
				config,
				"channel",
				channelList
			);
		}

		// Apply tx power config
		Map<String, Integer> txPowerList = new HashMap<>();
		if (deviceConfig.autoTxPowers != null) {
			txPowerList.putAll(deviceConfig.autoTxPowers);
		}
		if (deviceConfig.userTxPowers != null) {
			txPowerList.putAll(deviceConfig.userTxPowers);
		}
		if (!txPowerList.isEmpty()) {
			modified |= UCentralUtils.setRadioConfigField(
				serialNumber,
				config,
				"tx-power",
				txPowerList
			);
		}

		return modified;
	}

	/**
	 * Add/overwrite a config listener with an arbitrary identifier.
	 *
	 * The "id" string determines the order in which listeners are called.
	 */
	public void addConfigListener(String id, ConfigListener listener) {
		logger.debug("Adding config listener: {}", id);
		configListeners.put(id, listener);
	}

	/**
	 * Remove a config listener with the given identifier, returning true if
	 * anything was actually removed.
	 */
	public boolean removeConfigListener(String id) {
		logger.debug("Removing config listener: {}", id);
		return (configListeners.remove(id) != null);
	}

	/**
	 * Mark the zone to be updated, then interrupt the main thread to possibly
	 * trigger an update immediately.
	 *
	 * @param zone non-null zone (i.e., venue)
	 */
	public void queueZoneAndWakeUp(String zone) {
		if (zone == null) {
			logger.debug("Zone to queue must be a non-null String.");
			return;
		}
		zonesToUpdate.add(zone);
		wakeUp();
	}

	/**
	 * Track all zones to be updated, then interrupt the main thread to possibly
	 * trigger an update immediately.
	 */
	public void queueAllZonesAndWakeUp() {
		/*
		 * Note, addAll is not atomic, but that is ok. This just means that it
		 * is possible that some zones may get updated now by the main thread
		 * while others get updated either when the main thread is woken up or
		 * the next time the main thread does its periodic update.
		 */
		zonesToUpdate.addAll(deviceDataManager.getZones());
		wakeUp();
	}

	/** Interrupt the main thread to possibly trigger an update immediately. */
	private void wakeUp() {
		if (mainThread != null && mainThread.isAlive() && sleepingFlag.get()) {
			wakeupFlag.set(true);
			mainThread.interrupt();
		}
	}
}
