// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (C) Florian K. (Blacktempel)
// All Rights Reserved.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Ports;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using LibreHardwareMonitor.Interop.PowerMonitor;

namespace LibreHardwareMonitor.Hardware.PowerMonitor;

/// <summary>
/// Thermal Grizzly WireView Pro II power monitor.
/// </summary>
public sealed class WireViewPro2 : Hardware, IPowerMonitor
{
    public const string WelcomeMessage = "Thermal Grizzly WireView Pro II";

    /// <summary>
    /// Max RPM according to the8auer. This is a custom made fan.
    /// </summary>
    private const int MaxFanRPM = 5000;

    private const byte VendorID = 0xEF;
    private const byte ProductID = 0x05;

    /// <summary>
    /// Time the fan needs to ramp up by 10%.
    /// </summary>
    private static readonly TimeSpan FanRampupTime = TimeSpan.FromSeconds(3.5);

    private double _lastFanSpeedRpm;
    private DateTime _lastFanUpdateTime = DateTime.MinValue;

    private readonly int _baudRate;
    private readonly string _portName;
    private readonly List<WireViewPro2Sensor> _sensors = [];

    private readonly object _serialPortSync = new();
    private SharedSerialPort _serialPort;

    public WireViewPro2(string portName, ISettings settings, int baud = 115200)
        : base("WireView Pro II", new Identifier("gpu-powermonitor", portName), settings)
    {
        _portName = portName;
        _baudRate = baud;

        Connect();

        if (IsConnected)
        {
            CreateSensors();
        }
    }

    public override HardwareType HardwareType => HardwareType.PowerMonitor;

    public bool IsConnected { get; private set; }

    public string UniqueID { get; private set; }

    public VendorDataStruct? VendorData { get; private set; }

    public int ConfigVersion { get; private set; }

    public static List<WireViewPro2> TryFindDevices(ISettings settings)
    {
        var devices = new List<WireViewPro2>();

        if (!Software.OperatingSystem.IsWindows8OrGreater)
        {
            return devices; //No Linux implementation yet
        }

        List<string> matches = Stm32PortFinder.FindMatchingComPorts(0x0483, 0x5740);

        WireViewPro2 wireViewPro2 = null;

        foreach (string port in matches)
        {
            try
            {
                wireViewPro2 = new WireViewPro2(port, settings);
                if (wireViewPro2.IsConnected)
                {
                    devices.Add(wireViewPro2);
                    continue;
                }

                wireViewPro2.Close();
                wireViewPro2 = null;
            }
            catch
            {
                wireViewPro2?.Close();
                wireViewPro2 = null;
            }
        }

        return devices;
    }

    public override string GetReport()
    {
        var sb = new StringBuilder();
        sb.AppendLine(Name);

        foreach (WireViewPro2Sensor sensor in _sensors)
        {
            sb.AppendLine($"  Sensor: {sensor.Name} = {sensor.Value}");
        }

        if (VendorData.HasValue)
        {
            sb.AppendLine($"  {nameof(VendorData.Value.VendorId)} = {VendorData.Value.VendorId}");
            sb.AppendLine($"  {nameof(VendorData.Value.ProductId)} = {VendorData.Value.ProductId}");
            sb.AppendLine($"  {nameof(VendorData.Value.FwVersion)} = {VendorData.Value.FwVersion}");
        }

        sb.AppendLine($"  {nameof(UniqueID)} = {UniqueID}");

        return sb.ToString();
    }

    public override void Close()
    {
        Disconnect();

        base.Close();
    }

    public override void Update()
    {
        var deviceData = GetDeviceData();

        if (deviceData != null)
        {
            _sensors.ForEach(wvps => wvps.Update(deviceData));
        }
    }

