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

var options = Options.Parse(args);
if (!File.Exists(options.DrawingPath))
    throw new FileNotFoundException($"DWG not found: {options.DrawingPath}");
if (options.Exports.Count == 0)
    throw new ArgumentException("At least one --view-bounds-mm value is required.");

Directory.CreateDirectory(options.OutputDir);

var sourceDrawingPath = Path.GetFullPath(options.DrawingPath);
var exportDrawingPath = PrepareExportDrawing(sourceDrawingPath, options);

var app = StartOrConnectAutoCad(options.ProgId);
SetProperty(app, "Visible", options.Visible);
object? doc = null;
var results = new List<object>();
var cleanup = AnnotationCleanupResult.Empty;
try
{
    doc = OpenDocument(app, exportDrawingPath, readOnly: !options.RemoveAnnotations && !options.HasHighlights);
    WaitForQuiescent(app, TimeSpan.FromSeconds(30));
    TryInvoke(doc, "SetVariable", "BACKGROUNDPLOT", 0);
    TryInvoke(doc, "SetVariable", "FILEDIA", 0);
    TryInvoke(doc, "SetVariable", "CMDDIA", 0);

    if (options.RemoveAnnotations)
    {
        cleanup = RemoveAnnotations(doc, options);
        Safe(() => Invoke(doc, "SaveAs", exportDrawingPath));
        WaitForQuiescent(app, TimeSpan.FromSeconds(20));
        Console.WriteLine($"annotation_cleanup candidates={cleanup.CandidateCount} deleted={cleanup.DeletedCount} dwg={exportDrawingPath}");
    }

    var highlight = ApplyHighlights(doc, options);
    if (highlight.HighlightedCount > 0 || highlight.BoxCount > 0)
    {
        Safe(() => Invoke(doc, "SaveAs", exportDrawingPath));
        WaitForQuiescent(app, TimeSpan.FromSeconds(20));
        Console.WriteLine($"highlight handles={highlight.RequestedCount} highlighted={highlight.HighlightedCount} boxes={highlight.BoxCount} failed={highlight.FailedHandles.Count}");
    }

    foreach (var export in options.Exports)
    {
        var outputPath = Path.GetFullPath(Path.Combine(options.OutputDir, export.OutputName));
        Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
        var padded = export.Bounds.Expand(options.PaddingMm);
        var plotBounds = padded;
        PlotResult result;
        if (options.IncludeAnnotationsInBounds && !options.RemoveAnnotations)
            plotBounds = ExpandBoundsToConnectedAnnotations(doc, export.Bounds, padded, options);

        result = PlotWindowToPng(doc, outputPath, plotBounds, options);
        results.Add(new
        {
            name = export.Name,
            output_png = outputPath,
            bounds_mm = export.Bounds,
            padded_bounds_mm = padded,
            plot_bounds_mm = plotBounds,
            annotation_bounds_expanded = !BoundsNearlyEqual(padded, plotBounds),
            result.Device,
            result.Media,
            result.Ok
        });
        Console.WriteLine($"exported name={export.Name} ok={result.Ok} png={outputPath}");
    }
}
finally
{
    if (doc != null && options.CloseDocument)
        Safe(() => Invoke(doc, "Close", false));
    ReleaseCom(doc);
    ReleaseCom(app);
}

var reportPath = string.IsNullOrWhiteSpace(options.ReportPath)
    ? Path.Combine(options.OutputDir, "dwg-view-png-export-report.json")
    : options.ReportPath;
