using System.Diagnostics;
using System.IO;
using System.IO.Ports;
using Serilog;

namespace ConeCalorimeter.Services;

public sealed class ModbusRtuScaleService : IScaleService
{
    private const byte ReadInputRegistersFunction = 0x04;
    private const ushort RegisterCount = 2;
    private const int ExpectedDataByteCount = 4;
    private const double MinimumMass = 0;
    private const double MaximumMass = 100000;
    private const ushort CdabFloatRegisterStart = 0x200;
    private static readonly TimeSpan ReadWriteTimeout = TimeSpan.FromMilliseconds(1000);

    private readonly object _syncRoot = new();
    private readonly SerialScaleOptions _options;
    private SerialPort? _serialPort;

    public ModbusRtuScaleService(SerialScaleOptions options)
    {
        _options = options;
        Log.Information(
            "Scale service created. Port={PortName}, BaudRate={BaudRate}, UnitId={UnitId}, Register=D{RegisterAddress}.",
            _options.PortName,
            _options.BaudRate,
            _options.UnitId,
            _options.RegisterAddress);
    }

    public bool TryReadCurrentMass(out double value)
    {
        value = double.NaN;

        lock (_syncRoot)
        {
            try
            {
                var port = GetOpenPort();
                DiscardBufferedData(port);
                WriteReadRequest(port);
                var response = ReadResponse(port);

                if (!TryDecodeCurrentMass(response, _options.UnitId, GetFloatByteOrder(), out value))
                {
                    return false;
                }

                value = Math.Abs(value) < 0.005 ? 0 : value;
                return true;
            }
            catch (Exception ex) when (ex is IOException
                or InvalidDataException
                or TimeoutException
                or UnauthorizedAccessException
                or InvalidOperationException
                or ArgumentException)
            {
                Debug.WriteLine($"Scale read failed on {_options.PortName}: {ex.Message}");
                Log.Warning(
                    ex,
                    "Scale read failed. Port={PortName}, UnitId={UnitId}, Register=D{RegisterAddress}.",
                    _options.PortName,
                    _options.UnitId,
                    _options.RegisterAddress);
                CloseCurrentPort();
                return false;
            }
        }
    }

    private ModbusFloatByteOrder GetFloatByteOrder()
    {
        return _options.RegisterAddress >= CdabFloatRegisterStart
            ? ModbusFloatByteOrder.Cdab
            : ModbusFloatByteOrder.Abcd;
    }

    public void Dispose()
    {
        lock (_syncRoot)
        {
            CloseCurrentPort();
        }
    }

    private SerialPort GetOpenPort()
    {
        if (_serialPort is { IsOpen: true })
        {
            return _serialPort;
        }

        CloseCurrentPort();

        var port = new SerialPort(
            _options.PortName,
            _options.BaudRate,
            _options.Parity,
            _options.DataBits,
            _options.StopBits)
        {
            ReadTimeout = (int)ReadWriteTimeout.TotalMilliseconds,
            WriteTimeout = (int)ReadWriteTimeout.TotalMilliseconds
        };

        port.Open();
        _serialPort = port;
        Log.Information("Scale serial port opened. Port={PortName}.", _options.PortName);
        return port;
    }

    private void CloseCurrentPort()
    {
        var port = _serialPort;
        _serialPort = null;

        if (port is null)
        {
            return;
        }

        try
        {
            if (port.IsOpen)
            {
                port.Close();
            }
        }
        finally
        {
            port.Dispose();
            Log.Information("Scale serial port closed. Port={PortName}.", _options.PortName);
        }
    }

    private static void DiscardBufferedData(SerialPort port)
    {
        port.DiscardInBuffer();
        port.DiscardOutBuffer();
    }

    private void WriteReadRequest(SerialPort port)
    {
        Span<byte> request = stackalloc byte[8];
        request[0] = _options.UnitId;
        request[1] = ReadInputRegistersFunction;
        request[2] = (byte)(_options.RegisterAddress >> 8);
        request[3] = (byte)_options.RegisterAddress;
        request[4] = (byte)(RegisterCount >> 8);
        request[5] = (byte)RegisterCount;
        WriteCrc(request);

        var requestBytes = request.ToArray();
        port.Write(requestBytes, 0, requestBytes.Length);
    }