    public DeviceData GetDeviceData()
    {
        SensorStruct? sensorValues = null;
        DeviceConfigStructV3? config = null;

        try
        {
            sensorValues = ReadSensorValues();

            config = ReadConfig();
        }
        catch (IOException)
        {
            //"A device attached to the system is not functioning."
            //Can happen rarely
        }
        catch (InvalidOperationException)
        {
            //Can happen sometimes if the device is disconnecting while reading
        }

        if (sensorValues.HasValue && config.HasValue)
        {
            return MapSensorStructure(sensorValues.Value, config.Value);
        }

        return null;
    }

    public DeviceConfigStructV3? ReadConfig()
    {
        if (!IsConnected)
        {
            return null;
        }

        int size = 0;

        switch (ConfigVersion)
        {
            case 0:
                size = Marshal.SizeOf<DeviceConfigStructV1>();
                break;
            case 1:
                size = Marshal.SizeOf<DeviceConfigStructV2>();
                break;
            case 2:
                size = Marshal.SizeOf<DeviceConfigStructV3>();
                break;
            default:
                return null;
        }

        var buf = SendCmd(UsbCmd.CMD_READ_CONFIG, size);

        if (buf == null)
        {
            return null;
        }

        switch (ConfigVersion)
        {
            case 0:
                var s1 = BytesToStructure<DeviceConfigStructV1>(buf);
                return StructureConversion.ConvertConfigV1ToV3(s1);
            case 1:
                var s2 = BytesToStructure<DeviceConfigStructV2>(buf);
                return StructureConversion.ConvertConfigV2ToV3(s2);
            case 2:
                return BytesToStructure<DeviceConfigStructV3>(buf);
            default:
                return null;
        }
    }

    public void WriteConfig(DeviceConfigStructV3 config)
    {
        if (!IsConnected)
        {
            return;
        }

        byte[] payload = [];

        switch (ConfigVersion)
        {
            case 0:
                var s1 = StructureConversion.ConvertConfigV3ToV1(config);
                payload = StructureToBytes(s1);
                break;
            case 1:
                var s2 = StructureConversion.ConvertConfigV3ToV2(config);
                payload = StructureToBytes(s2);
                break;
            case 2:
                payload = StructureToBytes(config);
                break;
            default:
                return;
        }

        byte[] frame = new byte[64];
        frame[0] = (byte)UsbCmd.CMD_WRITE_CONFIG;

        lock (_serialPortSync)
        {
            try
            {
                _serialPort.Open();
                _serialPort.DiscardInBuffer();

                const int maxPayloadPerFrame = 62;

                for (byte offset = 0; offset < payload.Length; offset += maxPayloadPerFrame)
                {
                    int bytesToWrite = Math.Min(maxPayloadPerFrame, payload.Length - offset);

                    frame[1] = offset;
                    Buffer.BlockCopy(payload, offset, frame, 2, bytesToWrite);

                    _serialPort.Write(frame, 0, bytesToWrite + 2);
                }
            }
            finally
            {
                _serialPort.Close();
            }
        }
    }

    public void NonVolatileMemoryCommand(NVM_CMD cmd)
    {
        if (!IsConnected)
        {
            return;
        }

        SendData(
            [
                (byte)UsbCmd.CMD_NVM_CONFIG,
                0x55, //Magic
                0xAA, //Magic
                0x55, //Magic
                0xAA, //Magic
                (byte)cmd
            ]);
    }

    public void ScreenCmd(SCREEN_CMD cmd)
    {
        if (!IsConnected)
        {
            return;
        }

        SendData([(byte)UsbCmd.CMD_SCREEN_CHANGE, (byte)cmd]);
    }

    public void ClearFaults(int faultStatusMask = 0xFFFF, int faultLogMask = 0xFFFF)
    {
        if (!IsConnected)
        {
            return;
        }

        SendData(
            [
                (byte)UsbCmd.CMD_CLEAR_FAULTS,
                (byte)(faultStatusMask & 0xFF),
                (byte)((faultStatusMask >> 8) & 0xFF),
                (byte)(faultLogMask & 0xFF),
                (byte)((faultLogMask >> 8) & 0xFF)
            ]);
    }