File.WriteAllText(reportPath, JsonSerializer.Serialize(new
{
    source_drawing_path = sourceDrawingPath,
    drawing_path = exportDrawingPath,
    working_dwg = options.WorkingDwgPath,
    remove_annotations = options.RemoveAnnotations,
    highlight_handles = options.HighlightHandles,
    highlight_boxes_mm = options.HighlightBoxes,
    highlight_color_index = options.HighlightColorIndex,
    highlight_lineweight = options.HighlightLineweight,
    include_annotations_in_bounds = options.IncludeAnnotationsInBounds,
    annotation_connect_tolerance_mm = options.AnnotationConnectToleranceMm,
    max_annotation_expansion_mm = options.MaxAnnotationExpansionMm,
    max_annotation_box_size_mm = options.MaxAnnotationBoxSizeMm,
    annotation_cleanup = cleanup,
    output_dir = Path.GetFullPath(options.OutputDir),
    padding_mm = options.PaddingMm,
    device = options.DeviceName,
    requested_media = options.MediaName,
    exports = results
}, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"report={Path.GetFullPath(reportPath)}");

static string PrepareExportDrawing(string sourceDrawingPath, Options options)
{
    if (!options.RemoveAnnotations && !options.HasHighlights)
    {
        options.WorkingDwgPath = "";
        return sourceDrawingPath;
    }

    var workingDwg = options.WorkingDwgPath;
    if (string.IsNullOrWhiteSpace(workingDwg))
    {
        var baseName = Path.GetFileNameWithoutExtension(sourceDrawingPath);
        workingDwg = Path.Combine(options.OutputDir, "_working", $"{baseName}.annotation-clean.dwg");
        options.WorkingDwgPath = workingDwg;
    }

    if (string.IsNullOrWhiteSpace(workingDwg))
        return sourceDrawingPath;

    var workingPath = Path.GetFullPath(workingDwg);
    if (string.Equals(sourceDrawingPath, workingPath, StringComparison.OrdinalIgnoreCase))
        throw new ArgumentException("--working-dwg must not point at the source DWG.");

    Directory.CreateDirectory(Path.GetDirectoryName(workingPath)!);
    File.Copy(sourceDrawingPath, workingPath, overwrite: true);
    return workingPath;
}

static HighlightResult ApplyHighlights(object doc, Options options)
{
    if (!options.HasHighlights)
        return HighlightResult.Empty;

    var failed = new List<string>();
    var highlighted = 0;
    foreach (var handle in options.HighlightHandles)
    {
        if (string.IsNullOrWhiteSpace(handle))
            continue;
        object? entity = null;
        try
        {
            entity = Invoke(doc, "HandleToObject", handle);
            if (entity == null)
            {
                failed.Add(handle);
                continue;
            }

            TrySetProperty(entity, "Color", options.HighlightColorIndex);
            TrySetProperty(entity, "Lineweight", options.HighlightLineweight);
            Safe(() => Invoke(entity, "Highlight", true));
            Safe(() => Invoke(entity, "Update"));
            highlighted++;
        }
        catch
        {
            failed.Add(handle);
        }
        finally
        {
            ReleaseCom(entity);
        }
    }

    var boxes = ApplyHighlightBoxes(doc, options);
    return new HighlightResult(options.HighlightHandles.Count, highlighted, boxes, failed);
}

static int ApplyHighlightBoxes(object doc, Options options)
{
    if (options.HighlightBoxes.Count == 0)
        return 0;

    object? modelSpace = null;
    var added = 0;
    try
    {
        modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("ModelSpace unavailable.");
        foreach (var box in options.HighlightBoxes)
        {
            var expanded = box.Bounds.Expand(options.HighlightBoxPaddingMm);
            var points = new[]
            {
                new[] { expanded.MinX, expanded.MinY, 0.0 },
                new[] { expanded.MaxX, expanded.MinY, 0.0 },
                new[] { expanded.MaxX, expanded.MaxY, 0.0 },
                new[] { expanded.MinX, expanded.MaxY, 0.0 }
            };

            for (var i = 0; i < points.Length; i++)
            {
                object? line = null;
                try
                {
                    line = Invoke(modelSpace, "AddLine", points[i], points[(i + 1) % points.Length]);
                    if (line == null)
                        continue;
                    TrySetProperty(line, "Color", options.HighlightColorIndex);
                    TrySetProperty(line, "Lineweight", options.HighlightLineweight);
                    Safe(() => Invoke(line, "Update"));
                    added++;
                }
                finally
                {
                    ReleaseCom(line);
                }
            }
        }
    }
    finally
    {
        ReleaseCom(modelSpace);
    }

    return added / 4;
}

static AnnotationCleanupResult RemoveAnnotations(object doc, Options options)
{
    var deletedByObjectName = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
    var samples = new List<AnnotationDeleteSample>();
    var candidateCount = 0;
    var deletedCount = 0;

    foreach (var spaceName in new[] { "ModelSpace", "PaperSpace" })
    {
        var space = GetProperty(doc, spaceName);
        if (space == null)
            continue;

        try
        {
            var count = Convert.ToInt32(GetProperty(space, "Count"), CultureInfo.InvariantCulture);
            for (var i = count - 1; i >= 0; i--)
            {
                var entity = TryInvoke(space, "Item", i);
                if (entity == null)
                    continue;

                try
                {
                    var objectName = ReadComString(entity, "ObjectName");
                    var layer = ReadComString(entity, "Layer");
                    var handle = ReadComString(entity, "Handle");
                    var name = FirstNonEmpty(ReadComString(entity, "EffectiveName"), ReadComString(entity, "Name"));
                    if (!IsAnnotationEntity(entity, objectName, layer, name, out var reason))
                        continue;

                    candidateCount++;
                    if (!TryDeleteEntity(entity))
                        continue;

                    deletedCount++;
                    deletedByObjectName[objectName] = deletedByObjectName.GetValueOrDefault(objectName) + 1;
                    if (samples.Count < options.MaxCleanupSamples)
                        samples.Add(new AnnotationDeleteSample(spaceName, handle, objectName, layer, name, reason));
                }
                finally
                {
                    ReleaseCom(entity);
                }
            }
        }
        finally
        {
            ReleaseCom(space);
        }
    }

    return new AnnotationCleanupResult(candidateCount, deletedCount, deletedByObjectName, samples);
}

static bool TryDeleteEntity(object entity)
{
    try
    {
        Invoke(entity, "Delete");
        return true;
    }
    catch
    {
        return false;
    }
}

static BoundsMm ExpandBoundsToConnectedAnnotations(
    object doc,
    BoundsMm viewBounds,
    BoundsMm basePlotBounds,
    Options options)
{
    var limitBounds = viewBounds.Expand(options.MaxAnnotationExpansionMm);
    var seedBounds = viewBounds.Expand(options.ViewSeedToleranceMm);
    var seedBoxes = new List<BoundsMm>();
    var annotationBoxes = new List<BoundsMm>();

    foreach (var spaceName in new[] { "ModelSpace", "PaperSpace" })
    {
        var space = GetProperty(doc, spaceName);
        if (space == null)
            continue;

        try
        {
            var count = Convert.ToInt32(GetProperty(space, "Count"), CultureInfo.InvariantCulture);
            for (var i = 0; i < count; i++)
            {
                var entity = TryInvoke(space, "Item", i);
                if (entity == null)
                    continue;

                var objectName = ReadComString(entity, "ObjectName");
                var layer = ReadComString(entity, "Layer");
                var name = FirstNonEmpty(ReadComString(entity, "EffectiveName"), ReadComString(entity, "Name"));
                try
                {
                    var hasBounds = TryGetEntityBounds(entity, out var bounds);
                    var isAnnotation = IsAnnotationEntityForBounds(objectName, layer, name);
                    if (!hasBounds || !bounds.IsFinite || !bounds.Intersects(limitBounds))
                        continue;

                    if (isAnnotation)
                    {
                        if (bounds.Width <= options.MaxAnnotationBoxSizeMm &&
                            bounds.Height <= options.MaxAnnotationBoxSizeMm)
                        {
                            annotationBoxes.Add(bounds);
                        }
                    }
                    else if (bounds.Intersects(seedBounds))
                    {
                        seedBoxes.Add(bounds);
                    }
                }
                finally
                {
                    ReleaseCom(entity);
                }
            }
        }
        finally
        {
            ReleaseCom(space);
        }
    }

    var connectedBoxes = seedBoxes.ToList();
    if (connectedBoxes.Count == 0)
        connectedBoxes.Add(viewBounds);
    var remainingAnnotations = annotationBoxes.ToList();

    var plotBounds = basePlotBounds;
    var included = 0;
    var changed = true;
    while (changed)
    {
        changed = false;
        for (var i = remainingAnnotations.Count - 1; i >= 0; i--)
        {
            var candidate = remainingAnnotations[i];
            if (!connectedBoxes.Any(box => box.DistanceTo(candidate) <= options.AnnotationConnectToleranceMm))
                continue;

            connectedBoxes.Add(candidate);
            plotBounds = plotBounds.Union(candidate.ClampTo(limitBounds));
            remainingAnnotations.RemoveAt(i);
            included++;
            changed = true;
        }
    }

    Console.WriteLine(
        $"annotation_bounds connected_annotations={included} seed_entities={seedBoxes.Count} candidates={annotationBoxes.Count} base=({basePlotBounds.MinX:F3},{basePlotBounds.MinY:F3},{basePlotBounds.MaxX:F3},{basePlotBounds.MaxY:F3}) plot=({plotBounds.MinX:F3},{plotBounds.MinY:F3},{plotBounds.MaxX:F3},{plotBounds.MaxY:F3})");

    return plotBounds;
}

static bool IsAnnotationEntityForBounds(string objectName, string layer, string name)
{
    var type = objectName.ToLowerInvariant();
    if (ContainsAny(type, "dimension", "leader", "mleader", "tolerance", "featurecontrol", "fcf", "surfacefinish", "datum"))
        return true;
    if (type.EndsWith("text", StringComparison.OrdinalIgnoreCase) ||
        ContainsAny(type, "mtext", "attribute", "attdef"))
        return true;
    if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase))
        return true;
    return LooksLikeAnnotationName(layer.ToLowerInvariant()) ||
           LooksLikeAnnotationName(name.ToLowerInvariant());
}

