using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Microsoft.Win32;

var options = Options.Parse(args);
if (!File.Exists(options.InputJsonl))
    throw new FileNotFoundException($"ownership JSONL not found: {options.InputJsonl}");
if (!File.Exists(options.SourceDwg))
    throw new FileNotFoundException($"source DWG not found: {options.SourceDwg}");
if (string.IsNullOrWhiteSpace(options.OutputDwg))
    throw new ArgumentException("--output-dwg is required.");

Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.OutputDwg))!);
File.Copy(options.SourceDwg, options.OutputDwg, overwrite: true);

var records = ReadPointRecords(options.InputJsonl).ToList();
Console.OutputEncoding = Encoding.UTF8;

if (options.ColorEntities)
{
    var colorResult = ColorDwgEntities(options.OutputDwg, records, options);
    WriteEntityColorReport(options, records.Count, colorResult);
    WriteEntityColorLegend(options, records.Count, colorResult);
    Console.WriteLine($"input_points={records.Count}");
    Console.WriteLine($"entity_candidates={colorResult.CandidateCount}");
    Console.WriteLine($"colored_entities={colorResult.Colored.Count}");
    Console.WriteLine($"skipped_entities={colorResult.SkippedCount}");
    Console.WriteLine($"output_dwg={Path.GetFullPath(options.OutputDwg)}");
    Console.WriteLine($"report={Path.GetFullPath(options.ReportPath)}");
    Console.WriteLine($"legend={Path.GetFullPath(options.LegendPath)}");
    foreach (var item in colorResult.Colored.Take(30))
        Console.WriteLine($"color={item.ColorIndex} handle={item.Handle} kind={item.ObjectName} sample=({item.SampleX:F3},{item.SampleY:F3}) distance={item.DistanceMm:F3} {item.DisplayName}");
}
else
{
    var groups = BuildGroups(records, options).ToList();
    var contours = BuildContours(groups, options).ToList();

    DrawContours(options.OutputDwg, contours, options);
    WriteReport(options, records.Count, groups.Count, contours);

    Console.WriteLine($"input_points={records.Count}");
    Console.WriteLine($"groups={groups.Count}");
    Console.WriteLine($"contours={contours.Count}");
    Console.WriteLine($"output_dwg={Path.GetFullPath(options.OutputDwg)}");
    Console.WriteLine($"report={Path.GetFullPath(options.ReportPath)}");
    foreach (var contour in contours.Take(30))
    {
        Console.WriteLine(
            $"color={contour.ColorIndex} pts={contour.PointCount} bbox_mm=({contour.MinX:F3},{contour.MinY:F3},{contour.MaxX:F3},{contour.MaxY:F3}) {contour.DisplayName}");
    }
}

static IEnumerable<PointRecord> ReadPointRecords(string path)
{
    foreach (var line in File.ReadLines(path, Encoding.UTF8))
    {
        if (string.IsNullOrWhiteSpace(line))
            continue;

        using var doc = JsonDocument.Parse(line);
        var root = doc.RootElement;
        if (!string.Equals(ReadString(root, "Kind", "kind"), "point", StringComparison.OrdinalIgnoreCase))
            continue;
        if (!ReadBool(root, "Hit", "hit"))
            continue;

        var ownership = root.TryGetProperty("Ownership", out var pascal)
            ? pascal
            : root.TryGetProperty("ownership", out var camel) ? camel : default;
        if (ownership.ValueKind == JsonValueKind.Undefined)
            continue;

        var rawKey = ReadString(ownership, "EntityComponent", "entity_component");
        if (string.IsNullOrWhiteSpace(rawKey) || string.Equals(rawKey, "null", StringComparison.OrdinalIgnoreCase))
            continue;

        // Section lines and other drawing annotations can be IEntity but do not
        // represent a component ownership region.
        var selectedObject = ReadString(root, "SelectedObject", "selected_object");
        if (selectedObject.Contains("DrSectionLine", StringComparison.OrdinalIgnoreCase))
            continue;

        var x = ReadDouble(root, "SheetXMm", "sheet_x_mm", "SheetXMM");
        var y = ReadDouble(root, "SheetYMm", "sheet_y_mm", "SheetYMM");
        if (!double.IsFinite(x) || !double.IsFinite(y))
            continue;

        var row = ReadInt(root, "Row", "row");
        var column = ReadInt(root, "Column", "column");
        var key = NormalizeOwnershipKey(rawKey);
        yield return new PointRecord(key, DisplayNameFromKey(key), x, y, row, column);
    }
}

static IEnumerable<ComponentGroup> BuildGroups(List<PointRecord> records, Options options)
{
    var colorIndex = 0;
    foreach (var byOwner in records.GroupBy(r => r.Key, StringComparer.OrdinalIgnoreCase)
                 .OrderByDescending(g => g.Count()))
    {
        var points = byOwner.ToList();
        if (points.Count < options.MinGroupPoints)
            continue;

        var clusters = SplitConnectedClusters(points, options.StepMm * 1.55)
            .Where(cluster => cluster.Count >= options.MinClusterPoints)
            .OrderByDescending(cluster => cluster.Count)
            .ToList();

        var ownerColor = options.Colors[colorIndex % options.Colors.Length];
        colorIndex++;
        for (var i = 0; i < clusters.Count; i++)
        {
            yield return new ComponentGroup(
                byOwner.Key,
                points[0].DisplayName,
                i + 1,
                clusters[i],
                ownerColor);
        }
    }
}

static List<List<PointRecord>> SplitConnectedClusters(List<PointRecord> points, double maxDistanceMm)
{
    var clusters = new List<List<PointRecord>>();
    var visited = new bool[points.Count];
    var maxDistanceSquared = maxDistanceMm * maxDistanceMm;

    for (var i = 0; i < points.Count; i++)
    {
        if (visited[i])
            continue;

        var cluster = new List<PointRecord>();
        var queue = new Queue<int>();
        visited[i] = true;
        queue.Enqueue(i);

        while (queue.Count > 0)
        {
            var current = queue.Dequeue();
            var point = points[current];
            cluster.Add(point);

            for (var j = 0; j < points.Count; j++)
            {
                if (visited[j])
                    continue;

                var other = points[j];
                var dx = point.XMm - other.XMm;
                var dy = point.YMm - other.YMm;
                if (dx * dx + dy * dy > maxDistanceSquared)
                    continue;

                visited[j] = true;
                queue.Enqueue(j);
            }
        }

        clusters.Add(cluster);
    }

    return clusters;
}

static IEnumerable<Contour> BuildContours(List<ComponentGroup> groups, Options options)
{
    foreach (var group in groups)
    {
        var unique = group.Points
            .GroupBy(point => (X: Round(point.XMm), Y: Round(point.YMm)))
            .Select(g => new HullPoint(g.Key.X, g.Key.Y))
            .OrderBy(point => point.X)
            .ThenBy(point => point.Y)
            .ToList();

        var hull = unique.Count >= 3 ? ConvexHull(unique) : unique;
        var padded = PadPolygonFromCentroid(hull, options.PadMm);
        if (padded.Count == 1)
            padded = SmallRectangle(padded[0], options.PadMm);
        else if (padded.Count == 2)
            padded = SegmentRectangle(padded[0], padded[1], options.PadMm);

        var minX = group.Points.Min(point => point.XMm) - options.PadMm;
        var minY = group.Points.Min(point => point.YMm) - options.PadMm;
        var maxX = group.Points.Max(point => point.XMm) + options.PadMm;
        var maxY = group.Points.Max(point => point.YMm) + options.PadMm;

        yield return new Contour(
            group.Key,
            group.DisplayName,
            group.ClusterIndex,
            group.Points.Count,
            group.ColorIndex,
            minX,
            minY,
            maxX,
            maxY,
            padded);
    }
}