    private static byte[] ReadResponse(SerialPort port)
    {
        var header = new byte[3];
        ReadExactly(port, header);

        if (header[1] == (ReadInputRegistersFunction | 0x80))
        {
            var exceptionTail = new byte[2];
            ReadExactly(port, exceptionTail);
            var exceptionFrame = header.Concat(exceptionTail).ToArray();
            ValidateCrc(exceptionFrame);
            throw new InvalidDataException($"Scale returned Modbus exception code {header[2]}.");
        }

        if (header[1] != ReadInputRegistersFunction || header[2] != ExpectedDataByteCount)
        {
            throw new InvalidDataException("Invalid scale Modbus response header.");
        }

        var response = new byte[3 + ExpectedDataByteCount + 2];
        header.CopyTo(response, 0);
        ReadExactly(port, response.AsSpan(3));
        ValidateCrc(response);
        return response;
    }

    private static void ReadExactly(SerialPort port, Span<byte> buffer)
    {
        var totalRead = 0;
        var scratch = new byte[Math.Min(256, buffer.Length)];

        while (totalRead < buffer.Length)
        {
            var bytesToRead = Math.Min(scratch.Length, buffer.Length - totalRead);
            var read = port.Read(scratch, 0, bytesToRead);
            if (read == 0)
            {
                throw new IOException("Scale serial port returned no data.");
            }

            scratch.AsSpan(0, read).CopyTo(buffer[totalRead..]);
            totalRead += read;
        }
    }

    private static bool TryDecodeCurrentMass(
        byte[] response,
        byte unitId,
        ModbusFloatByteOrder byteOrder,
        out double value)
    {
        value = double.NaN;

        if (response.Length != 9
            || response[0] != unitId
            || response[1] != ReadInputRegistersFunction
            || response[2] != ExpectedDataByteCount)
        {
            return false;
        }

        var result = new ModbusFloatReadResult(response[3], response[4], response[5], response[6]);
        var candidate = result.GetValue(byteOrder);
        if (!ModbusFloatSelector.IsInRange(candidate, MinimumMass, MaximumMass))
        {
            Debug.WriteLine(
                $"Scale mass out of range raw [{result.RawHex}], selected={byteOrder}, "
                + $"ABCD={result.Abcd:G9}, "
                + $"CDAB={result.Cdab:G9}, BADC={result.Badc:G9}, DCBA={result.Dcba:G9}.");
            Log.Warning(
                "Scale mass out of range. Raw={RawHex}, SelectedByteOrder={SelectedByteOrder}, ABCD={Abcd}, CDAB={Cdab}, BADC={Badc}, DCBA={Dcba}, Range={MinimumMass}..{MaximumMass}.",
                result.RawHex,
                byteOrder,
                result.Abcd,
                result.Cdab,
                result.Badc,
                result.Dcba,
                MinimumMass,
                MaximumMass);
            return false;
        }

        value = candidate;
        return true;
    }

    private static void WriteCrc(Span<byte> frame)
    {
        var crc = CalculateCrc(frame[..^2]);
        frame[^2] = (byte)crc;
        frame[^1] = (byte)(crc >> 8);
    }

    private static void ValidateCrc(ReadOnlySpan<byte> frame)
    {
        if (frame.Length < 3)
        {
            throw new InvalidDataException("Scale Modbus response is too short.");
        }

        var expected = CalculateCrc(frame[..^2]);
        var actual = (ushort)(frame[^2] | (frame[^1] << 8));
        if (actual != expected)
        {
            throw new InvalidDataException("Scale Modbus response CRC mismatch.");
        }
    }

    private static ushort CalculateCrc(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;

        foreach (var value in data)
        {
            crc ^= value;
            for (var bit = 0; bit < 8; bit++)
            {
                var lsbSet = (crc & 0x0001) != 0;
                crc >>= 1;
                if (lsbSet)
                {
                    crc ^= 0xA001;
                }
            }
        }

        return crc;
    }
}
