using System.Globalization;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>
/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 <see cref="CsvParser"/> /
/// <see cref="CsvWriter"/> and the per-driver <see cref="CsvColumnMap"/> to the service's
/// <see cref="RawTagImportRow"/> commit shape: <see cref="Parse"/> turns an uploaded CSV into a
/// per-row review model with validation verdicts; <see cref="Export"/> renders a device's tags back to
/// the same column shape. No I/O, no DB, no Blazor — unit-testable in isolation.
/// <para>
/// A per-tag driver <c>TagConfig</c> JSON is assembled in a fixed precedence:
/// <c>TagConfigJson</c> (base) ← typed driver columns (override) ← the alarm / historize / array flag
/// columns. A typed column therefore <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c>
/// fallback; the review model flags when that override happened so the operator sees it.
/// </para>
/// </summary>
public static class RawTagCsvMapper
{
    // Root TagConfig keys owned by the flag columns — stripped from the export residual so the
    // TagConfigJson fallback never duplicates a value already carried by a dedicated column.
    private static readonly string[] FlagKeys =
        ["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"];

    // ---------------------------------------------------------------------------------------- Import

    /// <summary>
    /// Parses an uploaded CSV into a review model: one <see cref="RawCsvReviewRow"/> per data row with a
    /// validation verdict, the assembled <see cref="RawTagImportRow"/> (when valid), and the typed vs.
    /// <c>TagConfigJson</c>-fallback provenance. Intra-batch duplicate names (same effective group + name)
    /// are flagged on the later row. This is a dry parse — nothing is committed.
    /// </summary>
    /// <param name="driverType">The target device's driver-type string (selects the typed column map).</param>
    /// <param name="csvText">The uploaded CSV document.</param>
    /// <param name="groupPathPrefix">The target tag-group's path prefix (from the modal's TagGroupId), prepended
    /// to every row's <c>TagGroupPath</c>; null/blank when importing at the device root.</param>
    /// <returns>The parse result: header list, per-row review rows, and any fatal (whole-file) error.</returns>
    public static RawCsvParseResult Parse(string driverType, string? csvText, string? groupPathPrefix = null)
    {
        IReadOnlyList<string[]> raw;
        try
        {
            raw = CsvParser.Parse(csvText);
        }
        catch (FormatException ex)
        {
            return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), $"Malformed CSV: {ex.Message}");
        }

        if (raw.Count == 0)
        {
            return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), "The file is empty.");
        }

        var headers = raw[0];
        var headerIndex = new Dictionary<string, int>(StringComparer.Ordinal);
        for (var i = 0; i < headers.Length; i++)
        {
            if (!headerIndex.TryAdd(headers[i], i))
            {
                return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), $"Duplicate header column '{headers[i]}'.");
            }
        }

        if (!headerIndex.ContainsKey("Name"))
        {
            return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), "Required column 'Name' is missing from the header row.");
        }

        var map = CsvColumnMap.Resolve(driverType);
        var reviewed = new List<RawCsvReviewRow>(raw.Count - 1);

        // Skip a wholly-blank trailing/interstitial line (the RFC parser yields [""] for an empty line).
        for (var r = 1; r < raw.Count; r++)
        {
            var cells = raw[r];
            if (cells.Length == 1 && cells[0].Length == 0)
            {
                continue;
            }

            reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix));
        }

        FlagDuplicates(reviewed);
        return new RawCsvParseResult(headers, reviewed, null);
    }

    private static RawCsvReviewRow BuildRow(
        int rowNumber,
        string[] headers,
        IReadOnlyDictionary<string, int> headerIndex,
        string[] cells,
        CsvDriverTagMap? map,
        string? groupPathPrefix)
    {
        var row = new Dictionary<string, string>(StringComparer.Ordinal);
        foreach (var (name, idx) in headerIndex)
        {
            row[name] = idx < cells.Length ? cells[idx] : "";
        }

        string Cell(string name) => row.TryGetValue(name, out var v) ? v.Trim() : "";

        var typedUsed = new List<string>();
        var overrodeFallback = new List<string>();

        try
        {
            // --- Common identity/access fields ---
            var name = Cell("Name");
            if (RawPaths.ValidateSegment(name) is { } nameErr)
            {
                return Invalid(rowNumber, cells, $"Name: {nameErr}");
            }

            var dataType = Cell("DataType");
            if (dataType.Length == 0)
            {
                return Invalid(rowNumber, cells, "DataType is required.");
            }

            var accessRaw = Cell("AccessLevel");
            var access = accessRaw.Length == 0
                ? TagAccessLevel.Read
                : CsvValues.ParseEnum<TagAccessLevel>("AccessLevel", accessRaw);

            var writeIdemRaw = Cell("WriteIdempotent");
            var writeIdempotent = writeIdemRaw.Length != 0 && CsvValues.ParseBool("WriteIdempotent", writeIdemRaw);

            var pollGroup = Cell("PollGroup");
            var pollGroupId = pollGroup.Length == 0 ? null : pollGroup;

            var groupPath = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath"));

            // --- Assemble the TagConfig JSON: base ← typed ← flags ---
            var baseJson = Cell(CsvColumnMap.TagConfigJsonColumn);
            var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid);
            if (!baseValid)
            {
                return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON.");
            }

            if (map is not null)
            {
                foreach (var col in map.Columns)
                {
                    if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv))
                    {
                        typedUsed.Add(col.Header);
                        if (baseKeys.Contains(col.JsonKey))
                        {
                            overrodeFallback.Add(col.Header);
                        }
                    }
                }
            }

            var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
                       ?? (baseJson.Length == 0 ? "{}" : baseJson);

            // Historize flags.
            var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
            json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));

            // Array flags.
            var isArray = ParseFlag("IsArray", Cell("IsArray"));
            uint? arrayLength = null;
            var arrayLenRaw = Cell("ArrayLength");
            if (arrayLenRaw.Length != 0)
            {
                var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw);
                if (n < 0)
                {
                    return Invalid(rowNumber, cells, "ArrayLength must not be negative.");
                }

                arrayLength = (uint)n;
            }

            if (TagArrayConfig.Validate(isArray, arrayLength) is { } arrErr)
            {
                return Invalid(rowNumber, cells, arrErr);
            }

            json = TagArrayConfig.Set(json, isArray, arrayLength);

            // Alarm flags — an alarm is authored iff Alarm.AlarmType is present.
            json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva"));

            var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));

            return new RawCsvReviewRow
            {
                RowNumber = rowNumber,
                Cells = cells,
                Headers = headers,
                Error = null,
                ImportRow = importRow,
                TypedColumnsUsed = typedUsed,
                FallbackKeys = baseKeys,
                TypedOverrodeFallback = overrodeFallback,
                EffectiveGroupPath = groupPath ?? "",
                Name = name,
            };
        }
        catch (FormatException ex)
        {
            return Invalid(rowNumber, cells, ex.Message);
        }
    }

    private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new()
    {
        RowNumber = rowNumber,
        Cells = cells,
        Headers = Array.Empty<string>(),
        Error = error,
        TypedColumnsUsed = Array.Empty<string>(),
        FallbackKeys = Array.Empty<string>(),
        TypedOverrodeFallback = Array.Empty<string>(),
    };

    // Flags a same-(group+name) collision within the parsed batch on the SECOND-and-later row.
    private static void FlagDuplicates(List<RawCsvReviewRow> rows)
    {
        var seen = new HashSet<(string, string)>();
        foreach (var row in rows.Where(r => r.Ok))
        {
            if (!seen.Add((row.EffectiveGroupPath, row.Name)))
            {
                var where = row.EffectiveGroupPath.Length == 0 ? "the device root" : $"group '{row.EffectiveGroupPath}'";
                row.Error = $"Duplicate tag name '{row.Name}' under {where} (already appears earlier in this file).";
                row.ImportRow = null;
            }
        }
    }

    private static string? CombineGroupPath(string? prefix, string cell)
    {
        var suffix = string.IsNullOrWhiteSpace(cell) ? null : cell.Trim();
        prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
        if (prefix is null)
        {
            return suffix;
        }

        return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
    }

    private static bool ParseFlag(string header, string cell)
        => cell.Length != 0 && CsvValues.ParseBool(header, cell);

    private static string ApplyAlarm(string json, string alarmType, string severityRaw, string histRaw)
    {
        if (alarmType.Length == 0)
        {
            return json;
        }

        var model = NativeAlarmModel.FromJson(json);
        model.IsAlarm = true;
        model.AlarmType = alarmType;
        model.Severity = severityRaw.Length == 0 ? model.Severity : CsvValues.ParseInt("Alarm.Severity", severityRaw);
        model.HistorizeToAveva = histRaw.Length == 0 ? null : CsvValues.ParseBool("Alarm.HistorizeToAveva", histRaw);
        return model.ToJson();
    }

    private static IReadOnlyCollection<string> ReadRootKeys(string json, out bool valid)
    {
        JsonNode? node;
        try
        {
            node = JsonNode.Parse(json);
        }
        catch (System.Text.Json.JsonException)
        {
            valid = false;
            return Array.Empty<string>();
        }

        if (node is not JsonObject obj)
        {
            valid = false;
            return Array.Empty<string>();
        }

        valid = true;
        return obj.Select(kvp => kvp.Key).ToArray();
    }

    // ---------------------------------------------------------------------------------------- Export

    /// <summary>
    /// Renders a device's tags to a CSV string in the same column shape <see cref="Parse"/> consumes:
    /// the common columns, the driver's typed columns, then the trailing <c>TagConfigJson</c> fallback
    /// carrying only the residual keys (those not surfaced by a typed or flag column) so a round-trip is
    /// exact and non-redundant.
    /// </summary>
    /// <param name="driverType">The device's driver-type string (selects the typed column set).</param>
    /// <param name="tags">The tags to export.</param>
    /// <returns>The CSV document (CRLF, quote-on-demand).</returns>
    public static string Export(string driverType, IEnumerable<RawCsvExportTag> tags)
    {
        var map = CsvColumnMap.Resolve(driverType);
        var typedHeaders = map?.Headers ?? Array.Empty<string>();
        var header = CsvColumnMap.HeadersFor(driverType);

        var rows = new List<string[]> { header.ToArray() };
        foreach (var tag in tags)
        {
            rows.Add(BuildExportRow(tag, map, typedHeaders));
        }

        return CsvWriter.WriteToString(rows);
    }

    private static string[] BuildExportRow(RawCsvExportTag tag, CsvDriverTagMap? map, IReadOnlyList<string> typedHeaders)
    {
        var json = string.IsNullOrWhiteSpace(tag.TagConfig) ? "{}" : tag.TagConfig;

        var hist = TagHistorizeConfig.Read(json);
        var array = TagArrayConfig.Read(json);
        var alarm = NativeAlarmModel.FromJson(json);
        var typed = map?.ReadTypedColumns(json) ?? new Dictionary<string, string>();

        var cells = new List<string>(CsvColumnMap.CommonLeadingColumns.Count + typedHeaders.Count + 1)
        {
            tag.Name,
            tag.TagGroupPath ?? "",
            tag.DataType,
            tag.AccessLevel.ToString(),
            tag.WriteIdempotent ? "true" : "false",
            tag.PollGroupId ?? "",
            hist.IsHistorized ? "true" : "",
            hist.HistorianTagname,
            array.IsArray ? "true" : "",
            array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "",
            alarm.IsAlarm ? alarm.AlarmType : "",
            alarm.IsAlarm ? alarm.Severity.ToString(CultureInfo.InvariantCulture) : "",
            alarm.IsAlarm ? (CsvValues.FormatNullableBool(alarm.HistorizeToAveva) ?? "") : "",
        };

        foreach (var h in typedHeaders)
        {
            cells.Add(typed.TryGetValue(h, out var v) ? v : "");
        }

        cells.Add(ComputeResidual(json, map));
        return cells.ToArray();
    }

    // The TagConfigJson fallback cell: the tag's root keys minus every key a typed or flag column already
    // surfaces. Empty object ⇒ blank cell.
    private static string ComputeResidual(string json, CsvDriverTagMap? map)
    {
        var o = TagConfigJson.ParseOrNew(json);
        foreach (var key in map?.JsonKeys ?? Array.Empty<string>())
        {
            o.Remove(key);
        }

        foreach (var key in FlagKeys)
        {
            o.Remove(key);
        }

        return o.Count == 0 ? "" : TagConfigJson.Serialize(o);
    }
}