    private void CreateSensors()
    {
        //Onboard temperature sensors
        AddSensor("Onboard Temperature In", 0, SensorType.Temperature, dd => (float)dd.OnboardTempInC);
        AddSensor("Onboard Temperature Out", 1, SensorType.Temperature, dd => (float)dd.OnboardTempOutC);

        //External temperature sensors, requires shipped temperature sensors to be connected
        AddSensor("External Temperature 1", 2, SensorType.Temperature, dd => (float)dd.ExternalTemp1C);
        AddSensor("External Temperature 2", 3, SensorType.Temperature, dd => (float)dd.ExternalTemp2C);

        //Pin voltages
        AddSensor("Pin 1 Voltage", 10, SensorType.Voltage, dd => (float)dd.PinVoltage[0]);
        AddSensor("Pin 2 Voltage", 11, SensorType.Voltage, dd => (float)dd.PinVoltage[1]);
        AddSensor("Pin 3 Voltage", 12, SensorType.Voltage, dd => (float)dd.PinVoltage[2]);
        AddSensor("Pin 4 Voltage", 13, SensorType.Voltage, dd => (float)dd.PinVoltage[3]);
        AddSensor("Pin 5 Voltage", 14, SensorType.Voltage, dd => (float)dd.PinVoltage[4]);
        AddSensor("Pin 6 Voltage", 15, SensorType.Voltage, dd => (float)dd.PinVoltage[5]);

        //Pin currents
        AddSensor("Total Current", 20, SensorType.Current, dd => (float)dd.SumCurrentA);
        AddSensor("Pin 1 Current", 21, SensorType.Current, dd => (float)dd.PinCurrent[0]);
        AddSensor("Pin 2 Current", 22, SensorType.Current, dd => (float)dd.PinCurrent[1]);
        AddSensor("Pin 3 Current", 23, SensorType.Current, dd => (float)dd.PinCurrent[2]);
        AddSensor("Pin 4 Current", 24, SensorType.Current, dd => (float)dd.PinCurrent[3]);
        AddSensor("Pin 5 Current", 25, SensorType.Current, dd => (float)dd.PinCurrent[4]);
        AddSensor("Pin 6 Current", 26, SensorType.Current, dd => (float)dd.PinCurrent[5]);

        //Power
        AddSensor("Total Power", 30, SensorType.Power, dd => (float)dd.SumPowerW);

        //Fan
        var fan = AddSensor("Fan", 40, SensorType.Fan, CalculateFanSpeed);
        var ctrl = new Control(fan, _settings, 0, 100);

        fan.Control = ctrl;
        ctrl.ControlModeChanged += OnFanControlModeChanged;
        ctrl.SoftwareControlValueChanged += OnSoftwareControlValueChanged;

        //Control
        var fanControl = AddSensor($"{Name} ({_portName})", 50, SensorType.Control, GetFanSpeedInPercent);
        fanControl.Control = ctrl;
    }

    private WireViewPro2Sensor AddSensor(string name, int index, SensorType sensorType, GetWireViewPro2SensorValue getValue)
    {
        var sensor = new WireViewPro2Sensor(name, index, sensorType, this, _settings, getValue);

        _sensors.Add(sensor);

        ActivateSensor(sensor);

        return sensor;
    }

    private double FromTemp(short temp)
    {
        return temp == 0 ? 0 : temp / 10.0;
    }

    private short ToTemp(double temp)
    {
        return (short)(temp * 10);
    }

    private void OnFanControlModeChanged(Control control)
    {
        var deviceData = GetDeviceData();

        if (deviceData == null)
        {
            return;
        }

        switch (control.ControlMode)
        {
            case ControlMode.Software:
                deviceData.Config.FanConfig.Mode = FanMode.FanModeFixed;
                break;
            case ControlMode.Default:
                //Set default values of TG Software
                deviceData.Config.FanConfig.Mode = FanMode.FanModeCurve;
                deviceData.Config.FanConfig.TempSource = TempSource.TempSourceTmax;

                deviceData.Config.FanConfig.TempMin = ToTemp(50);
                deviceData.Config.FanConfig.TempMax = ToTemp(80);

                deviceData.Config.FanConfig.DutyMin = 0;
                deviceData.Config.FanConfig.DutyMax = 100;
                break;
            default:
                break;
        }

        WriteConfig(deviceData.Config);
    }