static bool TryGetEntityBounds(object entity, out BoundsMm bounds)
{
    bounds = new BoundsMm(0, 0, 0, 0);
    try
    {
        object? minPoint = null;
        object? maxPoint = null;
        object?[] args = [minPoint, maxPoint];
        var modifiers = new ParameterModifier(2);
        modifiers[0] = true;
        modifiers[1] = true;
        entity.GetType().InvokeMember(
            "GetBoundingBox",
            BindingFlags.InvokeMethod,
            null,
            entity,
            args,
            [modifiers],
            CultureInfo.InvariantCulture,
            null);

        var min = ToDoubleList(args[0]);
        var max = ToDoubleList(args[1]);
        if (min.Count < 2 || max.Count < 2)
            return false;

        bounds = new BoundsMm(
            Math.Min(min[0], max[0]),
            Math.Min(min[1], max[1]),
            Math.Max(min[0], max[0]),
            Math.Max(min[1], max[1]));
        return bounds.IsFinite;
    }
    catch
    {
        return false;
    }
}

static bool BoundsNearlyEqual(BoundsMm a, BoundsMm b) =>
    Math.Abs(a.MinX - b.MinX) < 1e-6 &&
    Math.Abs(a.MinY - b.MinY) < 1e-6 &&
    Math.Abs(a.MaxX - b.MaxX) < 1e-6 &&
    Math.Abs(a.MaxY - b.MaxY) < 1e-6;