static List<HullPoint> ConvexHull(List<HullPoint> points)
{
    if (points.Count <= 1)
        return points;

    var lower = new List<HullPoint>();
    foreach (var point in points)
    {
        while (lower.Count >= 2 && Cross(lower[^2], lower[^1], point) <= 0)
            lower.RemoveAt(lower.Count - 1);
        lower.Add(point);
    }

    var upper = new List<HullPoint>();
    for (var i = points.Count - 1; i >= 0; i--)
    {
        var point = points[i];
        while (upper.Count >= 2 && Cross(upper[^2], upper[^1], point) <= 0)
            upper.RemoveAt(upper.Count - 1);
        upper.Add(point);
    }

    lower.RemoveAt(lower.Count - 1);
    upper.RemoveAt(upper.Count - 1);
    lower.AddRange(upper);
    return lower;
}

static double Cross(HullPoint origin, HullPoint a, HullPoint b)
{
    return (a.X - origin.X) * (b.Y - origin.Y) - (a.Y - origin.Y) * (b.X - origin.X);
}

static List<HullPoint> PadPolygonFromCentroid(List<HullPoint> points, double padMm)
{
    if (points.Count == 0)
        return points;

    var cx = points.Average(point => point.X);
    var cy = points.Average(point => point.Y);
    return points.Select(point =>
    {
        var dx = point.X - cx;
        var dy = point.Y - cy;
        var length = Math.Sqrt(dx * dx + dy * dy);
        if (length < 1e-9)
            return point;
        return new HullPoint(point.X + dx / length * padMm, point.Y + dy / length * padMm);
    }).ToList();
}

static List<HullPoint> SmallRectangle(HullPoint point, double padMm)
{
    var pad = Math.Max(1.0, padMm);
    return
    [
        new(point.X - pad, point.Y - pad),
        new(point.X + pad, point.Y - pad),
        new(point.X + pad, point.Y + pad),
        new(point.X - pad, point.Y + pad)
    ];
}

static List<HullPoint> SegmentRectangle(HullPoint a, HullPoint b, double padMm)
{
    var pad = Math.Max(1.0, padMm);
    var dx = b.X - a.X;
    var dy = b.Y - a.Y;
    var length = Math.Sqrt(dx * dx + dy * dy);
    if (length < 1e-9)
        return SmallRectangle(a, pad);

    var nx = -dy / length * pad;
    var ny = dx / length * pad;
    return
    [
        new(a.X + nx, a.Y + ny),
        new(b.X + nx, b.Y + ny),
        new(b.X - nx, b.Y - ny),
        new(a.X - nx, a.Y - ny)
    ];
}

static void DrawContours(string dwgPath, List<Contour> contours, Options options)
{
    var app = StartOrConnectAutoCad(options.ProgId);
    SetProperty(app, "Visible", true);

    object? doc = null;
    try
    {
        var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD documents collection unavailable.");
        doc = Invoke(documents, "Open", Path.GetFullPath(dwgPath))
            ?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {dwgPath}");
        var modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("AutoCAD ModelSpace unavailable.");

        var textStyleName = options.DrawLabels ? EnsureChineseTextStyle(doc, options) : "";
        foreach (var contour in contours)
        {
            var layerName = $"AG4_OWN_{contour.ColorIndex:00}_{contour.ClusterIndex:00}";
            EnsureLayer(doc, layerName, contour.ColorIndex);
            var coordinates = ToPolylineCoordinates(contour.Points);
            var polyline = Invoke(modelSpace, "AddLightWeightPolyline", coordinates);
            if (polyline == null)
                continue;

            SetProperty(polyline, "Closed", true);
            SetProperty(polyline, "Layer", layerName);
            SetProperty(polyline, "Color", contour.ColorIndex);
            SetProperty(polyline, "Lineweight", options.Lineweight);
            Safe(() => Invoke(polyline, "Update"));

            if (options.DrawLabels)
                DrawLabel(modelSpace, contour, layerName, textStyleName, options);
        }

        Invoke(doc, "SaveAs", Path.GetFullPath(dwgPath));
    }
    finally
    {
        if (doc != null && options.CloseDocument)
            Safe(() => Invoke(doc, "Close", false));
    }
}

static EntityColorResult ColorDwgEntities(string dwgPath, List<PointRecord> records, Options options)
{
    if (records.Count == 0)
        throw new InvalidOperationException("No ownership records are available for entity coloring.");

    var owners = BuildOwnerStyles(records, options).ToDictionary(owner => owner.Key, StringComparer.OrdinalIgnoreCase);
    var app = StartOrConnectAutoCad(options.ProgId);
    SetProperty(app, "Visible", true);

    object? doc = null;
    var colored = new List<EntityColorRecord>();
    var candidateCount = 0;
    var skipped = new List<EntitySkipRecord>();
    var pending = new List<PendingEntityColor>();
    var unassigned = new List<UnassignedEntityColor>();
    var fillChanges = new List<FillChangeRecord>();
    var continuityChanges = new List<ContinuityChangeRecord>();

    try
    {
        var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD documents collection unavailable.");
        doc = Invoke(documents, "Open", Path.GetFullPath(dwgPath))
            ?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {dwgPath}");
        foreach (var owner in owners.Values)
            EnsureLayer(doc, owner.LayerName, owner.ColorIndex);

        var modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("AutoCAD ModelSpace unavailable.");
        var count = Convert.ToInt32(GetProperty(modelSpace, "Count"), CultureInfo.InvariantCulture);
        for (var i = 0; i < count; i++)
        {
            var entity = Safe(() => Invoke(modelSpace, "Item", i));
            if (entity == null)
                continue;

            var objectName = ReadComString(entity, "ObjectName");
            var layer = ReadComString(entity, "Layer");
            var handle = ReadComString(entity, "Handle");
            if (layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase))
            {
                skipped.Add(new EntitySkipRecord(handle, objectName, layer, "ag4_helper_layer", null, null, null));
                continue;
            }

            if (!IsSupportedGeometry(objectName))
            {
                skipped.Add(new EntitySkipRecord(handle, objectName, layer, "unsupported_entity_type", null, null, null));
                continue;
            }

            var samples = GetEntitySamplePoints(entity, objectName);
            if (samples.Count == 0)
            {
                skipped.Add(new EntitySkipRecord(handle, objectName, layer, "no_sample_points", null, null, null));
                continue;
            }

            if (!samples.Any(point => options.ContainsViewPoint(point.X, point.Y)))
            {
                var sample = samples[0];
                skipped.Add(new EntitySkipRecord(handle, objectName, layer, "outside_view_bounds", sample.X, sample.Y, null));
                continue;
            }

            candidateCount++;
            var assignment = FindMajorityOwnership(records, samples, options.MaxEntityAssignDistanceMm);
            if (assignment == null)
            {
                var nearest = FindBestSampleNearestOwnership(records, samples);
                unassigned.Add(new UnassignedEntityColor(entity, handle, objectName, layer, samples, nearest));
                continue;
            }

            pending.Add(new PendingEntityColor(entity, handle, objectName, layer, samples, assignment));
        }

        if (options.FillUnassignedByContinuity)
            fillChanges = FillUnassignedByContinuity(pending, unassigned, options);

        foreach (var item in unassigned.Where(item => !item.Filled))
        {
            skipped.Add(new EntitySkipRecord(
                item.Handle,
                item.ObjectName,
                item.OriginalLayer,
                "no_near_ownership_point",
                item.Nearest?.Sample.X,
                item.Nearest?.Sample.Y,
                item.Nearest?.Nearest.DistanceMm));
        }

        continuityChanges = options.SmoothContinuity
            ? SmoothContinuityAssignments(pending, options)
            : [];

        foreach (var item in pending)
        {
            var owner = owners[item.Assignment.Key];
            SetProperty(item.Entity, "Layer", owner.LayerName);
            SetProperty(item.Entity, "Color", owner.ColorIndex);
            SetProperty(item.Entity, "Lineweight", options.EntityLineweight);
            Safe(() => Invoke(item.Entity, "Update"));
            colored.Add(new EntityColorRecord(
                item.Handle,
                item.ObjectName,
                owner.Key,
                owner.DisplayName,
                owner.ColorIndex,
                owner.LayerName,
                item.Assignment.SampleX,
                item.Assignment.SampleY,
                item.Assignment.AverageDistanceMm,
                item.OriginalKey,
                item.UnassignedFilled,
                item.ContinuityAdjusted));
        }

        Invoke(doc, "SaveAs", Path.GetFullPath(dwgPath));
    }
    finally
    {
        if (doc != null && options.CloseDocument)
            Safe(() => Invoke(doc, "Close", false));
    }

    return new EntityColorResult(candidateCount, skipped.Count, colored, skipped, fillChanges, continuityChanges);
}