    private void OnSoftwareControlValueChanged(Control control)
    {
        var deviceData = GetDeviceData();

        if (deviceData == null)
        {
            return;
        }

        byte value = (byte)control.SoftwareValue;

        if (value < 0)
        {
            value = 0;
        }
        else if (value > 100)
        {
            value = 100;
        }

        deviceData.Config.FanConfig.TempMin = ToTemp(0);
        deviceData.Config.FanConfig.TempMax = ToTemp(80);

        deviceData.Config.FanConfig.DutyMin = value;
        deviceData.Config.FanConfig.DutyMax = value;

        WriteConfig(deviceData.Config);
    }

    private float GetFanSpeedInPercent(DeviceData dd)
    {
        var fanConfig = dd.Config.FanConfig;
        float fanSpeedInPercent;

        switch (fanConfig.Mode)
        {
            case FanMode.FanModeCurve:
                var tempMin = FromTemp(fanConfig.TempMin);
                var tempMax = FromTemp(fanConfig.TempMax);
                var currentTemperature = GetActiveTemperature(dd);

                if (tempMax <= tempMin || currentTemperature <= tempMin)
                {
                    fanSpeedInPercent = fanConfig.DutyMin;
                }
                else if (currentTemperature >= tempMax)
                {
                    fanSpeedInPercent = fanConfig.DutyMax;
                }
                else
                {
                    var temp = (currentTemperature - tempMin) / (tempMax - tempMin);
                    fanSpeedInPercent = (float)(fanConfig.DutyMin + temp * (fanConfig.DutyMax - fanConfig.DutyMin));
                }
                break;
            case FanMode.FanModeFixed:
                fanSpeedInPercent = fanConfig.DutyMin;
                break;
            default:
                return -1;
        }

        return fanSpeedInPercent;
    }

    /// <summary>
    /// Fan speed for this device is an approximation based on the curve configuration and current temperatures.<br/>
    /// The device itself does not report actual fan speed.
    /// </summary>
    private float CalculateFanSpeed(DeviceData dd)
    {
        var fanConfig = dd.Config.FanConfig;
        double targetRpm;

        var fanSpeedInPercent = GetFanSpeedInPercent(dd);

        switch (fanConfig.Mode)
        {
            case FanMode.FanModeCurve:
            case FanMode.FanModeFixed:
                targetRpm = fanSpeedInPercent == 0 ? 0 : fanSpeedInPercent / 100.0f * MaxFanRPM;
                break;
            default:
                return -1;
        }

        return (float)ApplyFanRamp(targetRpm);
    }

    private double ApplyFanRamp(double targetRpm)
    {
        var now = DateTime.UtcNow;

        if (_lastFanUpdateTime == DateTime.MinValue)
        {
            _lastFanSpeedRpm = targetRpm;
            _lastFanUpdateTime = now;
            return targetRpm;
        }

        var elapsed = now - _lastFanUpdateTime;

        if (elapsed <= TimeSpan.Zero)
        {
            return _lastFanSpeedRpm;
        }

        var maxPercentDeltaPerSecond = 0.1 / FanRampupTime.TotalSeconds; //10% per FanRampupTime
        var allowedPercentDelta = elapsed.TotalSeconds * maxPercentDeltaPerSecond;

        var rpmDelta = targetRpm - _lastFanSpeedRpm;
        var percentDelta = rpmDelta / MaxFanRPM;

        if (Math.Abs(percentDelta) > allowedPercentDelta)
        {
            rpmDelta = Math.Sign(percentDelta) * allowedPercentDelta * MaxFanRPM;
        }

        _lastFanSpeedRpm += rpmDelta;
        _lastFanUpdateTime = now;

        return _lastFanSpeedRpm;
    }