/// <summary>
/// One tag flattened for CSV export: its effective tag-group path under the device plus the operator-facing
/// fields. Produced by <see cref="RawTagCsvExportReader"/> and consumed by <see cref="RawTagCsvMapper.Export"/>.
/// </summary>
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for a device-root tag.</param>
/// <param name="Name">The tag name.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="AccessLevel">The tag's access-level baseline.</param>
/// <param name="WriteIdempotent">Whether writes are retry-eligible.</param>
/// <param name="PollGroupId">The optional poll-group id.</param>
/// <param name="TagConfig">The raw per-driver TagConfig JSON blob.</param>
public sealed record RawCsvExportTag(
    string? TagGroupPath,
    string Name,
    string DataType,
    TagAccessLevel AccessLevel,
    bool WriteIdempotent,
    string? PollGroupId,
    string TagConfig);

/// <summary>
/// One reviewed CSV data row: its 1-based row number, raw cells, validation verdict, and — when valid —
/// the assembled <see cref="RawTagImportRow"/> plus the typed vs. <c>TagConfigJson</c>-fallback provenance
/// the review grid renders.
/// </summary>
public sealed class RawCsvReviewRow
{
    /// <summary>The 1-based data-row number in the source file (header is row 0).</summary>
    public int RowNumber { get; init; }