static bool IsAnnotationEntity(object entity, string objectName, string layer, string name, out string reason)
{
    var type = objectName.ToLowerInvariant();
    if (ContainsAny(type, "dimension", "leader", "mleader", "tolerance", "featurecontrol", "fcf", "surfacefinish", "datum"))
    {
        reason = "annotation_object_type";
        return true;
    }

    if (type.EndsWith("text", StringComparison.OrdinalIgnoreCase) ||
        ContainsAny(type, "mtext", "attribute", "attdef"))
    {
        reason = "text_annotation";
        return true;
    }

    var layerText = layer.ToLowerInvariant();
    if (LooksLikeAnnotationName(layerText))
    {
        reason = "annotation_layer";
        return true;
    }

    if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase) &&
        LooksLikeAnnotationName(name.ToLowerInvariant()))
    {
        reason = "annotation_block";
        return true;
    }

    if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase) &&
        !layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase))
    {
        reason = "non_ag4_annotation_block";
        return true;
    }

    if (!layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase))
    {
        if (IsLeaderPrimitive(entity, type))
        {
            reason = "leader_primitive";
            return true;
        }

        if (IsLeaderDotPrimitive(entity, type))
        {
            reason = "leader_dot_primitive";
            return true;
        }
    }

    reason = "";
    return false;
}

static bool IsLeaderPrimitive(object entity, string objectName)
{
    if (objectName.Contains("line", StringComparison.OrdinalIgnoreCase) &&
        TryReadLineEndpoints(entity, out var start, out var end))
    {
        return IsLongDiagonalSegment(start.X, start.Y, end.X, end.Y);
    }

    if (!objectName.Contains("polyline", StringComparison.OrdinalIgnoreCase))
        return false;

    var coordinates = ToDoubleList(TryGetProperty(entity, "Coordinates"));
    if (coordinates.Count < 4)
        return false;

    for (var i = 0; i + 3 < coordinates.Count; i += 2)
    {
        if (IsLongDiagonalSegment(coordinates[i], coordinates[i + 1], coordinates[i + 2], coordinates[i + 3]))
            return true;
    }

    return false;
}