static List<FillChangeRecord> FillUnassignedByContinuity(
    List<PendingEntityColor> pending,
    List<UnassignedEntityColor> unassigned,
    Options options)
{
    var changes = new List<FillChangeRecord>();
    foreach (var item in unassigned)
    {
        if (item.Samples.Count < 1 || EstimatePathLength(item.Samples) > options.FillMaxEntityLengthMm)
            continue;

        var connected = pending
            .Where(other => other.Samples.Count >= 1 &&
                            AreEntitiesConnected(item.Samples, other.Samples, options.FillToleranceMm))
            .ToList();
        if (connected.Count == 0)
            continue;

        var best = connected
            .GroupBy(entity => entity.Assignment.Key, StringComparer.OrdinalIgnoreCase)
            .Select(group => new
            {
                Key = group.Key,
                Count = group.Count(),
                DisplayName = group.First().Assignment.DisplayName,
                AverageDistance = group.Average(entity => entity.Assignment.AverageDistanceMm)
            })
            .OrderByDescending(group => group.Count)
            .ThenBy(group => group.AverageDistance)
            .First();

        if (best.Count < options.FillMinSameNeighbors)
            continue;

        var sampleX = item.Samples.Average(point => point.X);
        var sampleY = item.Samples.Average(point => point.Y);
        var assignment = new EntityAssignment(
            best.Key,
            best.DisplayName,
            sampleX,
            sampleY,
            item.Nearest?.Nearest.DistanceMm ?? best.AverageDistance,
            best.Count,
            item.Samples.Count);
        var pendingItem = new PendingEntityColor(item.Entity, item.Handle, item.ObjectName, item.OriginalLayer, item.Samples, assignment)
        {
            UnassignedFilled = true
        };
        pending.Add(pendingItem);
        item.Filled = true;
        changes.Add(new FillChangeRecord(
            item.Handle,
            item.ObjectName,
            best.Key,
            best.DisplayName,
            best.Count,
            connected.Count,
            EstimatePathLength(item.Samples),
            item.Nearest?.Nearest.DistanceMm));
    }

    return changes;
}

static List<ContinuityChangeRecord> SmoothContinuityAssignments(List<PendingEntityColor> pending, Options options)
{
    var changes = new List<ContinuityChangeRecord>();
    foreach (var current in pending)
    {
        if (current.Samples.Count < 2 || EstimatePathLength(current.Samples) > options.ContinuityMaxEntityLengthMm)
            continue;

        var connected = pending
            .Where(other => !ReferenceEquals(other, current) &&
                            other.Samples.Count >= 2 &&
                            AreEntitiesConnected(current.Samples, other.Samples, options.ContinuityToleranceMm))
            .ToList();
        if (connected.Count == 0)
            continue;

        var best = connected
            .GroupBy(item => item.Assignment.Key, StringComparer.OrdinalIgnoreCase)
            .Select(group => new { Key = group.Key, Count = group.Count(), DisplayName = group.First().Assignment.DisplayName })
            .OrderByDescending(group => group.Count)
            .First();

        var currentNeighborCount = connected.Count(item =>
            string.Equals(item.Assignment.Key, current.Assignment.Key, StringComparison.OrdinalIgnoreCase));

        if (best.Count < options.ContinuityMinSameNeighbors ||
            string.Equals(best.Key, current.Assignment.Key, StringComparison.OrdinalIgnoreCase) ||
            currentNeighborCount > 0)
        {
            continue;
        }

        var before = current.Assignment;
        current.Assignment = before with { Key = best.Key, DisplayName = best.DisplayName };
        current.ContinuityAdjusted = true;
        changes.Add(new ContinuityChangeRecord(
            current.Handle,
            current.ObjectName,
            before.Key,
            best.Key,
            before.DisplayName,
            best.DisplayName,
            best.Count,
            connected.Count,
            EstimatePathLength(current.Samples)));
    }

    return changes;
}

static bool AreEntitiesConnected(List<HullPoint> a, List<HullPoint> b, double toleranceMm)
{
    var aTouches = TouchPoints(a);
    var bTouches = TouchPoints(b);
    var toleranceSquared = toleranceMm * toleranceMm;
    foreach (var pa in aTouches)
    {
        foreach (var pb in bTouches)
        {
            var dx = pa.X - pb.X;
            var dy = pa.Y - pb.Y;
            if (dx * dx + dy * dy <= toleranceSquared)
                return true;
        }
    }

    return false;
}

static List<HullPoint> TouchPoints(List<HullPoint> samples)
{
    if (samples.Count == 0)
        return [];
    if (samples.Count == 1)
        return [samples[0]];
    return [samples[0], samples[^1]];
}

static double EstimatePathLength(List<HullPoint> samples)
{
    var length = 0.0;
    for (var i = 0; i < samples.Count - 1; i++)
        length += Distance(samples[i].X, samples[i].Y, samples[i + 1].X, samples[i + 1].Y);
    return length;
}

static IEnumerable<OwnerStyle> BuildOwnerStyles(List<PointRecord> records, Options options)
{
    var index = 0;
    foreach (var group in records.GroupBy(record => record.Key, StringComparer.OrdinalIgnoreCase)
                 .OrderByDescending(group => group.Count()))
    {
        var color = options.Colors[index % options.Colors.Length];
        yield return new OwnerStyle(
            group.Key,
            group.First().DisplayName,
            color,
            $"AG4_ENTITY_{color:00}_{index + 1:00}");
        index++;
    }
}

static NearestOwnership? FindNearestOwnership(List<PointRecord> records, double xMm, double yMm)
{
    PointRecord? best = null;
    var bestDistanceSquared = double.PositiveInfinity;
    foreach (var record in records)
    {
        var dx = record.XMm - xMm;
        var dy = record.YMm - yMm;
        var distanceSquared = dx * dx + dy * dy;
        if (distanceSquared >= bestDistanceSquared)
            continue;

        best = record;
        bestDistanceSquared = distanceSquared;
    }

    return best == null ? null : new NearestOwnership(best, Math.Sqrt(bestDistanceSquared));
}

