using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;

/// <summary>
///     Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
///     <c>DriverHost.RegisterAsync</c>. The tags the driver serves are delivered as authored raw
///     tags in <see cref="RawTags"/>; the driver maps each through
///     <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> to build its RawPath → definition table.
/// </summary>
public sealed class ModbusDriverOptions
{
    /// <summary>Gets the Modbus TCP server host address.</summary>
    public string Host { get; init; } = "127.0.0.1";
    /// <summary>Gets the Modbus TCP server port.</summary>
    public int Port { get; init; } = 502;
    /// <summary>Gets the Modbus unit ID (slave ID).</summary>
    public byte UnitId { get; init; } = 1;
    /// <summary>Gets the communication timeout duration.</summary>
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);

    /// <summary>
    ///     Authored raw tags this driver serves. The deploy artifact hands each authored raw
    ///     <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
    ///     WriteIdempotent flag); the driver maps each through
    ///     <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
    ///     Modbus has no discovery protocol — the driver serves exactly these.
    /// </summary>
    public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];

    /// <summary>
    ///     Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
    ///     is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
    ///     <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
    ///     Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
    ///     <c>IHostConnectivityProbe</c>.
    /// </summary>
    public ModbusProbeOptions Probe { get; init; } = new();

    /// <summary>
    ///     Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
    ///     transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
    ///     AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
    ///     Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
    ///     instance talks to; the driver auto-chunks larger reads into consecutive requests.
    ///     Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
    ///     to <c>0</c> resets to the spec maximum (<c>125</c>) without requiring the operator
    ///     to hard-code the limit. Values above <c>125</c> are spec-illegal for conforming
    ///     servers; only use them when the target device's documentation explicitly allows it.
    /// </summary>
    public ushort MaxRegistersPerRead { get; init; } = 125;

    /// <summary>
    ///     Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
    ///     <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
    ///     exceeding the cap currently throws (writes aren't auto-chunked because a partial
    ///     write across two FC16 calls is no longer atomic — caller must explicitly opt in
    ///     by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
    /// </summary>
    public ushort MaxRegistersPerWrite { get; init; } = 123;

    /// <summary>
    ///     Maximum coils per FC01 (Read Coils) / FC02 (Read Discrete Inputs) transaction. Modbus
    ///     spec allows up to <c>2000</c> bits per request — separate from
    ///     <see cref="MaxRegistersPerRead"/> because the underlying packing is different
    ///     (1 bit per coil vs 16 bits per register). Default <c>2000</c>; setting to <c>0</c>
    ///     resets to the spec maximum (<c>2000</c>). Values above <c>2000</c> are spec-illegal
    ///     and will cause most PLCs to reject the request with exception 03. The driver
    ///     auto-chunks coil-array reads above the cap.
    /// </summary>
    [Range(0, 2000)]
    public ushort MaxCoilsPerRead { get; init; } = 2000;

    /// <summary>
    ///     When <c>true</c>, single-element coil writes use FC15 (Write Multiple Coils) with
    ///     <c>quantity=1</c> instead of the default FC05 (Write Single Coil). Safety / audit
    ///     PLCs that only accept the multi-write function codes need this. Default <c>false</c>
    ///     preserves the existing FC05 path.
    /// </summary>
    public bool UseFC15ForSingleCoilWrites { get; init; } = false;

    /// <summary>
    ///     When <c>true</c>, single-element holding-register writes use FC16 (Write Multiple
    ///     Registers) with <c>quantity=1</c> instead of the default FC06 (Write Single
    ///     Register). Same use-case as <see cref="UseFC15ForSingleCoilWrites"/>. Default
    ///     <c>false</c>.
    /// </summary>
    public bool UseFC16ForSingleRegisterWrites { get; init; } = false;

    /// <summary>
    ///     <b>Reserved / no-op</b> kill-switch for FC23 (Read/Write Multiple Registers). The
    ///     driver does not currently emit FC23 — toggling this option has no observable effect
    ///     today. The slot exists so a future block-read-coalescing enhancement that opts into
    ///     FC23 can be disabled per-deployment without a code change. Default <c>false</c>.
    /// </summary>
    public bool DisableFC23 { get; init; } = false;

    /// <summary>
    ///     Interval for the background re-probe loop that retries auto-prohibited
    ///     coalesced ranges. When non-null, every <c>AutoProhibitReprobeInterval</c>
    ///     the driver attempts each prohibition's coalesced read once. If the re-probe
    ///     succeeds, the prohibition clears and the planner resumes coalescing across the
    ///     range on the next scan. Default <c>null</c> = re-probe disabled (prohibitions
    ///     persist until <c>ReinitializeAsync</c>; preserves the previous behaviour).
    /// </summary>
    public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;

    /// <summary>
    ///     Block-read coalescing budget. When non-zero, the read planner combines tags
    ///     in the same (UnitId, Region) group whose addresses are at most this many registers
    ///     apart into a single FC03/FC04/FC01/FC02 read. The sliced response is then dispatched
    ///     back to per-tag values. Default <c>0</c> = no coalescing — every tag gets its own
    ///     PDU (preserves the previous behaviour). Typical opt-in values are 5..32 — large enough
    ///     to bridge a few unused registers, small enough to avoid trampling protected holes.
    /// </summary>
    public ushort MaxReadGap { get; init; } = 0;

    /// <summary>
    ///     PLC family hint that drives the parser's family-native branch. When set to a
    ///     non-Generic value, address strings using that family's native syntax (DL205 V2000 /
    ///     MELSEC D100) parse to the right region + offset directly. Defaults to
    ///     <see cref="ModbusFamily.Generic"/> = Modicon-only behaviour preserved by default.
    /// </summary>
    public ModbusFamily Family { get; init; } = ModbusFamily.Generic;

    /// <summary>
    ///     MELSEC sub-family selector — only consulted when <see cref="Family"/> = MELSEC.
    ///     Default Q/L/iQR (hex X/Y interpretation). Set F_iQF for FX-series PLCs.
    /// </summary>
    public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;

    /// <summary>
    ///     Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
    ///     historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
    ///     frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
    ///     serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
    /// </summary>
    public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;

    /// <summary>
    ///     When <c>true</c>, the driver suppresses redundant writes: if the most recent
    ///     successful write to a tag carried value V and a new write of V arrives, the second
    ///     write returns Good without touching the wire. Saves PLC bandwidth on clients that
    ///     re-publish the same setpoint every scan. The cached "last written" is invalidated
    ///     on the next read that returns a different value, so HMI-side changes don't get
    ///     masked. Default <c>false</c> preserves the historical "every write goes to the wire"
    ///     behaviour. Per-tag deadband lives on <c>ModbusTagDefinition.Deadband</c>.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         <b>Write-only-tag caveat:</b> the suppression cache is only
    ///         invalidated by a <i>read</i> that returns a divergent value. A tag that is never
    ///         subscribed or polled (write-only setpoints, command registers) never sees its
    ///         cache entry refreshed — so a value the operator believes was re-asserted is
    ///         silently suppressed forever after the first write. There is no time- or
    ///         count-based expiry. If you set <see cref="WriteOnChangeOnly"/> = <c>true</c>,
    ///         either subscribe / poll every tag that needs deterministic re-write, or leave
    ///         this option <c>false</c> for the affected driver instance.
    ///     </para>
    /// </remarks>
    public bool WriteOnChangeOnly { get; init; } = false;

    /// <summary>
    ///     When <c>true</c> (default) the built-in <see cref="ModbusTcpTransport"/> detects
    ///     mid-transaction socket failures (<see cref="System.IO.EndOfStreamException"/>,
    ///     <see cref="System.Net.Sockets.SocketException"/>) and transparently reconnects +
    ///     retries the PDU exactly once. Required for DL205/DL260 because the H2-ECOM100
    ///     does not send TCP keepalives — intermediate NAT / firewall devices silently close
    ///     idle sockets and the first send after the drop would otherwise surface as a
    ///     connection error to the caller even though the PLC is up.
    /// </summary>
    public bool AutoReconnect { get; init; } = true;

    /// <summary>
    ///     Per-driver TCP keep-alive settings. Defaults are the historical PR 53 values
    ///     (KeepAliveEnabled=true, Time=30s, Interval=10s, RetryCount=3) so existing
    ///     deployments see no behaviour change. Set <see cref="ModbusKeepAliveOptions.Enabled"/>
    ///     to <c>false</c> to disable OS-level keep-alive entirely (some PLCs don't tolerate it).
    /// </summary>
    public ModbusKeepAliveOptions KeepAlive { get; init; } = new();

    /// <summary>
    ///     If non-null, the transport tracks the time of the last successful PDU and proactively
    ///     closes + reconnects the socket on the next request after this idle threshold elapses.
    ///     Defends against silent NAT / firewall reaping of long-idle sockets — the explicit
    ///     close-and-reopen turns the failure mode from "first-send-after-X-minutes errors" into
    ///     "first-send-after-X-minutes pays one reconnect cost."
    /// </summary>
    public TimeSpan? IdleDisconnectTimeout { get; init; } = null;

    /// <summary>
    ///     Reconnect backoff settings used by the auto-reconnect path. Default is no backoff
    ///     (immediate retry — preserves the historical behaviour). Set to a non-zero
    ///     <see cref="ModbusReconnectOptions.InitialDelay"/> to sleep before the first reconnect
    ///     attempt; <see cref="ModbusReconnectOptions.MaxDelay"/> caps the geometric growth.
    /// </summary>
    public ModbusReconnectOptions Reconnect { get; init; } = new();

    /// <summary>
    ///     Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
    ///     60s server-side maximum; this default is what the form pre-fills for new instances.
    /// </summary>
    [Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
    [Range(1, 60)]
    public int ProbeTimeoutSeconds { get; init; } = 5;
}