static bool IsLeaderDotPrimitive(object entity, string objectName)
{
    if (objectName.Contains("point", StringComparison.OrdinalIgnoreCase))
        return true;

    if (objectName.Contains("circle", StringComparison.OrdinalIgnoreCase))
    {
        var radius = ReadComDouble(entity, "Radius");
        return double.IsFinite(radius) && radius > 0 && radius <= 2.0;
    }

    return objectName.Contains("hatch", StringComparison.OrdinalIgnoreCase) &&
           ReadComDouble(entity, "Area") is var area &&
           double.IsFinite(area) &&
           area > 0 &&
           area <= 12.0;
}

static bool TryReadLineEndpoints(object entity, out Point2 start, out Point2 end)
{
    start = default;
    end = default;
    var startValues = ToDoubleList(TryGetProperty(entity, "StartPoint"));
    var endValues = ToDoubleList(TryGetProperty(entity, "EndPoint"));
    if (startValues.Count < 2 || endValues.Count < 2)
        return false;

    start = new Point2(startValues[0], startValues[1]);
    end = new Point2(endValues[0], endValues[1]);
    return true;
}

static bool IsLongDiagonalSegment(double x1, double y1, double x2, double y2)
{
    var dx = x2 - x1;
    var dy = y2 - y1;
    var length = Math.Sqrt(dx * dx + dy * dy);
    if (length < 15.0)
        return false;

    var angle = Math.Abs(Math.Atan2(dy, dx) * 180.0 / Math.PI);
    angle %= 180.0;
    var axisAngle = Math.Min(angle, 180.0 - angle);
    return axisAngle is >= 8.0 and <= 82.0;
}

static bool LooksLikeAnnotationName(string value)
{
    if (string.IsNullOrWhiteSpace(value))
        return false;

    return ContainsAny(value,
        "dim", "dimension", "anno", "annot", "annotation", "note", "text", "txt",
        "tol", "tolerance", "leader", "mleader", "balloon", "bubble", "item",
        "center", "centre", "symbol",
        "\u6807\u6ce8", "\u5c3a\u5bf8", "\u516c\u5dee", "\u5e8f\u53f7", "\u5f15\u7ebf",
        "\u6587\u5b57", "\u6ce8\u91ca", "\u4e2d\u5fc3\u7ebf", "\u7b26\u53f7");
}

static bool ContainsAny(string value, params string[] needles) =>
    needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase));

static string ReadComString(object target, string propertyName)
{
    try { return GetProperty(target, propertyName)?.ToString() ?? ""; }
    catch { return ""; }
}

static double ReadComDouble(object target, string propertyName)
{
    try
    {
        var value = GetProperty(target, propertyName);
        return value == null ? double.NaN : Convert.ToDouble(value, CultureInfo.InvariantCulture);
    }
    catch
    {
        return double.NaN;
    }
}

static object? TryGetProperty(object target, string name)
{
    try { return GetProperty(target, name); }
    catch { return null; }
}

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

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

    return [];
}

static string FirstNonEmpty(params string[] values) =>
    values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";

static PlotResult PlotWindowToPng(object doc, string outputPath, BoundsMm bounds, Options options)
{
    var layout = GetProperty(doc, "ActiveLayout") ?? throw new InvalidOperationException("ActiveLayout unavailable.");
    var plot = GetProperty(doc, "Plot") ?? throw new InvalidOperationException("Plot unavailable.");
    try
    {
        SetProperty(layout, "ConfigName", options.DeviceName);
        TryInvoke(layout, "RefreshPlotDeviceInfo");

        var media = string.IsNullOrWhiteSpace(options.MediaName)
            ? SelectLargestPixelMedia(layout)
            : options.MediaName;
        if (!string.IsNullOrWhiteSpace(media))
            SetProperty(layout, "CanonicalMediaName", media);

        Invoke(layout, "SetWindowToPlot",
            new[] { bounds.MinX, bounds.MinY },
            new[] { bounds.MaxX, bounds.MaxY });
        SetProperty(layout, "PlotType", 4); // acWindow
        SetProperty(layout, "UseStandardScale", true);
        SetProperty(layout, "StandardScale", 0); // acScaleToFit
        SetProperty(layout, "CenterPlot", true);
        SetProperty(layout, "PlotWithPlotStyles", false);
        SetProperty(layout, "PlotWithLineweights", true);
        SetProperty(layout, "ScaleLineweights", false);
        SetProperty(layout, "PlotRotation", bounds.Width >= bounds.Height ? 1 : 0); // ac90degrees / ac0degrees
        TrySetProperty(layout, "StyleSheet", "");
        TrySetProperty(layout, "PaperUnits", 1); // acMillimeters

        if (File.Exists(outputPath))
            File.Delete(outputPath);

        Console.WriteLine($"plot name={Path.GetFileNameWithoutExtension(outputPath)} device={options.DeviceName} media={media}");
        var ok = ToBool(TryInvoke(plot, "PlotToFile", outputPath, options.DeviceName)) ??
                 ToBool(TryInvoke(plot, "PlotToFile", outputPath)) ??
                 File.Exists(outputPath);
        WaitForFile(outputPath, TimeSpan.FromSeconds(20));
        WaitForQuiescent(GetProperty(doc, "Application") ?? doc, TimeSpan.FromSeconds(20));
        return new PlotResult(ok && File.Exists(outputPath), options.DeviceName, media);
    }
    finally
    {
        ReleaseCom(plot);
        ReleaseCom(layout);
    }
}