static EntityAssignment? FindMajorityOwnership(List<PointRecord> records, List<HullPoint> samples, double maxDistanceMm)
{
    var votes = new Dictionary<string, AssignmentAccumulator>(StringComparer.OrdinalIgnoreCase);
    foreach (var sample in samples)
    {
        var nearest = FindNearestOwnership(records, sample.X, sample.Y);
        if (nearest == null || nearest.DistanceMm > maxDistanceMm)
            continue;

        if (!votes.TryGetValue(nearest.Point.Key, out var accumulator))
        {
            accumulator = new AssignmentAccumulator(nearest.Point.Key, nearest.Point.DisplayName);
            votes[nearest.Point.Key] = accumulator;
        }

        accumulator.Count++;
        accumulator.DistanceSum += nearest.DistanceMm;
        accumulator.SampleXSum += sample.X;
        accumulator.SampleYSum += sample.Y;
    }

    var best = votes.Values
        .OrderByDescending(vote => vote.Count)
        .ThenBy(vote => vote.DistanceSum / Math.Max(1, vote.Count))
        .FirstOrDefault();
    if (best == null)
        return null;

    return new EntityAssignment(
        best.Key,
        best.DisplayName,
        best.SampleXSum / best.Count,
        best.SampleYSum / best.Count,
        best.DistanceSum / best.Count,
        best.Count,
        samples.Count);
}

static SampleNearestOwnership? FindBestSampleNearestOwnership(List<PointRecord> records, List<HullPoint> samples)
{
    SampleNearestOwnership? best = null;
    foreach (var sample in samples)
    {
        var nearest = FindNearestOwnership(records, sample.X, sample.Y);
        if (nearest == null)
            continue;

        if (best == null || nearest.DistanceMm < best.Nearest.DistanceMm)
            best = new SampleNearestOwnership(sample, nearest);
    }

    return best;
}

static bool IsSupportedGeometry(string objectName)
{
    return objectName.Contains("Line", StringComparison.OrdinalIgnoreCase) ||
           objectName.Contains("Polyline", StringComparison.OrdinalIgnoreCase) ||
           objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase) ||
           objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase) ||
           objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase);
}

static List<HullPoint> GetEntitySamplePoints(object entity, string objectName)
{
    if (objectName.Contains("Polyline", StringComparison.OrdinalIgnoreCase))
        return GetPolylineSamplePoints(entity);

    if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase))
        return GetSplineSamplePoints(entity);

    if (objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase))
        return GetCircleSamplePoints(entity);

    if (objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase))
        return GetArcSamplePoints(entity);

    if (objectName.Contains("Line", StringComparison.OrdinalIgnoreCase) ||
        objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase))
        return GetLineLikeSamplePoints(entity);

    return TryGetBoundingBoxCenter(entity, out var x, out var y) ? [new HullPoint(x, y)] : [];
}

static List<HullPoint> GetSplineSamplePoints(object entity)
{
    var fitPoints = ToDoubleArray(Safe(() => GetProperty(entity, "FitPoints")));
    if (fitPoints.Count >= 4)
        return GetCoordinatePathSamples(fitPoints);

    var controlPoints = ToDoubleArray(Safe(() => GetProperty(entity, "ControlPoints")));
    if (controlPoints.Count >= 4)
        return GetCoordinatePathSamples(controlPoints);

    return GetLineLikeSamplePoints(entity);
}

static List<HullPoint> GetLineLikeSamplePoints(object entity)
{
    if (!TryGetPointProperty(entity, "StartPoint", out var sx, out var sy) ||
        !TryGetPointProperty(entity, "EndPoint", out var ex, out var ey))
        return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : [];

    var length = Distance(sx, sy, ex, ey);
    var count = Math.Clamp((int)Math.Ceiling(length / 8.0) + 1, 3, 11);
    return Enumerable.Range(0, count)
        .Select(i =>
        {
            var t = count == 1 ? 0.5 : i / (double)(count - 1);
            return new HullPoint(sx + (ex - sx) * t, sy + (ey - sy) * t);
        })
        .ToList();
}

static List<HullPoint> GetCircleSamplePoints(object entity)
{
    if (!TryGetPointProperty(entity, "Center", out var cx, out var cy))
        return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : [];

    var radius = ReadComDouble(entity, "Radius");
    if (!double.IsFinite(radius) || radius <= 0)
        return [new HullPoint(cx, cy)];

    return Enumerable.Range(0, 8)
        .Select(i =>
        {
            var angle = Math.PI * 2.0 * i / 8.0;
            return new HullPoint(cx + Math.Cos(angle) * radius, cy + Math.Sin(angle) * radius);
        })
        .ToList();
}

static List<HullPoint> GetArcSamplePoints(object entity)
{
    if (!TryGetPointProperty(entity, "Center", out var cx, out var cy))
        return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : [];

    var radius = ReadComDouble(entity, "Radius");
    var start = ReadComDouble(entity, "StartAngle");
    var end = ReadComDouble(entity, "EndAngle");
    if (!double.IsFinite(radius) || !double.IsFinite(start) || !double.IsFinite(end) || radius <= 0)
        return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : [];

    var span = end - start;
    if (span < 0)
        span += Math.PI * 2.0;
    var count = Math.Clamp((int)Math.Ceiling(radius * span / 6.0) + 1, 3, 9);
    return Enumerable.Range(0, count)
        .Select(i =>
        {
            var t = count == 1 ? 0.5 : i / (double)(count - 1);
            var angle = start + span * t;
            return new HullPoint(cx + Math.Cos(angle) * radius, cy + Math.Sin(angle) * radius);
        })
        .ToList();
}

static List<HullPoint> GetPolylineSamplePoints(object entity)
{
    var raw = Safe(() => GetProperty(entity, "Coordinates"));
    var values = ToDoubleArray(raw);
    if (values.Count < 4)
        return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : [];

    return GetCoordinatePathSamples(values);
}

static List<HullPoint> GetCoordinatePathSamples(List<double> values)
{
    var dimensions = values.Count % 3 == 0 ? 3 : 2;
    var pointCount = values.Count / dimensions;
    var samples = new List<HullPoint>();
    for (var i = 0; i < pointCount - 1; i++)
    {
        var ax = values[i * dimensions];
        var ay = values[i * dimensions + 1];
        var bx = values[(i + 1) * dimensions];
        var by = values[(i + 1) * dimensions + 1];
        var length = Distance(ax, ay, bx, by);
        var count = Math.Clamp((int)Math.Ceiling(length / 8.0) + 1, 2, 8);
        for (var j = 0; j < count; j++)
        {
            var t = count == 1 ? 0.5 : j / (double)(count - 1);
            samples.Add(new HullPoint(ax + (bx - ax) * t, ay + (by - ay) * t));
        }
    }

    return samples.Count > 0 ? samples : [new HullPoint(values[0], values[1])];
}

static bool TryGetPointProperty(object entity, string propertyName, out double xMm, out double yMm)
{
    xMm = double.NaN;
    yMm = double.NaN;
    var values = ToDoubleArray(Safe(() => GetProperty(entity, propertyName)));
    if (values.Count < 2)
        return false;

    xMm = values[0];
    yMm = values[1];
    return true;
}

