using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;

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

/// <summary>
///     Static factory registration helper for <see cref="FocasDriver"/>. Server's
///     Program.cs calls <see cref="Register"/> once at startup; the bootstrapper
///     then materialises FOCAS DriverInstance rows from the central config DB
///     into live driver instances.
/// </summary>
/// <remarks>
///     The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
///     <list type="bullet">
///         <item><c>"Backend": "wire"</c> (default) — pure-managed FOCAS2 wire
///         client (<see cref="WireFocasClientFactory"/>) speaking directly to
///         the CNC on TCP:8193.</item>
///         <item><c>"Backend": "unimplemented"</c> / <c>"none"</c> / <c>"stub"</c>
///         — returns the no-op factory; useful for scaffolding DriverInstance
///         rows before the CNC endpoint is reachable.</item>
///     </list>
///     Devices / Tags / Probe / Timeout / Series come from the same JSON and
///     feed directly into <see cref="FocasDriverOptions"/>.
/// </remarks>
public static class FocasDriverFactoryExtensions
{
    public const string DriverTypeName = "FOCAS";

    /// <summary>
    ///     Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
    ///     Throws if 'FOCAS' is already registered — single-instance per process.
    /// </summary>
    /// <param name="registry">The driver factory registry to register with.</param>
    public static void Register(DriverFactoryRegistry registry)
    {
        ArgumentNullException.ThrowIfNull(registry);
        registry.Register(DriverTypeName, CreateInstance);
    }

    /// <summary>
    ///     Creates a new <see cref="FocasDriver"/> instance from the supplied configuration ID and JSON.
    /// </summary>
    /// <param name="driverInstanceId">The unique driver instance identifier.</param>
    /// <param name="driverConfigJson">The driver configuration JSON string.</param>
    /// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
    internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
    {
        var binding = ParseBinding(driverInstanceId, driverConfigJson);
        return new FocasDriver(
            binding.Options,
            driverInstanceId,
            binding.ClientFactory,
            // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
            // config-derived dependency on a reinitialize — options AND the backend client factory
            // together (#516). Re-parsing options alone would run a new device/tag set against the old
            // backend, which is worse than not re-parsing at all.
            rebind: json => ParseBinding(driverInstanceId, json));
    }