static string SelectLargestPixelMedia(object layout)
{
    var names = ToStringList(Invoke(layout, "GetCanonicalMediaNames"));
    if (names.Count == 0)
        return "";

    return names
        .Select(name => new { Name = name, Pixels = ParsePixelArea(name) })
        .OrderByDescending(item => item.Pixels)
        .ThenByDescending(item => item.Name.Length)
        .First().Name;
}

static double ParsePixelArea(string mediaName)
{
    var matches = Regex.Matches(mediaName, @"\d+(?:\.\d+)?");
    if (matches.Count < 2)
        return 0;

    var values = matches
        .Select(match => double.Parse(match.Value, CultureInfo.InvariantCulture))
        .ToList();
    return values[^1] * values[^2];
}

static List<string> ToStringList(object? value)
{
    if (value is null)
        return [];
    if (value is object[] objects)
        return objects.Where(item => item != null).Select(item => item.ToString() ?? "").Where(item => item.Length > 0).ToList();
    if (value is Array array)
    {
        var result = new List<string>();
        foreach (var item in array)
        {
            var text = item?.ToString() ?? "";
            if (!string.IsNullOrWhiteSpace(text))
                result.Add(text);
        }
        return result;
    }
    return [];
}

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;
}

static object OpenDocument(object app, string drawingPath, bool readOnly)
{
    var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD.Application.Documents unavailable.");
    try
    {
        return TryInvoke(documents, "Open", drawingPath, readOnly)
            ?? TryInvoke(documents, "Open", drawingPath)
            ?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {drawingPath}");
    }
    finally
    {
        ReleaseCom(documents);
    }
}

static void WaitForQuiescent(object app, TimeSpan timeout)
{
    var deadline = DateTime.UtcNow + timeout;
    while (DateTime.UtcNow < deadline)
    {
        var state = TryInvoke(app, "GetAcadState");
        if (state == null)
            return;
        try
        {
            if (ToBool(GetProperty(state, "IsQuiescent")) ?? true)
                return;
        }
        finally
        {
            ReleaseCom(state);
        }
        Thread.Sleep(250);
    }
}

static void WaitForFile(string path, TimeSpan timeout)
{
    var deadline = DateTime.UtcNow + timeout;
    while (DateTime.UtcNow < deadline)
    {
        if (File.Exists(path) && new FileInfo(path).Length > 0)
            return;
        Thread.Sleep(250);
    }
}

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

static object? TryInvoke(object target, string name, params object?[] args)
{
    try { return Invoke(target, name, args); }
    catch { return null; }
}

static object? GetProperty(object target, string name) =>
    RetryCom(() => target.GetType().InvokeMember(name, BindingFlags.GetProperty, null, target, null, CultureInfo.InvariantCulture));

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

static void TrySetProperty(object target, string name, object? value)
{
    try { SetProperty(target, name, value); }
    catch { }
}

static T RetryCom<T>(Func<T> action)
{
    Exception? last = null;
    for (var attempt = 0; attempt < 20; attempt++)
    {
        try { return action(); }
        catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException)) { last = ex.InnerException; }
        catch (COMException ex) when (IsRetryableCom(ex)) { last = ex; }
        Thread.Sleep(100);
    }
    throw last ?? new COMException("COM call failed.");
}