static bool TryGetBoundingBoxCenter(object entity, out double xMm, out double yMm)
{
    xMm = double.NaN;
    yMm = double.NaN;
    object? minObj = null;
    object? maxObj = null;
    try
    {
        var args = new object?[] { minObj, maxObj };
        var modifiers = new ParameterModifier(2);
        modifiers[0] = true;
        modifiers[1] = true;
        entity.GetType().InvokeMember(
            "GetBoundingBox",
            BindingFlags.InvokeMethod,
            null,
            entity,
            args,
            [modifiers],
            CultureInfo.InvariantCulture,
            null);
        minObj = args[0];
        maxObj = args[1];
    }
    catch
    {
        return false;
    }

    var min = ToDoubleArray(minObj);
    var max = ToDoubleArray(maxObj);
    if (min.Count < 2 || max.Count < 2)
        return false;

    xMm = (min[0] + max[0]) / 2.0;
    yMm = (min[1] + max[1]) / 2.0;
    return true;
}

static List<double> ToDoubleArray(object? value)
{
    if (value == null)
        return [];

    if (value is Array array)
    {
        var values = new List<double>();
        foreach (var item in array)
        {
            if (item == null)
                continue;
            values.Add(Convert.ToDouble(item, CultureInfo.InvariantCulture));
        }
        return values;
    }

    return [];
}

static string ReadComString(object target, string propertyName)
{
    return Safe(() => GetProperty(target, propertyName)?.ToString()) ?? "";
}

static double ReadComDouble(object target, string propertyName)
{
    var value = Safe(() => GetProperty(target, propertyName));
    if (value == null)
        return double.NaN;
    return Convert.ToDouble(value, CultureInfo.InvariantCulture);
}

static double Distance(double ax, double ay, double bx, double by)
{
    var dx = ax - bx;
    var dy = ay - by;
    return Math.Sqrt(dx * dx + dy * dy);
}

static double[] ToPolylineCoordinates(List<HullPoint> points)
{
    var values = new double[points.Count * 2];
    for (var i = 0; i < points.Count; i++)
    {
        values[i * 2] = points[i].X;
        values[i * 2 + 1] = points[i].Y;
    }

    return values;
}

static void EnsureLayer(object doc, string layerName, int colorIndex)
{
    var layers = GetProperty(doc, "Layers") ?? throw new InvalidOperationException("AutoCAD Layers unavailable.");
    object? layer = null;
    try
    {
        layer = Invoke(layers, "Item", layerName);
    }
    catch
    {
        layer = Invoke(layers, "Add", layerName);
    }

    if (layer != null)
        SetProperty(layer, "Color", colorIndex);
}

static string EnsureChineseTextStyle(object doc, Options options)
{
    var styles = GetProperty(doc, "TextStyles") ?? throw new InvalidOperationException("AutoCAD TextStyles unavailable.");
    object? style = null;
    try
    {
        style = Invoke(styles, "Item", options.TextStyleName);
    }
    catch
    {
        style = Invoke(styles, "Add", options.TextStyleName);
    }

    if (style == null)
        return options.TextStyleName;

    var fontPath = ResolveChineseFont(options);
    if (!string.IsNullOrWhiteSpace(fontPath))
        SafeAction(() => SetProperty(style, "FontFile", fontPath));

    // TrueType Chinese fonts are more reliable through SetFont than through
    // whatever Standard style the source DWG happens to use.
    Safe(() => Invoke(style, "SetFont", options.TextTypeface, false, false, 134, 49));
    SafeAction(() => SetProperty(style, "Height", 0.0));
    SafeAction(() => SetProperty(style, "Width", 1.0));
    return options.TextStyleName;
}

static string ResolveChineseFont(Options options)
{
    if (!string.IsNullOrWhiteSpace(options.TextFontFile) && File.Exists(options.TextFontFile))
        return options.TextFontFile;

    var candidates = new[]
    {
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simsun.ttc"),
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf")
    };
    return candidates.FirstOrDefault(File.Exists) ?? "";
}

static void DrawLabel(object modelSpace, Contour contour, string layerName, string textStyleName, Options options)
{
    var point = new[] { contour.MinX, contour.MaxY + options.LabelOffsetMm, 0.0 };
    var text = Invoke(modelSpace, "AddText", ShortLabel(contour.DisplayName, 24), point, options.LabelHeightMm);
    if (text == null)
        return;

    SetProperty(text, "Layer", layerName);
    SetProperty(text, "Color", contour.ColorIndex);
    if (!string.IsNullOrWhiteSpace(textStyleName))
        SafeAction(() => SetProperty(text, "StyleName", textStyleName));
    Safe(() => Invoke(text, "Update"));
}

static void WriteReport(Options options, int inputPointCount, int groupCount, List<Contour> contours)
{
    Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.ReportPath))!);
    File.WriteAllText(options.ReportPath, JsonSerializer.Serialize(new
    {
        input_jsonl = Path.GetFullPath(options.InputJsonl),
        source_dwg = Path.GetFullPath(options.SourceDwg),
        output_dwg = Path.GetFullPath(options.OutputDwg),
        input_point_count = inputPointCount,
        group_count = groupCount,
        contour_count = contours.Count,
        step_mm = options.StepMm,
        pad_mm = options.PadMm,
        min_group_points = options.MinGroupPoints,
        min_cluster_points = options.MinClusterPoints,
        contours = contours.Select(contour => new
        {
            contour.Key,
            contour.DisplayName,
            contour.ClusterIndex,
            contour.PointCount,
            contour.ColorIndex,
            contour.MinX,
            contour.MinY,
            contour.MaxX,
            contour.MaxY,
            points = contour.Points.Select(point => new { x = point.X, y = point.Y })
        })
    }, new JsonSerializerOptions { WriteIndented = true }));
}

static void WriteEntityColorReport(Options options, int inputPointCount, EntityColorResult result)
{
    var skipSummary = result.Skipped
        .GroupBy(item => item.Reason, StringComparer.OrdinalIgnoreCase)
        .OrderByDescending(group => group.Count())
        .Select(group => new
        {
            reason = group.Key,
            count = group.Count()
        })
        .ToList();

    Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.ReportPath))!);
    File.WriteAllText(options.ReportPath, JsonSerializer.Serialize(new
    {
        input_jsonl = Path.GetFullPath(options.InputJsonl),
        source_dwg = Path.GetFullPath(options.SourceDwg),
        output_dwg = Path.GetFullPath(options.OutputDwg),
        color_legend_text = Path.GetFullPath(options.LegendPath),
        mode = "color_entities",
        input_point_count = inputPointCount,
        entity_candidate_count = result.CandidateCount,
        colored_entity_count = result.Colored.Count,
        skipped_entity_count = result.SkippedCount,
        skipped_summary = skipSummary,
        skipped_samples = result.Skipped.Take(options.MaxSkippedSamples),
        fill_unassigned_by_continuity = new
        {
            enabled = options.FillUnassignedByContinuity,
            tolerance_mm = options.FillToleranceMm,
            min_same_neighbors = options.FillMinSameNeighbors,
            max_entity_length_mm = options.FillMaxEntityLengthMm,
            change_count = result.FillChanges.Count,
            changes = result.FillChanges
        },
        continuity_smoothing = new
        {
            enabled = options.SmoothContinuity,
            tolerance_mm = options.ContinuityToleranceMm,
            min_same_neighbors = options.ContinuityMinSameNeighbors,
            max_entity_length_mm = options.ContinuityMaxEntityLengthMm,
            change_count = result.ContinuityChanges.Count,
            changes = result.ContinuityChanges
        },
        max_entity_assign_distance_mm = options.MaxEntityAssignDistanceMm,
        view_bounds_mm = options.HasViewBounds
            ? new { min_x = options.ViewMinX, min_y = options.ViewMinY, max_x = options.ViewMaxX, max_y = options.ViewMaxY }
            : null,
        view_bounds_list_mm = options.ViewBoundsMm
            .Select(box => new { min_x = box.MinX, min_y = box.MinY, max_x = box.MaxX, max_y = box.MaxY })
            .ToList(),
        colored = result.Colored
    }, new JsonSerializerOptions { WriteIndented = true }));
}