/// <summary>OS-level TCP keep-alive knobs. Set <see cref="Enabled"/>=false to skip entirely.</summary>
public sealed class ModbusKeepAliveOptions
{
    /// <summary>Gets a value indicating whether keep-alive is enabled.</summary>
    public bool Enabled { get; init; } = true;
    /// <summary>Idle time before the first probe (seconds, mapped to <c>TcpKeepAliveTime</c>).</summary>
    public TimeSpan Time { get; init; } = TimeSpan.FromSeconds(30);
    /// <summary>Interval between probes once started (seconds, mapped to <c>TcpKeepAliveInterval</c>).</summary>
    public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(10);
    /// <summary>Probes before declaring the socket dead (mapped to <c>TcpKeepAliveRetryCount</c>).</summary>
    public int RetryCount { get; init; } = 3;
}

/// <summary>Geometric-backoff settings for the post-drop reconnect loop.</summary>
public sealed class ModbusReconnectOptions
{
    /// <summary>Delay before the first reconnect attempt. Default zero = immediate.</summary>
    public TimeSpan InitialDelay { get; init; } = TimeSpan.Zero;
    /// <summary>Upper bound on the geometric backoff sequence.</summary>
    public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
    /// <summary>Multiplier applied each retry. Default 2.0 doubles each step.</summary>
    public double BackoffMultiplier { get; init; } = 2.0;
}