    /// <summary>
    ///     Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
    ///     from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
    ///     selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
    ///     <see cref="CreateInstance"/>.
    /// </summary>
    /// <param name="driverInstanceId">The unique driver instance identifier.</param>
    /// <param name="driverConfigJson">The driver configuration JSON string.</param>
    /// <returns>The parsed options + backend factory.</returns>
    internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
        ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);

        var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
            ?? throw new InvalidOperationException(
                $"FOCAS driver config for '{driverInstanceId}' deserialised to null");

        // Eager-validate top-level Series so a typo fails fast regardless of whether Devices
        // are populated yet (common during rollout when rows are seeded before CNCs arrive).
        _ = ParseSeries(dto.Series);

        var options = new FocasDriverOptions
        {
            Devices = dto.Devices is { Count: > 0 }
                ? [.. dto.Devices.Select(d => new FocasDeviceOptions(
                    HostAddress: d.HostAddress ?? throw new InvalidOperationException(
                        $"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
                    DeviceName: d.DeviceName,
                    Series: ParseSeries(d.Series ?? dto.Series),
                    PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))]
                : [],
            // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw FOCAS
            // tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each
            // RawTagEntry's TagConfig blob through FocasTagDefinitionFactory at Initialize and routes it to
            // its device by DeviceName; the legacy pre-declared DTO tag path is retired.
            RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
            Probe = new FocasProbeOptions
            {
                Enabled = dto.Probe?.Enabled ?? true,
                Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
                Timeout = PositiveTimeoutOrDefault(dto.Probe?.TimeoutMs, 2_000),
            },
            Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
            FixedTree = BuildFixedTree(dto.FixedTree),
            AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
            HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
        };

        return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
    }

    /// <summary>
    ///     Maps an operator-supplied timeout (ms) to a positive <see cref="TimeSpan"/>, substituting
    ///     <paramref name="defaultMs"/> for a null / zero / negative value. A non-positive
    ///     <c>Timeout</c> would make <see cref="SynchronizedFocasClient"/> DISABLE its per-call
    ///     wall-clock ceiling (falling back to the caller's long-lived poll token), reintroducing the
    ///     exact unbounded-read-on-a-frozen-peer wedge the S7 R2-01 read-leg fix eliminated. Clamping
    ///     here at the config boundary guarantees the deadline can never be authored away.
    /// </summary>
    /// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
    /// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
    /// <returns>A strictly positive timeout.</returns>
    private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
        TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);

    /// <summary>
    ///     Builds the appropriate <see cref="IFocasClientFactory"/> based on the config DTO's backend selection.
    /// </summary>
    /// <param name="dto">The driver configuration DTO.</param>
    /// <param name="driverInstanceId">The driver instance identifier for error reporting.</param>
    /// <returns>A configured <see cref="IFocasClientFactory"/> instance.</returns>
    internal static IFocasClientFactory BuildClientFactory(
        FocasDriverConfigDto dto, string driverInstanceId)
    {
        var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
        return backend switch
        {
            "wire" => new WireFocasClientFactory(),
            "unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
            _ => throw new InvalidOperationException(
                $"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
                "Expected one of: wire, unimplemented. " +
                "(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " +
                "see docs/drivers/FOCAS.md.)"),
        };
    }

    /// <summary>
    ///     Map the optional <c>FixedTree</c> config section onto <see cref="FocasFixedTreeOptions"/>.
    ///     A missing section keeps the hard-coded defaults (tree disabled); a present section
    ///     with omitted intervals keeps each interval's default. Interval fields are JSON
    ///     <see cref="TimeSpan"/> strings (e.g. <c>"00:00:00.250"</c>) per docs/drivers/FOCAS.md.
    /// </summary>
    private static FocasFixedTreeOptions BuildFixedTree(FocasFixedTreeDto? dto)
    {
        if (dto is null) return new FocasFixedTreeOptions();
        var defaults = new FocasFixedTreeOptions();
        return new FocasFixedTreeOptions
        {
            Enabled = dto.Enabled ?? defaults.Enabled,
            PollInterval = dto.PollInterval ?? defaults.PollInterval,
            ProgramPollInterval = dto.ProgramPollInterval ?? defaults.ProgramPollInterval,
            TimerPollInterval = dto.TimerPollInterval ?? defaults.TimerPollInterval,
        };
    }

    /// <summary>
    ///     Map the optional <c>AlarmProjection</c> config section onto
    ///     <see cref="FocasAlarmProjectionOptions"/>. Missing section keeps the disabled default.
    /// </summary>
    private static FocasAlarmProjectionOptions BuildAlarmProjection(FocasAlarmProjectionDto? dto)
    {
        if (dto is null) return new FocasAlarmProjectionOptions();
        var defaults = new FocasAlarmProjectionOptions();
        return new FocasAlarmProjectionOptions
        {
            Enabled = dto.Enabled ?? defaults.Enabled,
            PollInterval = dto.PollInterval ?? defaults.PollInterval,
        };
    }

    /// <summary>
    ///     Map the optional <c>HandleRecycle</c> config section onto
    ///     <see cref="FocasHandleRecycleOptions"/>. Missing section keeps the disabled default.
    /// </summary>
    private static FocasHandleRecycleOptions BuildHandleRecycle(FocasHandleRecycleDto? dto)
    {
        if (dto is null) return new FocasHandleRecycleOptions();
        var defaults = new FocasHandleRecycleOptions();
        return new FocasHandleRecycleOptions
        {
            Enabled = dto.Enabled ?? defaults.Enabled,
            Interval = dto.Interval ?? defaults.Interval,
        };
    }

    private static FocasCncSeries ParseSeries(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
        return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
            ? s
            : throw new InvalidOperationException(
                $"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
    }

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        ReadCommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true,
    };

    /// <summary>
    ///     Reads a JSON property as a string, tolerating a JSON <b>number</b> token as well. The
    ///     AdminUI persists the FOCAS <c>Series</c> enum as its integer value (e.g. <c>"series":6</c>),
    ///     while this DTO models <c>Series</c> as a string handed to <see cref="ParseSeries"/>
    ///     (Enum.TryParse accepts the numeric form). Without this, System.Text.Json throws
    ///     "Cannot get the value of a token type 'Number' as a string" on the bare number and the
    ///     driver falls back to a stub. Accepts string / number / null and emits a string.
    /// </summary>
    internal sealed class FlexibleStringConverter : JsonConverter<string?>
    {
        /// <inheritdoc />
        public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
            reader.TokenType switch
            {
                JsonTokenType.String => reader.GetString(),
                JsonTokenType.Number => reader.TryGetInt64(out var n)
                    ? n.ToString(System.Globalization.CultureInfo.InvariantCulture)
                    : reader.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture),
                JsonTokenType.Null => null,
                _ => throw new JsonException($"Expected string, number, or null but got {reader.TokenType}."),
            };

        /// <inheritdoc />
        public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
        {
            if (value is null) writer.WriteNullValue();
            else writer.WriteStringValue(value);
        }
    }

    internal sealed class FocasDriverConfigDto
    {
        /// <summary>Gets or sets the FOCAS client factory backend name (e.g. "wire" or "stub").</summary>
        public string? Backend { get; init; }

        /// <summary>Gets or sets the CNC series for this driver.</summary>
        [JsonConverter(typeof(FlexibleStringConverter))]
        public string? Series { get; init; }

        /// <summary>Gets or sets the operation timeout in milliseconds.</summary>
        public int? TimeoutMs { get; init; }

        /// <summary>Gets or sets the list of CNC devices to manage.</summary>
        public List<FocasDeviceDto>? Devices { get; init; }

        /// <summary>
        ///     Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
        ///     the driver serves. Bound verbatim into <see cref="FocasDriverOptions.RawTags"/>.
        /// </summary>
        public List<RawTagEntry>? RawTags { get; init; }

        /// <summary>Gets or sets the probe configuration options.</summary>
        public FocasProbeDto? Probe { get; init; }

        /// <summary>Gets or sets the fixed-tree configuration options.</summary>
        public FocasFixedTreeDto? FixedTree { get; init; }

        /// <summary>Gets or sets the alarm projection configuration options.</summary>
        public FocasAlarmProjectionDto? AlarmProjection { get; init; }

        /// <summary>Gets or sets the handle recycle configuration options.</summary>
        public FocasHandleRecycleDto? HandleRecycle { get; init; }
    }

    internal sealed class FocasDeviceDto
    {
        /// <summary>Gets or sets the hostname or IP address of the CNC.</summary>
        public string? HostAddress { get; init; }

        /// <summary>Gets or sets the logical device name.</summary>
        public string? DeviceName { get; init; }

        /// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
        [JsonConverter(typeof(FlexibleStringConverter))]
        public string? Series { get; init; }

        /// <summary>
        ///     Gets or sets the axis-position decimal places. <c>cnc_rddynamic2</c> returns
        ///     positions as scaled integers; the driver divides by <c>10^PositionDecimalPlaces</c>
        ///     so they surface in engineering units. Omitted / <c>0</c> = no scaling (legacy).
        /// </summary>
        public int? PositionDecimalPlaces { get; init; }
    }

    internal sealed class FocasProbeDto
    {
        /// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
        public bool? Enabled { get; init; }

        /// <summary>Gets or sets the probe interval in milliseconds.</summary>
        public int? IntervalMs { get; init; }

        /// <summary>Gets or sets the probe timeout in milliseconds.</summary>
        public int? TimeoutMs { get; init; }
    }

    /// <summary>
    ///     Optional <c>FixedTree</c> config section. Interval fields are JSON
    ///     <see cref="TimeSpan"/> strings (<c>"hh:mm:ss[.fff]"</c>) — System.Text.Json
    ///     parses these natively.
    /// </summary>
    internal sealed class FocasFixedTreeDto
    {
        /// <summary>Gets or sets a value indicating whether fixed-tree discovery is enabled.</summary>
        public bool? Enabled { get; init; }

        /// <summary>Gets or sets the poll interval for discovering fixed-tree items.</summary>
        public TimeSpan? PollInterval { get; init; }

        /// <summary>Gets or sets the poll interval for discovering program-related items.</summary>
        public TimeSpan? ProgramPollInterval { get; init; }

        /// <summary>Gets or sets the poll interval for discovering timer-related items.</summary>
        public TimeSpan? TimerPollInterval { get; init; }
    }

    /// <summary>Optional <c>AlarmProjection</c> config section.</summary>
    internal sealed class FocasAlarmProjectionDto
    {
        /// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
        public bool? Enabled { get; init; }

        /// <summary>Gets or sets the alarm poll interval.</summary>
        public TimeSpan? PollInterval { get; init; }
    }

    /// <summary>Optional <c>HandleRecycle</c> config section.</summary>
    internal sealed class FocasHandleRecycleDto
    {
        /// <summary>Gets or sets a value indicating whether handle recycling is enabled.</summary>
        public bool? Enabled { get; init; }

        /// <summary>Gets or sets the handle recycle interval.</summary>
        public TimeSpan? Interval { get; init; }
    }
}

/// <summary>
///     Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
///     reinitialize adopts all of it atomically.
///     <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
///     <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
///     <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);