static void WriteEntityColorLegend(Options options, int inputPointCount, EntityColorResult result)
{
    Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.LegendPath))!);

    var groups = result.Colored
        .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
        .Select(group =>
        {
            var first = group.First();
            return new
            {
                first.Key,
                first.DisplayName,
                first.ColorIndex,
                first.LayerName,
                EntityCount = group.Count(),
                FilledCount = group.Count(item => item.UnassignedFilled),
                AdjustedCount = group.Count(item => item.ContinuityAdjusted),
                ObjectTypes = group
                    .GroupBy(item => item.ObjectName, StringComparer.OrdinalIgnoreCase)
                    .OrderByDescending(typeGroup => typeGroup.Count())
                    .Select(typeGroup => $"{typeGroup.Key}={typeGroup.Count()}")
                    .ToList()
            };
        })
        .OrderBy(item => item.ColorIndex)
        .ThenByDescending(item => item.EntityCount)
        .ToList();

    var sb = new StringBuilder();
    sb.AppendLine("上色二维图线条归属说明");
    sb.AppendLine($"原始DWG: {Path.GetFullPath(options.SourceDwg)}");
    sb.AppendLine($"上色DWG: {Path.GetFullPath(options.OutputDwg)}");
    sb.AppendLine($"归属采样JSONL: {Path.GetFullPath(options.InputJsonl)}");
    sb.AppendLine($"归属采样点数量: {inputPointCount}");
    sb.AppendLine($"已上色可绘制线条数量: {result.Colored.Count}");
    sb.AppendLine();
    sb.AppendLine("使用方式:");
    sb.AppendLine("上色后的零件位置说明图中，同一种线条颜色对应下面列出的同一个零件/组件归属。分析时请把本说明、上色位置图、学生原始标注图一起使用。");
    sb.AppendLine();
    sb.AppendLine("颜色归属:");

    if (groups.Count == 0)
    {
        sb.AppendLine("- 没有成功上色的可绘制线条。");
    }
    else
    {
        foreach (var group in groups)
        {
            sb.AppendLine(
                $"- {AciColorDescription(group.ColorIndex)} | 图层={group.LayerName} | 上色线条数={group.EntityCount} | 归属={group.DisplayName}");
            sb.AppendLine($"  归属键={group.Key}");
            if (group.FilledCount > 0 || group.AdjustedCount > 0)
                sb.AppendLine($"  连续性补色={group.FilledCount}, 连续性修正={group.AdjustedCount}");
            if (group.ObjectTypes.Count > 0)
                sb.AppendLine($"  图元类型={string.Join(", ", group.ObjectTypes)}");
        }
    }

    sb.AppendLine();
    sb.AppendLine("备注:");
    sb.AppendLine("- ACI 是 AutoCAD Color Index，即 DWG 图元使用的颜色编号。");
    sb.AppendLine("- Hex 是用于 AI 读图理解的近似屏幕颜色，不要求与 CAD 显示完全逐像素一致。");

    File.WriteAllText(options.LegendPath, sb.ToString(), new UTF8Encoding(false));
}

static string AciColorDescription(int colorIndex)
{
    var color = KnownAciColor(colorIndex);
    if (color == null)
        return $"ACI {colorIndex}";

    return $"ACI {colorIndex} {color.Value.Name} 近似 {color.Value.Hex}";
}

static (string Name, string Hex)? KnownAciColor(int colorIndex) => colorIndex switch
{
    1 => ("红色", "#FF0000"),
    2 => ("黄色", "#FFFF00"),
    3 => ("绿色", "#00FF00"),
    4 => ("青色", "#00FFFF"),
    5 => ("蓝色", "#0000FF"),
    6 => ("品红色", "#FF00FF"),
    7 => ("白色/黑色", "#FFFFFF"),
    8 => ("灰色", "#808080"),
    9 => ("浅灰色", "#C0C0C0"),
    10 => ("红橙色", "#FF7F00"),
    30 => ("橙色", "#FFBF00"),
    94 => ("黄绿色", "#99CC00"),
    140 => ("蓝绿色", "#00CC99"),
    170 => ("蓝紫色", "#3366CC"),
    200 => ("紫色", "#9933CC"),
    _ => null
};

static object StartOrConnectAutoCad(string progId)
{
    object? app = null;
    try
    {
        app = GetActiveObjectFromProgId(progId);
    }
    catch
    {
        app = null;
    }

    if (app != null)
        return app;

    StartAutoCadFromRegistry(progId);
    var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
    while (DateTime.UtcNow < deadline)
    {
        try
        {
            return GetActiveObjectFromProgId(progId);
        }
        catch
        {
            Thread.Sleep(500);
        }
    }

    throw new InvalidOperationException($"AutoCAD startup or COM connection failed: {progId}");
}

static object GetActiveObjectFromProgId(string progId)
{
    var hr = CLSIDFromProgID(progId, out var clsid);
    if (hr < 0)
        Marshal.ThrowExceptionForHR(hr);
    return GetActiveObject(ref clsid, IntPtr.Zero);
}

static void StartAutoCadFromRegistry(string progId)
{
    var clsid = ReadDefaultValue($@"{progId}\CLSID");
    var command = string.IsNullOrWhiteSpace(clsid) ? "" : ReadDefaultValue($@"CLSID\{clsid}\LocalServer32");
    var exe = ExtractExePath(command);
    if (string.IsNullOrWhiteSpace(exe) || !File.Exists(exe))
        exe = Environment.GetEnvironmentVariable("AUTOCAD_EXE") ?? "";
    if (!File.Exists(exe))
        throw new FileNotFoundException($"acad.exe not found. ProgID={progId}, LocalServer32={command}");

    Process.Start(new ProcessStartInfo
    {
        FileName = exe,
        Arguments = "/Automation",
        UseShellExecute = true,
        WorkingDirectory = Path.GetDirectoryName(exe) ?? Environment.CurrentDirectory,
        WindowStyle = ProcessWindowStyle.Hidden
    });
}

static string ReadDefaultValue(string subKey)
{
    using var key = Registry.ClassesRoot.OpenSubKey(subKey);
    return key?.GetValue("")?.ToString() ?? "";
}

static string ExtractExePath(string command)
{
    if (string.IsNullOrWhiteSpace(command))
        return "";
    var trimmed = command.Trim();
    if (trimmed.StartsWith('"'))
    {
        var end = trimmed.IndexOf('"', 1);
        return end > 1 ? trimmed[1..end] : "";
    }

    var exeIndex = trimmed.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);
    return exeIndex >= 0 ? trimmed[..(exeIndex + 4)] : trimmed;
}

[DllImport("ole32.dll", CharSet = CharSet.Unicode)]
static extern int CLSIDFromProgID(string progId, out Guid clsid);

[DllImport("oleaut32.dll", PreserveSig = false)]
[return: MarshalAs(UnmanagedType.IUnknown)]
static extern object GetActiveObject(ref Guid clsid, IntPtr reserved);