public sealed class ModbusProbeOptions
{
    /// <summary>Gets a value indicating whether probing is enabled.</summary>
    public bool Enabled { get; init; } = true;
    /// <summary>Gets the interval between probe requests.</summary>
    public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
    /// <summary>Gets the probe request timeout.</summary>
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
    /// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
    public ushort ProbeAddress { get; init; } = 0;
}

/// <summary>
///     One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
///     the documentation's 1-based coil/register conventions). Multi-register types
///     (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
///     <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
/// </summary>
/// <param name="Name">
///     Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
///     unique within the driver.
/// </param>
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
/// <param name="Address">Zero-based address within the region.</param>
/// <param name="DataType">
///     Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
/// </param>
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
/// <param name="StringByteOrder">
///     Per-register byte order for <c>DataType = String</c>. Standard Modbus packs the first
///     character in the high byte (<see cref="ModbusStringByteOrder.HighByteFirst"/>).
///     AutomationDirect DirectLOGIC (DL205/DL260) and a few legacy families pack the first
///     character in the low byte instead — see <c>docs/v2/dl205.md</c> §strings.
/// </param>
/// <param name="WriteIdempotent">
///     Flags a tag as safe to replay on write timeout / failure. Default <c>false</c>; writes
///     do not auto-retry. Safe candidates:
///     holding-register set-points for analog values and configuration registers where the same
///     value can be written again without side-effects. Unsafe: coils that drive edge-triggered
///     actions (pulse outputs), counter-increment addresses on PLCs that treat writes as deltas,
///     any BCD / counter register where repeat-writes advance state.
/// </param>
/// <param name="ArrayCount">
///     When non-null, the tag is exposed as an OPC UA array of this many elements. Total
///     registers consumed = ArrayCount * registers-per-element. Bit + array is rejected at
///     bind time (no use case). Default null = scalar (existing behavior).
/// </param>
/// <param name="Deadband">
///     When non-null, the subscribe path suppresses a publish whenever
///     <c>|new - last_published| &lt; Deadband</c>. Reduces wire traffic on noisy analog
///     signals (flow meters, temperatures). Only meaningful for numeric scalar types
///     (Int*, UInt*, Float32, Float64, Bcd*); ignored for Bool / BitInRegister / String /
///     array tags. Default null = no deadband (every change publishes).
/// </param>
/// <param name="UnitId">
///     Per-tag UnitId override for multi-slave gateway topology. When non-null this
///     UnitId is used in the MBAP header instead of the driver-level <c>ModbusDriverOptions.UnitId</c>.
///     Defaults to null = use the driver-level value (preserves single-slave deployments).
///     Tags with different UnitIds belong to different physical slaves and the read planner
///     must NOT coalesce them across slaves — even at the same address.
/// </param>
/// <param name="CoalesceProhibited">
///     Escape hatch for block-read coalescing. When <c>true</c>, the planner reads this
///     tag in isolation regardless of <c>ModbusDriverOptions.MaxReadGap</c>. Use when the
///     surrounding registers are write-only or fault on read (some Schneider Premium / Siemens
///     PNs have protected holes). Default <c>false</c>.
/// </param>
public sealed record ModbusTagDefinition(
    string Name,
    ModbusRegion Region,
    ushort Address,
    ModbusDataType DataType,
    bool Writable = true,
    ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
    byte BitIndex = 0,
    ushort StringLength = 0,
    ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst,
    bool WriteIdempotent = false,
    int? ArrayCount = null,
    double? Deadband = null,
    byte? UnitId = null,
    bool CoalesceProhibited = false);