    private double GetActiveTemperature(DeviceData dd)
    {
        double temperature = 0;

        switch (dd.Config.FanConfig.TempSource)
        {
            case TempSource.TempSourceTsIn:
                temperature = dd.OnboardTempInC;
                break;
            case TempSource.TempSourceTsOut:
                temperature = dd.OnboardTempOutC;
                break;
            case TempSource.TempSourceTs1:
                temperature = dd.ExternalTemp1C;
                break;
            case TempSource.TempSourceTs2:
                temperature = dd.ExternalTemp2C;
                break;
            case TempSource.TempSourceTmax:
                temperature = Math.Max(
                    Math.Max(dd.OnboardTempInC, dd.OnboardTempOutC),
                    Math.Max(dd.ExternalTemp1C, dd.ExternalTemp2C));
                break;
            default:
                break;
        }

        return temperature;
    }

    private void Connect()
    {
        if (IsConnected)
        {
            return;
        }

        try
        {
            _serialPort = new SharedSerialPort(_portName, _baudRate, Parity.None, 8, StopBits.One);
            _serialPort.ReadTimeout = 1000;
            _serialPort.WriteTimeout = 1000;

            if (!ReadWelcomeMessage())
            {
                IsConnected = false;
                return;
            }

            VendorDataStruct? vendorData = ReadVendorData();

            if (vendorData.HasValue &&
                vendorData.Value.VendorId == VendorID &&
                vendorData.Value.ProductId == ProductID)
            {
                VendorData = vendorData.Value;

                var configVersion = ReadConfigVersion();
                if (configVersion == null)
                {
                    IsConnected = false;
                    return;
                }
                else
                {
                    ConfigVersion = configVersion.Value;
                }

                UniqueID = ReadUniqueID();

                IsConnected = true;
            }
        }
        catch
        {
            if (_serialPort != null)
            {
                try
                {
                    if (_serialPort.IsOpen)
                    {
                        _serialPort.Close();
                    }
                }
                catch
                {
                    //Ignore exceptions during cleanup
                }
                finally
                {
                    _serialPort.Dispose();
                    _serialPort = null;
                }
            }

            IsConnected = false;
        }
    }

    private void Disconnect()
    {
        lock (_serialPortSync)
        {
            if (!IsConnected)
            {
                return;
            }

            if (_serialPort != null)
            {
                _serialPort.Close();
                _serialPort.Dispose();
                _serialPort = null;
            }

            IsConnected = false;

            VendorData = null;
            UniqueID = null;
        }
    }

    private bool ReadWelcomeMessage(bool sendCmd = false)
    {
        int size = WelcomeMessage.Length + 1;

        var bytes = SendData([(byte)UsbCmd.CMD_WELCOME], size, true);

        return bytes == null ? false : Encoding.ASCII.GetString(bytes, 0, size).TrimEnd('\0').CompareTo(WelcomeMessage) == 0;
    }

    private VendorDataStruct? ReadVendorData()
    {
        var bytes = SendCmd(UsbCmd.CMD_READ_VENDOR_DATA, Marshal.SizeOf<VendorDataStruct>());

        return bytes == null ? null : BytesToStructure<VendorDataStruct>(bytes);
    }

    private string ReadUniqueID()
    {
        const int UIDBytes = 12;

        var bytes = SendCmd(UsbCmd.CMD_READ_UID, UIDBytes);

        return bytes == null ? null : BitConverter.ToString(bytes).Replace("-", string.Empty);
    }

    private SensorStruct? ReadSensorValues()
    {
        if (!IsConnected)
        {
            return null;
        }

        var bytes = SendCmd(UsbCmd.CMD_READ_SENSOR_VALUES, Marshal.SizeOf<SensorStruct>());

        return bytes == null ? null : BytesToStructure<SensorStruct>(bytes);
    }