static object? Invoke(object target, string name, params object?[] args)
{
    return RetryCom(() => target.GetType().InvokeMember(
        name,
        BindingFlags.InvokeMethod,
        null,
        target,
        args));
}

static object? GetProperty(object target, string name)
{
    return RetryCom(() => target.GetType().InvokeMember(
        name,
        BindingFlags.GetProperty,
        null,
        target,
        []));
}

static void SetProperty(object target, string name, object? value)
{
    RetryCom(() => target.GetType().InvokeMember(
        name,
        BindingFlags.SetProperty,
        null,
        target,
        [value]));
}

static object? RetryCom(Func<object?> action)
{
    Exception? last = null;
    for (var attempt = 0; attempt < 60; attempt++)
    {
        try
        {
            return action();
        }
        catch (TargetInvocationException ex) when (IsRetryLater(ex.InnerException))
        {
            last = ex;
            Thread.Sleep(250);
        }
        catch (COMException ex) when (IsRetryLater(ex))
        {
            last = ex;
            Thread.Sleep(250);
        }
    }

    throw last ?? new InvalidOperationException("COM call failed.");
}

static bool IsRetryLater(Exception? ex)
{
    return ex is COMException com && unchecked((uint)com.HResult) == 0x8001010A;
}

static T? Safe<T>(Func<T> action)
{
    try
    {
        return action();
    }
    catch
    {
        return default;
    }
}

static void SafeAction(Action action)
{
    try
    {
        action();
    }
    catch
    {
    }
}

static double ReadDouble(JsonElement element, params string[] names)
{
    foreach (var name in names)
    {
        if (!element.TryGetProperty(name, out var value))
            continue;
        if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
            return number;
        if (value.ValueKind == JsonValueKind.String &&
            double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out number))
            return number;
    }

    return double.NaN;
}

static bool ReadBool(JsonElement element, params string[] names)
{
    foreach (var name in names)
    {
        if (!element.TryGetProperty(name, out var value))
            continue;
        if (value.ValueKind == JsonValueKind.True)
            return true;
        if (value.ValueKind == JsonValueKind.False)
            return false;
        if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed))
            return parsed;
    }

    return false;
}

static int ReadInt(JsonElement element, params string[] names)
{
    foreach (var name in names)
    {
        if (!element.TryGetProperty(name, out var value))
            continue;
        if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
            return number;
        if (value.ValueKind == JsonValueKind.String &&
            int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number))
            return number;
    }

    return -1;
}

static string ReadString(JsonElement element, params string[] names)
{
    foreach (var name in names)
    {
        if (!element.TryGetProperty(name, out var value))
            continue;
        return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
    }

    return "";
}

static string DisplayNameFromKey(string key)
{
    const string prefix = "name=";
    var start = key.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
    if (start < 0)
        return key;
    start += prefix.Length;
    var end = key.IndexOf(';', start);
    return end > start ? key[start..end] : key[start..];
}

static string NormalizeOwnershipKey(string key)
{
    var name = ReadKeyField(key, "name");
    var path = ReadKeyField(key, "path");
    var config = ReadKeyField(key, "config");
    if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(path))
        return key;

    var leafName = name
        .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
        .LastOrDefault();
    if (string.IsNullOrWhiteSpace(leafName))
        leafName = name;

    return $"name={leafName}; path={path}; config={config}";
}

static string ReadKeyField(string key, string fieldName)
{
    var prefix = fieldName + "=";
    var start = key.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
    if (start < 0)
        return "";

    start += prefix.Length;
    var end = key.IndexOf(';', start);
    return end > start ? key[start..end].Trim() : key[start..].Trim();
}

static string ShortLabel(string text, int maxLength)
{
    return text.Length <= maxLength ? text : text[..maxLength];
}

static double Round(double value)
{
    return Math.Round(value, 6);
}

sealed record PointRecord(string Key, string DisplayName, double XMm, double YMm, int Row, int Column);

sealed record ComponentGroup(
    string Key,
    string DisplayName,
    int ClusterIndex,
    List<PointRecord> Points,
    int ColorIndex);

sealed record HullPoint(double X, double Y);

sealed record Contour(
    string Key,
    string DisplayName,
    int ClusterIndex,
    int PointCount,
    int ColorIndex,
    double MinX,
    double MinY,
    double MaxX,
    double MaxY,
    List<HullPoint> Points);

sealed record OwnerStyle(string Key, string DisplayName, int ColorIndex, string LayerName);

sealed record ViewBoundsMm(double MinX, double MinY, double MaxX, double MaxY);

sealed record NearestOwnership(PointRecord Point, double DistanceMm);

sealed record SampleNearestOwnership(HullPoint Sample, NearestOwnership Nearest);

sealed record EntityAssignment(
    string Key,
    string DisplayName,
    double SampleX,
    double SampleY,
    double AverageDistanceMm,
    int Votes,
    int SampleCount);

sealed class AssignmentAccumulator(string key, string displayName)
{
    public string Key { get; } = key;
    public string DisplayName { get; } = displayName;
    public int Count { get; set; }
    public double DistanceSum { get; set; }
    public double SampleXSum { get; set; }
    public double SampleYSum { get; set; }
}

sealed record EntityColorResult(
    int CandidateCount,
    int SkippedCount,
    List<EntityColorRecord> Colored,
    List<EntitySkipRecord> Skipped,
    List<FillChangeRecord> FillChanges,
    List<ContinuityChangeRecord> ContinuityChanges);

sealed record EntityColorRecord(
    string Handle,
    string ObjectName,
    string Key,
    string DisplayName,
    int ColorIndex,
    string LayerName,
    double SampleX,
    double SampleY,
    double DistanceMm,
    string OriginalKey,
    bool UnassignedFilled,
    bool ContinuityAdjusted);

sealed record EntitySkipRecord(
    string Handle,
    string ObjectName,
    string LayerName,
    string Reason,
    double? SampleX,
    double? SampleY,
    double? NearestDistanceMm);

sealed record FillChangeRecord(
    string Handle,
    string ObjectName,
    string ToKey,
    string ToDisplayName,
    int SameNeighborCount,
    int ConnectedNeighborCount,
    double EntityLengthMm,
    double? NearestDistanceMm);

sealed record ContinuityChangeRecord(
    string Handle,
    string ObjectName,
    string FromKey,
    string ToKey,
    string FromDisplayName,
    string ToDisplayName,
    int SameNeighborCount,
    int ConnectedNeighborCount,
    double EntityLengthMm);

sealed class PendingEntityColor(
    object entity,
    string handle,
    string objectName,
    string originalLayer,
    List<HullPoint> samples,
    EntityAssignment assignment)
{
    public object Entity { get; } = entity;
    public string Handle { get; } = handle;
    public string ObjectName { get; } = objectName;
    public string OriginalLayer { get; } = originalLayer;
    public List<HullPoint> Samples { get; } = samples;
    public string OriginalKey { get; } = assignment.Key;
    public EntityAssignment Assignment { get; set; } = assignment;
    public bool UnassignedFilled { get; set; }
    public bool ContinuityAdjusted { get; set; }
}

sealed class UnassignedEntityColor(
    object entity,
    string handle,
    string objectName,
    string originalLayer,
    List<HullPoint> samples,
    SampleNearestOwnership? nearest)
{
    public object Entity { get; } = entity;
    public string Handle { get; } = handle;
    public string ObjectName { get; } = objectName;
    public string OriginalLayer { get; } = originalLayer;
    public List<HullPoint> Samples { get; } = samples;
    public SampleNearestOwnership? Nearest { get; } = nearest;
    public bool Filled { get; set; }
}