static bool IsRetryableCom(Exception? ex) =>
    ex is COMException com && (uint)com.ErrorCode is 0x80010001 or 0x8001010A or 0x800AC472;

static bool? ToBool(object? value)
{
    if (value == null)
        return null;
    if (value is bool b)
        return b;
    if (value is int i)
        return i != 0;
    return bool.TryParse(value.ToString(), out var parsed) ? parsed : null;
}

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

static void ReleaseCom(object? obj)
{
    if (obj != null && Marshal.IsComObject(obj))
        Safe(() => Marshal.FinalReleaseComObject(obj));
}

[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);

sealed record PlotResult(bool Ok, string Device, string Media);

sealed record AnnotationCleanupResult(
    int CandidateCount,
    int DeletedCount,
    Dictionary<string, int> DeletedByObjectName,
    List<AnnotationDeleteSample> Samples)
{
    public static AnnotationCleanupResult Empty { get; } = new(0, 0, new Dictionary<string, int>(), []);
}

sealed record AnnotationDeleteSample(
    string Space,
    string Handle,
    string ObjectName,
    string Layer,
    string Name,
    string Reason);

readonly record struct Point2(double X, double Y);

sealed record BoundsMm(double MinX, double MinY, double MaxX, double MaxY)
{
    public double Width => MaxX - MinX;
    public double Height => MaxY - MinY;
    public bool IsFinite =>
        double.IsFinite(MinX) &&
        double.IsFinite(MinY) &&
        double.IsFinite(MaxX) &&
        double.IsFinite(MaxY);

    public BoundsMm Expand(double paddingMm) =>
        new(MinX - paddingMm, MinY - paddingMm, MaxX + paddingMm, MaxY + paddingMm);

    public BoundsMm Union(BoundsMm other) =>
        new(
            Math.Min(MinX, other.MinX),
            Math.Min(MinY, other.MinY),
            Math.Max(MaxX, other.MaxX),
            Math.Max(MaxY, other.MaxY));

    public BoundsMm ClampTo(BoundsMm limit) =>
        new(
            Math.Max(MinX, limit.MinX),
            Math.Max(MinY, limit.MinY),
            Math.Min(MaxX, limit.MaxX),
            Math.Min(MaxY, limit.MaxY));

    public bool Intersects(BoundsMm other) =>
        MinX <= other.MaxX &&
        MaxX >= other.MinX &&
        MinY <= other.MaxY &&
        MaxY >= other.MinY;

    public double DistanceTo(BoundsMm other)
    {
        var dx = other.MinX > MaxX ? other.MinX - MaxX : MinX > other.MaxX ? MinX - other.MaxX : 0.0;
        var dy = other.MinY > MaxY ? other.MinY - MaxY : MinY > other.MaxY ? MinY - other.MaxY : 0.0;
        return Math.Sqrt(dx * dx + dy * dy);
    }
}

sealed record ExportRequest(string Name, BoundsMm Bounds, string OutputName);
sealed record HighlightBoxRequest(string Name, BoundsMm Bounds);
sealed record HighlightResult(int RequestedCount, int HighlightedCount, int BoxCount, List<string> FailedHandles)
{
    public static HighlightResult Empty { get; } = new(0, 0, 0, []);
}