    private DeviceData MapSensorStructure(SensorStruct sensorStruct, DeviceConfigStructV3 config)
    {
        var deviceData = new DeviceData
        {
            Connected = true,
            HardwareRevision = $"{VendorData?.VendorId}{VendorData?.ProductId}",
            FirmwareVersion = $"{VendorData?.FwVersion}",
            OnboardTempInC = sensorStruct.Ts[(int)SensorTs.SENSOR_TS_IN] / 10.0,
            OnboardTempOutC = sensorStruct.Ts[(int)SensorTs.SENSOR_TS_OUT] / 10.0,
            ExternalTemp1C = sensorStruct.Ts[(int)SensorTs.SENSOR_TS3] / 10.0,
            ExternalTemp2C = sensorStruct.Ts[(int)SensorTs.SENSOR_TS4] / 10.0,
            FaultStatus = sensorStruct.FaultStatus,
            FaultLog = sensorStruct.FaultLog,
            Config = config,
        };

        switch (sensorStruct.HpwrCapability)
        {
            case HpwrCapability.PSU_CAP_600W:
                deviceData.PsuCapabilityW = 600;
                break;
            case HpwrCapability.PSU_CAP_450W:
                deviceData.PsuCapabilityW = 450;
                break;
            case HpwrCapability.PSU_CAP_300W:
                deviceData.PsuCapabilityW = 300;
                break;
            case HpwrCapability.PSU_CAP_150W:
                deviceData.PsuCapabilityW = 150;
                break;
        }

        for (int i = 0; i < 6; ++i)
        {
            deviceData.PinVoltage[i] = sensorStruct.PowerReadings[i].Voltage / 1000.0;
            deviceData.PinCurrent[i] = sensorStruct.PowerReadings[i].Current / 1000.0;
        }

        return deviceData;
    }

    private int? ReadConfigVersion()
    {
        var bytes = SendCmd(UsbCmd.CMD_READ_CONFIG, 4);

        return bytes == null ? null : bytes[2];
    }

    private byte[] SendCmd(UsbCmd cmd, int responseSize = 0, bool rts = false)
    {
        return SendData(new[] { (byte)cmd }, responseSize, rts);
    }

    private byte[] SendData(byte[] data, int responseSize = 0, bool rts = false)
    {
        lock (_serialPortSync)
        {
            if (_serialPort == null)
            {
                return null;
            }

            byte[] buf = null;
            try
            {
                _serialPort.Open();
                _serialPort.DiscardInBuffer();

                if (rts)
                {
                    _serialPort.RtsEnable = true;
                    Thread.Sleep(10);
                }

                _serialPort.Write(data, 0, data.Length);

                if (responseSize > 0)
                {
                    buf = ReadExact(responseSize);
                }

                if (rts)
                {
                    Thread.Sleep(10);
                    _serialPort.RtsEnable = false;
                }
            }
            finally
            {
                _serialPort.Close();
            }

            return buf;
        }
    }

    private byte[] ReadExact(int size)
    {
        byte[] buffer = new byte[size];

        int offset = 0;

        var sw = Stopwatch.StartNew();

        while (offset < size && sw.ElapsedMilliseconds < _serialPort.ReadTimeout)
        {
            try
            {
                if (_serialPort.BytesToRead > 0)
                {
                    offset += _serialPort.Read(buffer, offset, size - offset);
                }
            }
            catch (TimeoutException)
            {
                //Ignore timeout exceptions and continue reading
            }
        }

        return offset != size ? null : buffer;
    }

    private T BytesToStructure<
#if NET
        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)]
#endif
        T>(byte[] bytes) where T : struct
    {
        var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);

        try
        {
            return Marshal.PtrToStructure<T>(handle.AddrOfPinnedObject());
        }
        finally
        {
            handle.Free();
        }
    }

    private byte[] StructureToBytes<T>(T value) where T : struct
    {
        int size = Marshal.SizeOf<T>();

        byte[] bytes = new byte[size];
        IntPtr ptr = Marshal.AllocHGlobal(size);

        try
        {
            Marshal.StructureToPtr(value, ptr, false);
            Marshal.Copy(ptr, bytes, 0, size);
            return bytes;
        }
        finally
        {
            Marshal.FreeHGlobal(ptr);
        }
    }
}