sealed class Options
{
    public string InputJsonl { get; private set; } = "";
    public string SourceDwg { get; private set; } = "";
    public string OutputDwg { get; private set; } = "";
    public string ReportPath { get; private set; } = "";
    public string LegendPath { get; private set; } = "";
    public string ProgId { get; private set; } = "AutoCAD.Application.23.1";
    public double StepMm { get; private set; } = 4.0;
    public double PadMm { get; private set; } = 2.0;
    public int MinGroupPoints { get; private set; } = 2;
    public int MinClusterPoints { get; private set; } = 2;
    public int Lineweight { get; private set; } = 35;
    public int EntityLineweight { get; private set; } = 25;
    public bool DrawLabels { get; private set; } = true;
    public bool ColorEntities { get; private set; }
    public bool CloseDocument { get; private set; }
    public double LabelHeightMm { get; private set; } = 3.0;
    public double LabelOffsetMm { get; private set; } = 2.5;
    public string TextStyleName { get; private set; } = "AG4_CN_TEXT";
    public string TextTypeface { get; private set; } = "SimSun";
    public string TextFontFile { get; private set; } = "";
    public double MaxEntityAssignDistanceMm { get; private set; } = 8.0;
    public double ViewMinX { get; private set; } = double.NegativeInfinity;
    public double ViewMinY { get; private set; } = double.NegativeInfinity;
    public double ViewMaxX { get; private set; } = double.PositiveInfinity;
    public double ViewMaxY { get; private set; } = double.PositiveInfinity;
    public bool HasViewBounds { get; private set; }
    public List<ViewBoundsMm> ViewBoundsMm { get; } = new();
    public int MaxSkippedSamples { get; private set; } = 200;
    public bool SmoothContinuity { get; private set; } = true;
    public double ContinuityToleranceMm { get; private set; } = 1.0;
    public int ContinuityMinSameNeighbors { get; private set; } = 2;
    public double ContinuityMaxEntityLengthMm { get; private set; } = 40.0;
    public bool FillUnassignedByContinuity { get; private set; } = true;
    public double FillToleranceMm { get; private set; } = 2.0;
    public int FillMinSameNeighbors { get; private set; } = 1;
    public double FillMaxEntityLengthMm { get; private set; } = 60.0;
    public int[] Colors { get; private set; } = [1, 3, 5, 6, 2, 4, 30, 94, 140, 200, 10, 170];

    public bool ContainsViewPoint(double xMm, double yMm)
    {
        if (ViewBoundsMm.Count == 0)
            return xMm >= ViewMinX && xMm <= ViewMaxX && yMm >= ViewMinY && yMm <= ViewMaxY;

        return ViewBoundsMm.Any(box =>
            xMm >= box.MinX &&
            xMm <= box.MaxX &&
            yMm >= box.MinY &&
            yMm <= box.MaxY);
    }

    public static Options Parse(string[] args)
    {
        var options = new Options();
        for (var i = 0; i < args.Length; i++)
        {
            var arg = args[i];
            string Next() => i + 1 < args.Length ? args[++i] : throw new ArgumentException($"Missing value after {arg}");

            switch (arg.ToLowerInvariant())
            {
                case "--input":
                case "--input-jsonl":
                    options.InputJsonl = Next();
                    break;
                case "--source-dwg":
                    options.SourceDwg = Next();
                    break;
                case "--output-dwg":
                    options.OutputDwg = Next();
                    break;
                case "--report":
                case "--output":
                    options.ReportPath = Next();
                    break;
                case "--legend":
                case "--color-legend":
                case "--legend-text":
                    options.LegendPath = Next();
                    break;
                case "--prog-id":
                    options.ProgId = Next();
                    break;
                case "--step-mm":
                    options.StepMm = double.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--pad-mm":
                    options.PadMm = double.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--min-group-points":
                    options.MinGroupPoints = int.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--min-cluster-points":
                    options.MinClusterPoints = int.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--lineweight":
                    options.Lineweight = int.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--entity-lineweight":
                    options.EntityLineweight = int.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--colors":
                    options.Colors = Next()
                        .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                        .Select(item => int.Parse(item, CultureInfo.InvariantCulture))
                        .ToArray();
                    if (options.Colors.Length == 0)
                        throw new ArgumentException("--colors requires at least one AutoCAD color index.");
                    break;
                case "--color-entities":
                    options.ColorEntities = true;
                    break;
                case "--max-entity-assign-distance-mm":
                    options.MaxEntityAssignDistanceMm = double.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--view-bounds-mm":
                    var parts = Next().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
                    if (parts.Length != 4)
                        throw new ArgumentException("--view-bounds-mm requires minX,minY,maxX,maxY.");
                    options.ViewMinX = double.Parse(parts[0], CultureInfo.InvariantCulture);
                    options.ViewMinY = double.Parse(parts[1], CultureInfo.InvariantCulture);
                    options.ViewMaxX = double.Parse(parts[2], CultureInfo.InvariantCulture);
                    options.ViewMaxY = double.Parse(parts[3], CultureInfo.InvariantCulture);
                    options.HasViewBounds = true;
                    options.ViewBoundsMm.Add(new ViewBoundsMm(options.ViewMinX, options.ViewMinY, options.ViewMaxX, options.ViewMaxY));
                    break;
                case "--max-skipped-samples":
                    options.MaxSkippedSamples = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--smooth-continuity":
                    options.SmoothContinuity = true;
                    break;
                case "--no-smooth-continuity":
                    options.SmoothContinuity = false;
                    break;
                case "--continuity-tolerance-mm":
                    options.ContinuityToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--continuity-min-same-neighbors":
                    options.ContinuityMinSameNeighbors = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--continuity-max-entity-length-mm":
                    options.ContinuityMaxEntityLengthMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--fill-unassigned-by-continuity":
                    options.FillUnassignedByContinuity = true;
                    break;
                case "--no-fill-unassigned-by-continuity":
                    options.FillUnassignedByContinuity = false;
                    break;
                case "--fill-tolerance-mm":
                    options.FillToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--fill-min-same-neighbors":
                    options.FillMinSameNeighbors = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--fill-max-entity-length-mm":
                    options.FillMaxEntityLengthMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--no-labels":
                    options.DrawLabels = false;
                    break;
                case "--close-document":
                    options.CloseDocument = true;
                    break;
                case "--label-height-mm":
                    options.LabelHeightMm = double.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--label-offset-mm":
                    options.LabelOffsetMm = double.Parse(Next(), CultureInfo.InvariantCulture);
                    break;
                case "--text-style":
                    options.TextStyleName = Next();
                    break;
                case "--text-typeface":
                    options.TextTypeface = Next();
                    break;
                case "--text-font-file":
                    options.TextFontFile = Next();
                    break;
            }
        }

        if (string.IsNullOrWhiteSpace(options.ReportPath) && !string.IsNullOrWhiteSpace(options.OutputDwg))
            options.ReportPath = Path.ChangeExtension(options.OutputDwg, ".ownership-contours.json");
        if (string.IsNullOrWhiteSpace(options.LegendPath) && !string.IsNullOrWhiteSpace(options.ReportPath))
            options.LegendPath = Path.ChangeExtension(options.ReportPath, ".color-legend.txt");

        return options;
    }
}