sealed class Options
{
    public string DrawingPath { get; private set; } = "";
    public string WorkingDwgPath { get; set; } = "";
    public string OutputDir { get; private set; } = "";
    public string ReportPath { get; private set; } = "";
    public string ProgId { get; private set; } = "AutoCAD.Application.23.1";
    public string DeviceName { get; private set; } = "PublishToWeb PNG.pc3";
    public string MediaName { get; private set; } = "";
    public double PaddingMm { get; private set; } = 5.0;
    public bool Visible { get; private set; }
    public bool CloseDocument { get; private set; } = true;
    public bool RemoveAnnotations { get; private set; }
    public bool IncludeAnnotationsInBounds { get; private set; }
    public double AnnotationConnectToleranceMm { get; private set; } = 2.0;
    public double MaxAnnotationExpansionMm { get; private set; } = 80.0;
    public double MaxAnnotationBoxSizeMm { get; private set; } = 220.0;
    public double ViewSeedToleranceMm { get; private set; } = 0.5;
    public int MaxCleanupSamples { get; private set; } = 100;
    public int HighlightColorIndex { get; private set; } = 1;
    public int HighlightLineweight { get; private set; } = 70;
    public double HighlightBoxPaddingMm { get; private set; } = 2.0;
    public List<string> HighlightHandles { get; } = [];
    public List<HighlightBoxRequest> HighlightBoxes { get; } = [];
    public bool HasHighlights => HighlightHandles.Count > 0 || HighlightBoxes.Count > 0;
    public List<ExportRequest> Exports { get; } = [];

    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 "--drawing-path":
                case "--source-dwg":
                    options.DrawingPath = Next();
                    break;
                case "--output-dir":
                    options.OutputDir = Next();
                    break;
                case "--working-dwg":
                case "--copy-dwg-to":
                    options.WorkingDwgPath = Next();
                    break;
                case "--report":
                    options.ReportPath = Next();
                    break;
                case "--prog-id":
                    options.ProgId = Next();
                    break;
                case "--device":
                case "--plotter":
                    options.DeviceName = Next();
                    break;
                case "--media":
                    options.MediaName = Next();
                    break;
                case "--padding-mm":
                    options.PaddingMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--remove-annotations":
                case "--clean-annotations":
                    options.RemoveAnnotations = true;
                    break;
                case "--include-annotations-in-bounds":
                case "--expand-bounds-to-connected-annotations":
                    options.IncludeAnnotationsInBounds = true;
                    break;
                case "--annotation-connect-tolerance-mm":
                    options.AnnotationConnectToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--max-annotation-expansion-mm":
                    options.MaxAnnotationExpansionMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--max-annotation-box-size-mm":
                    options.MaxAnnotationBoxSizeMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--view-seed-tolerance-mm":
                    options.ViewSeedToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--max-cleanup-samples":
                    options.MaxCleanupSamples = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--highlight-handles":
                    options.HighlightHandles.AddRange(ParseHandles(Next()));
                    break;
                case "--highlight-color-index":
                    options.HighlightColorIndex = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--highlight-lineweight":
                    options.HighlightLineweight = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--highlight-boxes-mm":
                    options.HighlightBoxes.Add(ParseHighlightBox(Next(), options.HighlightBoxes.Count + 1));
                    break;
                case "--highlight-box-padding-mm":
                    options.HighlightBoxPaddingMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
                    break;
                case "--visible":
                    options.Visible = true;
                    break;
                case "--no-close":
                    options.CloseDocument = false;
                    break;
                case "--view-bounds-mm":
                    options.Exports.Add(ParseExport(Next(), options.Exports.Count + 1));
                    break;
            }
        }

        if (string.IsNullOrWhiteSpace(options.DrawingPath))
            throw new ArgumentException("--drawing-path is required.");
        if (string.IsNullOrWhiteSpace(options.OutputDir))
            options.OutputDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(options.DrawingPath)) ?? ".", "view-png");

        return options;
    }

    static ExportRequest ParseExport(string value, int index)
    {
        var name = $"view-{index:00}";
        var boundsText = value;
        var nameSeparator = value.IndexOf('=', StringComparison.Ordinal);
        if (nameSeparator > 0)
        {
            name = SanitizeFileName(value[..nameSeparator].Trim());
            boundsText = value[(nameSeparator + 1)..];
        }

        var parts = boundsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (parts.Length != 4)
            throw new ArgumentException("--view-bounds-mm requires optionalName=minX,minY,maxX,maxY.");

        var bounds = new BoundsMm(
            double.Parse(parts[0], CultureInfo.InvariantCulture),
            double.Parse(parts[1], CultureInfo.InvariantCulture),
            double.Parse(parts[2], CultureInfo.InvariantCulture),
            double.Parse(parts[3], CultureInfo.InvariantCulture));
        return new ExportRequest(name, bounds, name + ".png");
    }

    static HighlightBoxRequest ParseHighlightBox(string value, int index)
    {
        var export = ParseExport(value, index);
        return new HighlightBoxRequest(export.Name, export.Bounds);
    }

    static string SanitizeFileName(string value)
    {
        var invalid = Path.GetInvalidFileNameChars().ToHashSet();
        var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
        var result = new string(chars).Trim();
        return string.IsNullOrWhiteSpace(result) ? "view" : result;
    }

    static IEnumerable<string> ParseHandles(string value) =>
        value.Split(new[] { ',', ';', '|', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Where(item => !string.IsNullOrWhiteSpace(item))
            .Distinct(StringComparer.OrdinalIgnoreCase);
}