    /// <summary>The raw cell values as parsed.</summary>
    public IReadOnlyList<string> Cells { get; init; } = Array.Empty<string>();

    /// <summary>The header row (for aligning <see cref="Cells"/> in the grid).</summary>
    public IReadOnlyList<string> Headers { get; init; } = Array.Empty<string>();

    /// <summary>The validation error, or <c>null</c> when the row is valid.</summary>
    public string? Error { get; set; }

    /// <summary>Whether the row is valid (commit-eligible).</summary>
    public bool Ok => Error is null;

    /// <summary>The assembled import row when valid; <c>null</c> otherwise.</summary>
    public RawTagImportRow? ImportRow { get; set; }

    /// <summary>The typed-column headers that supplied a value on this row.</summary>
    public IReadOnlyList<string> TypedColumnsUsed { get; init; } = Array.Empty<string>();

    /// <summary>The root keys present in the row's <c>TagConfigJson</c> fallback.</summary>
    public IReadOnlyCollection<string> FallbackKeys { get; init; } = Array.Empty<string>();

    /// <summary>The typed columns that overrode a conflicting key carried in <c>TagConfigJson</c>.</summary>
    public IReadOnlyList<string> TypedOverrodeFallback { get; init; } = Array.Empty<string>();

    /// <summary>The effective tag-group path (prefix + row's TagGroupPath) used for duplicate detection.</summary>
    public string EffectiveGroupPath { get; init; } = "";

    /// <summary>The tag name (echoed for duplicate detection + grid display).</summary>
    public string Name { get; init; } = "";
}

/// <summary>
/// The outcome of <see cref="RawTagCsvMapper.Parse"/>: the parsed header row, the per-row review model, and
/// a fatal (whole-file) error when the document could not be parsed at all (malformed CSV, duplicate/absent
/// required header). When <see cref="FatalError"/> is set, <see cref="Rows"/> is empty.
/// </summary>
/// <param name="Headers">The parsed header row.</param>
/// <param name="Rows">The per-row review model.</param>
/// <param name="FatalError">A whole-file error, or null when the file parsed.</param>
public sealed record RawCsvParseResult(
    IReadOnlyList<string> Headers,
    IReadOnlyList<RawCsvReviewRow> Rows,
    string? FatalError)
{
    /// <summary>Whether the file parsed and every row is valid (commit is unblocked).</summary>
    public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok);

    /// <summary>The valid rows' assembled import rows, ready for <c>ImportTagsAsync</c>.</summary>
    public IReadOnlyList<RawTagImportRow> ToImportRows()
        => Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray();
}
