using System.Diagnostics;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;

Console.OutputEncoding = Encoding.UTF8;

if (args.Length == 0 || IsHelp(args[0]))
{
    PrintUsage();
    return 1;
}

try
{
    return args[0].Trim().ToLowerInvariant() switch
    {
        "verify" => Verify(args.Skip(1).ToArray()),
        _ => Fail($"Unknown command: {args[0]}")
    };
}
catch (Exception ex)
{
    Console.Error.WriteLine(ex.Message);
    return 1;
}

static int Verify(string[] args)
{
    var parsed = Args.Parse(args);
    var modelInput = parsed.Required("--model");
    var rulePath = parsed.Required("--rules");
    var outBase = parsed.Optional("--out") ?? Path.Combine("runtime", "diagnostics", "model_diagnostic_result");

    var root = LocateRepoRoot();
    var evidencePaths = EvidenceInputResolver.Resolve(root, modelInput);
    var package = JsonSerializer.Deserialize<RuleTemplatePackage>(File.ReadAllText(rulePath, Encoding.UTF8), JsonOptions.Default)
        ?? throw new InvalidOperationException("Cannot parse rule template package.");

    var modelEvidence = ModelEvidenceNormalizer.Normalize(evidencePaths);
    var result = DiagnosticPipeline.Run(package, modelEvidence, modelInput, evidencePaths, rulePath);

    var fullOutBase = Path.GetFullPath(outBase);
    Directory.CreateDirectory(Path.GetDirectoryName(fullOutBase) ?? ".");
    File.WriteAllText(fullOutBase + ".json", JsonSerializer.Serialize(result, JsonOptions.Default), new UTF8Encoding(false));
    File.WriteAllText(fullOutBase + ".md", MarkdownReport.Write(result), new UTF8Encoding(false));

    Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions.Default));
    return result.RuleResults.Any(r => r.Decision == "problem") ? 4 : 0;
}

static bool IsHelp(string arg) => arg is "-h" or "--help" or "/?" or "help";

static int Fail(string message)
{
    Console.Error.WriteLine(message);
    PrintUsage();
    return 1;
}

static void PrintUsage()
{
    Console.WriteLine("""
ModelDiagnosticVerifier

Purpose:
  Verify SolidWorks model evidence against rule packages generated by RuleTemplateGenerator.

Usage:
  ModelDiagnosticVerifier verify --model <assembly.SLDASM|evidence.json> --rules <rule_template.json> [--out <outputBase>]

Flow:
  model -> raw B-rep/structural evidence -> scene candidates -> rule applicability
        -> model 2D section structure instances -> image structure template matching
        -> real geometry/contact evidence validation -> pass/problem/needs_review
""");
}

static string LocateRepoRoot()
{
    var dir = new DirectoryInfo(Environment.CurrentDirectory);
    while (dir != null)
    {
        if (File.Exists(Path.Combine(dir.FullName, "project_paths.json")) &&
            Directory.Exists(Path.Combine(dir.FullName, "tools")))
            return dir.FullName;
        dir = dir.Parent;
    }

    return Environment.CurrentDirectory;
}

static class EvidenceInputResolver
{
    public static List<string> Resolve(string root, string modelInput)
    {
        var full = Path.GetFullPath(modelInput);
        if (!File.Exists(full))
            throw new FileNotFoundException($"Model/evidence input not found: {full}");

        var ext = Path.GetExtension(full).ToLowerInvariant();
        if (ext == ".json")
            return [full];

        if (ext is ".sldasm" or ".sldprt")
            return RunModelEvidenceExtractors(root, full);

        throw new InvalidOperationException($"Unsupported model input extension: {ext}");
    }

    static List<string> RunModelEvidenceExtractors(string root, string modelPath)
    {
        var evidencePaths = new List<string>();
        var section = TryRunProject(
            root,
            Path.Combine(root, "tools", "model-diagnostics", "SectionBrepExtractor", "SectionBrepExtractor.csproj"),
            modelPath,
            Path.Combine(root, "tools", "model-diagnostics", "SectionBrepExtractor", "output", "section_brep_report.json"),
            "SectionBrepExtractor");
        if (section != null)
            evidencePaths.Add(section);

        var structural = TryRunProject(
            root,
            Path.Combine(root, "tools", "model-diagnostics", "StructuralFaultProbe", "StructuralFaultProbe.csproj"),
            modelPath,
            Path.Combine(root, "runtime", "structural_fault_probe", "structural_fault_evidence.json"),
            "StructuralFaultProbe");
        if (structural != null)
            evidencePaths.Add(structural);

        if (evidencePaths.Count == 0)
            throw new InvalidOperationException("No model evidence extractor produced a usable JSON file.");

        return evidencePaths;
    }

    static string? TryRunProject(string root, string projectPath, string modelPath, string expectedOutput, string name)
    {
        if (!File.Exists(projectPath))
            return null;

        var psi = new ProcessStartInfo("dotnet")
        {
            WorkingDirectory = root,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };
        psi.ArgumentList.Add("run");
        psi.ArgumentList.Add("--project");
        psi.ArgumentList.Add(projectPath);
        psi.ArgumentList.Add("-c");
        psi.ArgumentList.Add("Release");
        psi.ArgumentList.Add("--");
        psi.ArgumentList.Add(modelPath);

        using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {name}.");
        var stdout = process.StandardOutput.ReadToEnd();
        var stderr = process.StandardError.ReadToEnd();
        process.WaitForExit();
        if (process.ExitCode != 0)
        {
            Console.Error.WriteLine($"{name} failed with exit={process.ExitCode}");
            Console.Error.WriteLine(stdout);
            Console.Error.WriteLine(stderr);
            return null;
        }

        return File.Exists(expectedOutput) ? expectedOutput : null;
    }
}

static class ModelEvidenceNormalizer
{
    public static ModelEvidence Normalize(List<string> sourcePaths)
    {
        var evidence = new ModelEvidence
        {
            Schema = "model_evidence_v1",
            SourcePaths = sourcePaths.Select(Path.GetFullPath).ToList()
        };

        foreach (var sourcePath in sourcePaths)
        {
            using var json = JsonDocument.Parse(File.ReadAllText(sourcePath, Encoding.UTF8));
            var root = json.RootElement;
            if (root.ValueKind == JsonValueKind.Array)
            {
                foreach (var assembly in root.EnumerateArray())
                    ReadStructuralAssemblyReport(assembly, evidence);
            }
            else
            {
                ReadStructuralAssemblyReport(root, evidence);
                ReadSectionBrepReport(root, evidence);
            }
        }

        SectionStructureSynthesizer.Synthesize(evidence);
        SceneRecognizer.Recognize(evidence);
        PortFeatureSynthesizer.Synthesize(evidence);
        DisassemblyAccessDefinitionFilter.Apply(evidence);
        return evidence;
    }

    static void ReadStructuralAssemblyReport(JsonElement assembly, ModelEvidence evidence)
    {
        if (assembly.ValueKind != JsonValueKind.Object)
            return;

        if (assembly.TryGetProperty("AssemblyPath", out var assemblyPath) && assemblyPath.ValueKind == JsonValueKind.String)
            evidence.ModelPath = assemblyPath.GetString() ?? evidence.ModelPath;

        if (TryArray(assembly, "Components", out var components))
        {
            foreach (var component in components.EnumerateArray())
            {
                var c = new ComponentEvidence
                {
                    Id = GetString(component, "ComponentName"),
                    Name = GetString(component, "ComponentName"),
                    PartName = GetString(component, "PartName"),
                    Category = NormalizeCategory(GetString(component, "Category")),
                    Path = GetString(component, "Path")
                };
                AddOrMergeComponent(evidence, c);
                if (TryArray(component, "CylinderFaces", out var cylinderFaces))
                    ReadComponentCylinderFaces(c, cylinderFaces, evidence);
            }
        }

        if (TryArray(assembly, "ComponentFixations", out var fixations))
        {
            foreach (var fixation in fixations.EnumerateArray())
            {
                var componentName = GetString(fixation, "ComponentName");
                var category = NormalizeCategory(GetString(fixation, "Category"));
                MergeComponentFrame(evidence, componentName, fixation);
                AddIfTrue(evidence, GetBool(fixation, "LeftFixed"), "left_axial_limit", componentName, 0.8, "StructuralFaultProbe.ComponentFixations");
                AddIfTrue(evidence, GetBool(fixation, "RightFixed"), "right_axial_limit", componentName, 0.8, "StructuralFaultProbe.ComponentFixations");
                AddIfTrue(evidence, GetBool(fixation, "FixedBothSides"), "both_sides_axial_limit", componentName, 0.85, "StructuralFaultProbe.ComponentFixations");

                if (TryArray(fixation, "Contacts", out var contacts))
                {
                    foreach (var contact in contacts.EnumerateArray())
                    {
                        var accepted = GetBool(contact, "Accepted");
                        var other = GetString(contact, "ComponentName");
                        var side = NormalizeToken(GetString(contact, "Side"));
                        var ring = NormalizeToken(GetString(contact, "BearingRing"));
                        var id = $"{componentName}:{other}:{side}:{ring}:{evidence.Items.Count + 1}";
                        evidence.Items.Add(new EvidenceItem
                        {
                            Id = id,
                            Type = "axial_contact",
                            Owner = componentName,
                            Related = other,
                            Confidence = accepted ? 0.82 : 0.35,
                            Source = "StructuralFaultProbe.ComponentFixations.Contacts",
                            Attributes =
                            {
                                ["side"] = side,
                                ["ring"] = ring,
                                ["accepted"] = accepted.ToString().ToLowerInvariant(),
                                ["target_category"] = category,
                                ["center_mm"] = FormatArray(GetDoubleArray(contact, "CenterMm")),
                                ["normal"] = FormatArray(GetDoubleArray(contact, "Normal")),
                                ["axial_position_mm"] = FormatDouble(GetDouble(contact, "AxialPositionMm")),
                                ["face_radial_min_mm"] = FormatDouble(GetDouble(contact, "FaceRadialMinMm")),
                                ["face_radial_max_mm"] = FormatDouble(GetDouble(contact, "FaceRadialMaxMm"))
                            }
                        });
                    }
                }
            }
        }

        if (TryArray(assembly, "Bearings", out var bearings))
        {
            foreach (var bearing in bearings.EnumerateArray())
                ReadBearingReport(bearing, evidence);
        }
    }

    static void ReadBearingReport(JsonElement bearing, ModelEvidence evidence)
    {
        var name = GetString(bearing, "ComponentName");
        AddIfNotEmpty(evidence, name, "bearing_component", name, 0.85, "StructuralFaultProbe.Bearings");

        if (bearing.TryGetProperty("RuleInterpretation", out var rule) && rule.ValueKind == JsonValueKind.Object)
        {
            AddIfTrue(evidence, GetBool(rule, "InnerLeft"), "bearing_inner_left_limit", name, 0.84, "StructuralFaultProbe.Bearings");
            AddIfTrue(evidence, GetBool(rule, "InnerRight"), "bearing_inner_right_limit", name, 0.84, "StructuralFaultProbe.Bearings");
            AddIfTrue(evidence, GetBool(rule, "OuterLeft"), "bearing_outer_left_limit", name, 0.84, "StructuralFaultProbe.Bearings");
            AddIfTrue(evidence, GetBool(rule, "OuterRight"), "bearing_outer_right_limit", name, 0.84, "StructuralFaultProbe.Bearings");
        }
    }

    static void ReadSectionBrepReport(JsonElement report, ModelEvidence evidence)
    {
        ReadMechanicalFeatureGraph(report, evidence);

        if (!TryObject(report, "SketchGraph", out var sketch))
            return;

        var edgeIndex = BuildSketchEdgeIndex(sketch);

        if (TryArray(sketch, "Relations", out var relations))
        {
            foreach (var relation in relations.EnumerateArray())
            {
                var type = NormalizeToken(GetString(relation, "Type"));
                var a = GetString(relation, "A");
                var b = GetString(relation, "B");
                if (string.IsNullOrWhiteSpace(type))
                    continue;

                AddComponentFromReference(evidence, a);
                AddComponentFromReference(evidence, b);
                evidence.SectionInstances.AddRelation(type, a, b, 0.86, "SketchGraph.Relations");
                if (type == "contact")
                    evidence.Items.Add(Item("close_fit_contact", "section_graph", $"{a}|{b}", 0.9, "SketchGraph.Relations"));
            }
        }

        if (TryArray(sketch, "Dimensions", out var dimensions))
        {
            foreach (var dimension in dimensions.EnumerateArray())
                ReadSketchDimension(dimension, edgeIndex, evidence);
        }

        if (TryArray(sketch, "Regions", out var regions))
        {
            foreach (var region in regions.EnumerateArray())
            {
                var id = GetString(region, "Id");
                var role = NormalizeToken(GetString(region, "Role"));
                if (role is "tool_gap" or "gap_region" or "void_region")
                {
                    evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = "region", SemanticRole = "void_gap" });
                    PromoteGapRegionEvidence(id, role, evidence);
                }
                if (role is "ejector_port" or "port_region")
                {
                    evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = "region", SemanticRole = "ejector_or_threaded_hole" });
                    PromotePortRegionEvidence(id, role, evidence);
                }
            }
        }

        GapFeatureSynthesizer.Synthesize(sketch, edgeIndex.Values
            .GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase)
            .Select(g => g.First())
            .ToList(), evidence);
    }

    static void ReadMechanicalFeatureGraph(JsonElement report, ModelEvidence evidence)
    {
        if (!TryObject(report, "FeatureGraph", out var graph) || !TryArray(graph, "Features", out var features))
            return;

        foreach (var feature in features.EnumerateArray())
        {
            var id = GetString(feature, "Id");
            var type = NormalizeToken(GetString(feature, "Type"));
            var component = GetString(feature, "ComponentName");
            var source = GetString(feature, "Source");
            var confidence = GetDouble(feature, "Confidence");
            if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type))
                continue;

            if (confidence <= 0)
                confidence = 0.65;

            evidence.SectionInstances.Entities.Add(new StructureEntity
            {
                Id = id,
                Kind = "brep_feature",
                SemanticRole = type
            });

            var faceRefs = ReadStringArray(feature, "FaceRefs").ToList();
            evidence.Items.Add(new EvidenceItem
            {
                Id = $"feature:{id}",
                Type = type,
                Owner = component,
                Related = id,
                Confidence = confidence,
                Source = string.IsNullOrWhiteSpace(source) ? "FeatureGraph" : $"FeatureGraph.{source}",
                Attributes =
                {
                    ["feature_id"] = id,
                    ["face_refs"] = string.Join(",", faceRefs)
                }
            });

            foreach (var faceRef in faceRefs)
                evidence.SectionInstances.AddRelation("feature_has_face", id, faceRef, confidence, "FeatureGraph.FaceRefs");

            foreach (var possible in ReadStringArray(feature, "PossibleFunctions"))
            {
                var possibleType = NormalizeToken(possible);
                if (string.IsNullOrWhiteSpace(possibleType))
                    continue;

                evidence.Items.Add(new EvidenceItem
                {
                    Id = $"feature_possible:{id}:{possibleType}",
                    Type = possibleType,
                    Owner = component,
                    Related = id,
                    Confidence = Math.Min(confidence, 0.68),
                    Source = "FeatureGraph.PossibleFunctions",
                    Attributes =
                    {
                        ["feature_id"] = id,
                        ["feature_type"] = type
                    }
                });
                evidence.SectionInstances.AddRelation(possibleType, id, component, Math.Min(confidence, 0.68), "FeatureGraph.PossibleFunctions");
            }
        }
    }

    static Dictionary<string, SketchEdgeInfo> BuildSketchEdgeIndex(JsonElement sketch)
    {
        var index = new Dictionary<string, SketchEdgeInfo>(StringComparer.OrdinalIgnoreCase);
        if (!TryArray(sketch, "Edges", out var edges))
            return index;

        foreach (var edge in edges.EnumerateArray())
        {
            var id = GetString(edge, "Id");
            if (string.IsNullOrWhiteSpace(id))
                continue;
            var info = new SketchEdgeInfo
            {
                Id = id,
                Owner = GetString(edge, "Owner"),
                Role = NormalizeToken(GetString(edge, "Role")),
                Orientation = NormalizeToken(AttributeString(edge, "orientation")),
                Length = GetDouble(edge, "Length"),
                Start = GetDoubleArray(edge, "Start"),
                End = GetDoubleArray(edge, "End")
            };
            index[id] = info;

            if (TryArray(edge, "SourceEdgeIds", out var sourceIds))
            {
                foreach (var source in sourceIds.EnumerateArray())
                {
                    if (source.ValueKind == JsonValueKind.String)
                    {
                        var sourceId = source.GetString();
                        if (!string.IsNullOrWhiteSpace(sourceId))
                        {
                            info.SourceEdgeIds.Add(sourceId);
                            index[sourceId] = info;
                        }
                    }
                }
            }
        }

        return index;
    }

    static void ReadSketchDimension(JsonElement dimension, Dictionary<string, SketchEdgeInfo> edgeIndex, ModelEvidence evidence)
    {
        var kind = NormalizeToken(GetString(dimension, "Kind"));
        var role = NormalizeToken(GetString(dimension, "Role"));
        var value = GetDouble(dimension, "Value");

        if (kind is "gap_width_h" or "gap_width_candidate" or "minimum_gap_width_candidate" ||
            role is "tool_gap_width_candidate" or "tool_insertion_clearance_candidate")
        {
            if (value >= 2.0)
            {
                AddEvidenceOnce(evidence, "gap_width_sufficient", "gap_region", "h", 0.76, "SketchGraph.Dimensions");
                evidence.SectionInstances.AddRelation("gap_width_sufficient", "gap_region", "h", 0.76, "SketchGraph.Dimensions");
            }
            return;
        }

        if (kind is "port_diameter_t" or "port_diameter_candidate" or "ejector_port_diameter_candidate" ||
            role is "ejector_port_size_candidate" or "threaded_hole_size_candidate")
        {
            if (value >= 1.0)
            {
                AddEvidenceOnce(evidence, "port_diameter_sufficient", "port_region", "t", 0.76, "SketchGraph.Dimensions");
                evidence.SectionInstances.AddRelation("port_diameter_sufficient", "port_region", "t", 0.76, "SketchGraph.Dimensions");
            }
            return;
        }

        if (kind != "exposed_sleeve_end_height_candidate" && role != "exposed_push_face_candidate")
            return;

        if (value < 1.0)
            return;

        if (!TryArray(dimension, "TargetRefs", out var refs))
            return;

        foreach (var reference in refs.EnumerateArray())
        {
            if (reference.ValueKind != JsonValueKind.String)
                continue;
            var id = reference.GetString() ?? "";
            if (!edgeIndex.TryGetValue(id, out var edge))
                continue;
            if (!IsSleeveOwner(edge.Owner))
                continue;

            evidence.Items.Add(new EvidenceItem
            {
                Id = $"exposed_axial_push_face:{edge.Id}",
                Type = "exposed_axial_push_face",
                Owner = edge.Owner,
                Related = edge.Id,
                Confidence = 0.86,
                Source = "SketchGraph.Dimensions",
                Attributes =
                {
                    ["dimension_kind"] = kind,
                    ["height_mm"] = value.ToString(System.Globalization.CultureInfo.InvariantCulture),
                    ["orientation"] = edge.Orientation
                }
            });
            evidence.Items.Add(new EvidenceItem
            {
                Id = $"outside_accessible_force_path:{edge.Id}",
                Type = "outside_accessible_force_path",
                Owner = edge.Owner,
                Related = edge.Id,
                Confidence = 0.8,
                Source = "SketchGraph.Dimensions",
                Attributes =
                {
                    ["via"] = "exposed_sleeve_end_height_candidate"
                }
            });
            AddEvidenceOnce(evidence, "outside_accessible_push_path", edge.Owner, edge.Id, 0.82, "SketchGraph.Dimensions");
            AddEvidenceOnce(evidence, "push_path_reaches_sleeve_push_area", edge.Owner, edge.Id, 0.82, "SketchGraph.Dimensions");
            AddEvidenceOnce(evidence, "push_direction_has_removal_component", edge.Owner, edge.Id, 0.78, "SketchGraph.Dimensions");

            evidence.SectionInstances.AddRelation("owned_by", "push_face", "sleeve_material", 0.86, "SketchGraph.Dimensions");
            evidence.SectionInstances.AddRelation("force_direction_parallel_to_axis", "push_face", "sleeve_axis", 0.78, "SketchGraph.Dimensions");
            evidence.SectionInstances.AddRelation("can_push_or_strike", "outside", "push_face", 0.8, "SketchGraph.Dimensions");
            evidence.SectionInstances.AddRelation("adjacent_to_outside", "push_face", "outside", 0.86, "SketchGraph.Dimensions");
            evidence.SectionInstances.AddRelation("connected_to_outside", "push_access_path", "outside", 0.82, "SketchGraph.Dimensions");
            evidence.SectionInstances.AddRelation("reaches", "push_access_path", "sleeve_push_area", 0.82, "SketchGraph.Dimensions");
            evidence.SectionInstances.AddRelation("direction_has_component", "push_access_path", "removal_axis", 0.78, "SketchGraph.Dimensions");
        }
    }

    static void PromoteGapRegionEvidence(string regionId, string role, ModelEvidence evidence)
    {
        bool outside = RegionConnectedToOutside(evidence, regionId);
        bool adjacentSleeve = RegionAdjacentToSleeve(evidence, regionId);
        bool adjacentEnd = RegionHasRelation(evidence, regionId, ["adjacent_to_sleeve_end", "reaches_sleeve_end", "force_path_reaches"]);
        bool toolCanEnter = RegionHasRelation(evidence, regionId, ["tool_can_enter", "tool_access", "tool_insertable"]);
        bool widthOk = evidence.Has("gap_width_sufficient", 0.7) || RegionHasRelation(evidence, regionId, ["gap_width_sufficient", "minimum_width_sufficient"]);

        if (outside)
        {
            AddEvidenceOnce(evidence, "outside_connected_gap", regionId, "outside", 0.82, "SketchGraph.Regions");
            AddEvidenceOnce(evidence, "outside_connected_void_gap", regionId, "outside", 0.82, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("connected_to_outside", "gap_region", "outside", 0.82, "SketchGraph.Regions");
        }

        if (adjacentSleeve)
        {
            AddEvidenceOnce(evidence, "gap_adjacent_to_sleeve_end", regionId, "sleeve_end", adjacentEnd ? 0.82 : 0.72, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("adjacent_to", "gap_region", "sleeve_material", 0.78, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("adjacent_to_sleeve_end", "gap_region", "sleeve_end", adjacentEnd ? 0.82 : 0.72, "SketchGraph.Regions");
        }

        if (outside && (toolCanEnter || role is "tool_gap" or "gap_region"))
        {
            AddEvidenceOnce(evidence, "tool_can_enter_gap", regionId, "outside", toolCanEnter ? 0.82 : 0.72, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("tool_can_enter", "outside", "gap_region", toolCanEnter ? 0.82 : 0.72, "SketchGraph.Regions");
        }

        if (outside && adjacentSleeve)
        {
            AddEvidenceOnce(evidence, "gap_force_path_reaches_sleeve", regionId, "sleeve_end", 0.76, "SketchGraph.Regions");
            AddEvidenceOnce(evidence, "gap_path_reaches_sleeve_push_area", regionId, "sleeve_push_area", 0.76, "SketchGraph.Regions");
            AddEvidenceOnce(evidence, "tool_force_direction_valid", regionId, "removal_axis", 0.72, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("force_path_reaches", "gap_region", "sleeve_end", 0.76, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("reaches", "gap_region", "sleeve_push_area", 0.76, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("direction_has_component", "gap_region", "removal_axis", 0.72, "SketchGraph.Regions");
        }

        if (widthOk)
        {
            AddEvidenceOnce(evidence, "gap_width_sufficient", regionId, "h", 0.76, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("gap_width_sufficient", "gap_region", "h", 0.76, "SketchGraph.Regions");
        }
    }

    static void PromotePortRegionEvidence(string regionId, string role, ModelEvidence evidence)
    {
        bool outside = RegionConnectedToOutside(evidence, regionId);
        bool reachesPushArea = RegionHasRelation(evidence, regionId, ["reaches_sleeve_push_area", "reaches", "push_area_on"]);
        bool axisOk = RegionHasRelation(evidence, regionId, ["axis_points_to", "axis_can_push", "port_axis_can_push_sleeve"]);
        bool threaded = role == "ejector_port" || RegionHasRelation(evidence, regionId, ["threaded_or_ejector_port", "threaded_hole", "ejector_screw_hole"]);
        bool diameterOk = evidence.Has("port_diameter_sufficient", 0.7) || RegionHasRelation(evidence, regionId, ["port_diameter_sufficient", "hole_size_sufficient"]);

        if (outside)
        {
            AddEvidenceOnce(evidence, "outside_connected_port", regionId, "outside", 0.82, "SketchGraph.Regions");
            AddEvidenceOnce(evidence, "outside_connected_cylindrical_port", regionId, "outside", 0.82, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("connected_to_outside", "port_region", "outside", 0.82, "SketchGraph.Regions");
        }

        if (reachesPushArea)
        {
            AddEvidenceOnce(evidence, "port_reaches_sleeve_push_area", regionId, "push_area", 0.84, "SketchGraph.Regions");
            AddEvidenceOnce(evidence, "port_axis_intersects_sleeve_push_area", regionId, "sleeve_push_area", 0.78, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("reaches", "port_region", "push_area", 0.84, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("push_area_on", "push_area", "sleeve_material", 0.84, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("axis_intersects", "port_axis", "sleeve_push_area", 0.78, "SketchGraph.Regions");
        }

        if (axisOk || reachesPushArea)
        {
            AddEvidenceOnce(evidence, "port_axis_can_push_sleeve", regionId, "push_area", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
            AddEvidenceOnce(evidence, "port_push_direction_valid", regionId, "removal_axis", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("axis_points_to", "port_axis", "push_area", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("screw_can_push", "port_region", "push_area", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("direction_has_component", "port_axis", "removal_axis", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
        }

        if (threaded)
        {
            AddEvidenceOnce(evidence, "threaded_or_ejector_port", regionId, "t", role == "ejector_port" ? 0.84 : 0.74, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("threaded_or_ejector_port", "port_region", "t", role == "ejector_port" ? 0.84 : 0.74, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("owned_by", "port_region", "host_material", 0.72, "SketchGraph.Regions");
        }

        if (diameterOk)
        {
            AddEvidenceOnce(evidence, "port_diameter_sufficient", regionId, "t", 0.76, "SketchGraph.Regions");
            evidence.SectionInstances.AddRelation("port_diameter_sufficient", "port_region", "t", 0.76, "SketchGraph.Regions");
        }
    }

    static void AddIfTrue(ModelEvidence evidence, bool condition, string type, string owner, double confidence, string source)
    {
        if (condition)
            evidence.Items.Add(Item(type, owner, "", confidence, source));
    }

    static void AddIfNotEmpty(ModelEvidence evidence, string value, string type, string owner, double confidence, string source)
    {
        if (!string.IsNullOrWhiteSpace(value))
            evidence.Items.Add(Item(type, owner, "", confidence, source));
    }

    static EvidenceItem Item(string type, string owner, string related, double confidence, string source)
    {
        var rawId = $"{type}:{owner}:{related}:{Guid.NewGuid():N}";
        return new EvidenceItem
        {
            Id = rawId[..Math.Min(64, rawId.Length)],
            Type = NormalizeToken(type),
            Owner = owner,
            Related = related,
            Confidence = confidence,
            Source = source
        };
    }

    static void AddEvidenceOnce(ModelEvidence evidence, string type, string owner, string related, double confidence, string source)
    {
        var normalized = NormalizeToken(type);
        if (evidence.Items.Any(i =>
            i.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) &&
            i.Owner.Equals(owner, StringComparison.OrdinalIgnoreCase) &&
            i.Related.Equals(related, StringComparison.OrdinalIgnoreCase)))
            return;

        evidence.Items.Add(new EvidenceItem
        {
            Id = $"{normalized}:{owner}:{related}:promoted"[..Math.Min(64, $"{normalized}:{owner}:{related}:promoted".Length)],
            Type = normalized,
            Owner = owner,
            Related = related,
            Confidence = confidence,
            Source = source
        });
    }

    static bool RegionConnectedToOutside(ModelEvidence evidence, string regionId)
    {
        return evidence.SectionInstances.Relations.Any(r =>
            (r.Type is "connected_to_outside" or "region_connected_to_region" or "adjacent_to_outside") &&
            RelationTouches(r, regionId) &&
            (IsOutsideReference(r.A) || IsOutsideReference(r.B) || r.Type == "connected_to_outside"));
    }

    static bool RegionAdjacentToSleeve(ModelEvidence evidence, string regionId)
    {
        return evidence.SectionInstances.Relations.Any(r =>
            (r.Type is "adjacent_to_sleeve_region" or "adjacent_to" or "adjacent_to_sleeve_end") &&
            RelationTouches(r, regionId) &&
            (IsSleeveReference(r.A) || IsSleeveReference(r.B) || r.Type is "adjacent_to_sleeve_region" or "adjacent_to_sleeve_end"));
    }

    static bool RegionHasRelation(ModelEvidence evidence, string regionId, string[] relationTypes)
    {
        var normalized = relationTypes.Select(NormalizeToken).ToHashSet(StringComparer.OrdinalIgnoreCase);
        return evidence.SectionInstances.Relations.Any(r => normalized.Contains(r.Type) && RelationTouches(r, regionId));
    }

    static bool RelationTouches(StructureRelation relation, string id)
    {
        return relation.A.Equals(id, StringComparison.OrdinalIgnoreCase) ||
               relation.B.Equals(id, StringComparison.OrdinalIgnoreCase);
    }

    static bool IsOutsideReference(string value)
    {
        return NormalizeToken(value) is "outside" or "region_outside" or "outside_air" or "environment";
    }

    static bool IsSleeveReference(string value)
    {
        return NormalizeToken(value) is "sleeve" or "sleeve_material" or "sleeve_end" or "sleeve_push_area" ||
               IsSleeveOwner(value);
    }

    static void AddComponentFromReference(ModelEvidence evidence, string reference)
    {
        var component = ExtractComponentName(reference);
        if (string.IsNullOrWhiteSpace(component) || evidence.Components.Any(c => c.Id == component))
            return;

        AddOrMergeComponent(evidence, new ComponentEvidence
        {
            Id = component,
            Name = component,
            Category = GuessCategory(component)
        });
    }

    static void ReadComponentCylinderFaces(ComponentEvidence component, JsonElement cylinderFaces, ModelEvidence evidence)
    {
        foreach (var face in cylinderFaces.EnumerateArray())
        {
            var radius = GetDouble(face, "RadiusMm");
            var axis = GetDoubleArray(face, "Axis");
            var axisPoint = GetDoubleArray(face, "AxisPointMm");
            if (radius <= 0 || axis.Length != 3 || axisPoint.Length != 3)
                continue;

            var faceIndex = GetString(face, "FaceIndex");
            var id = $"cylindrical_face:{component.Id}:{faceIndex}:{radius:0.###}";
            evidence.Items.Add(new EvidenceItem
            {
                Id = id,
                Type = "cylindrical_face",
                Owner = component.Id,
                Related = faceIndex,
                Confidence = 0.78,
                Source = "StructuralFaultProbe.Components.CylinderFaces",
                Attributes =
                {
                    ["component_category"] = component.Category,
                    ["radius_mm"] = FormatDouble(radius),
                    ["diameter_mm"] = FormatDouble(radius * 2.0),
                    ["axis"] = FormatArray(axis),
                    ["axis_point_mm"] = FormatArray(axisPoint),
                    ["center_mm"] = FormatArray(GetDoubleArray(face, "CenterMm")),
                    ["bbox_mm"] = FormatArray(GetDoubleArray(face, "BBoxMm")),
                    ["axial_min_mm"] = FormatDouble(GetDouble(face, "AxialMinMm")),
                    ["axial_max_mm"] = FormatDouble(GetDouble(face, "AxialMaxMm")),
                    ["loop_count"] = GetString(face, "LoopCount")
                }
            });
        }
    }

    static void MergeComponentFrame(ModelEvidence evidence, string componentName, JsonElement fixation)
    {
        var component = evidence.Components.FirstOrDefault(c => c.Id.Equals(componentName, StringComparison.OrdinalIgnoreCase));
        if (component == null)
        {
            component = new ComponentEvidence
            {
                Id = componentName,
                Name = componentName,
                Category = NormalizeCategory(GetString(fixation, "Category"))
            };
            evidence.Components.Add(component);
        }

        var axis = GetDoubleArray(fixation, "Axis");
        var axisPoint = GetDoubleArray(fixation, "AxisPointMm");
        if (axis.Length == 3)
            component.Axis = axis;
        if (axisPoint.Length == 3)
            component.AxisPointMm = axisPoint;
        component.AxialMinMm = GetDouble(fixation, "AxialMinMm");
        component.AxialMaxMm = GetDouble(fixation, "AxialMaxMm");
        component.InnerRadiusMm = GetNullableDouble(fixation, "InnerRadiusMm");
        component.OuterRadiusMm = GetNullableDouble(fixation, "OuterRadiusMm");
    }

    static void AddOrMergeComponent(ModelEvidence evidence, ComponentEvidence component)
    {
        if (string.IsNullOrWhiteSpace(component.Id))
            return;

        var existing = evidence.Components.FirstOrDefault(c => c.Id.Equals(component.Id, StringComparison.OrdinalIgnoreCase));
        if (existing == null)
        {
            evidence.Components.Add(component);
            return;
        }

        if (string.IsNullOrWhiteSpace(existing.Name))
            existing.Name = component.Name;
        if (string.IsNullOrWhiteSpace(existing.PartName))
            existing.PartName = component.PartName;
        if (string.IsNullOrWhiteSpace(existing.Path))
            existing.Path = component.Path;
        bool shouldUpgradeCategory =
            string.IsNullOrWhiteSpace(existing.Category) ||
            existing.Category == "unknown" ||
            (existing.Category == "sleeve" && component.Category == "sleeve_or_ring");
        if (shouldUpgradeCategory)
            existing.Category = component.Category;
        if (existing.Axis.Length == 0 && component.Axis.Length == 3)
            existing.Axis = component.Axis;
        if (existing.AxisPointMm.Length == 0 && component.AxisPointMm.Length == 3)
            existing.AxisPointMm = component.AxisPointMm;
        existing.AxialMinMm = existing.AxialMinMm == 0 ? component.AxialMinMm : existing.AxialMinMm;
        existing.AxialMaxMm = existing.AxialMaxMm == 0 ? component.AxialMaxMm : existing.AxialMaxMm;
        existing.InnerRadiusMm ??= component.InnerRadiusMm;
        existing.OuterRadiusMm ??= component.OuterRadiusMm;
    }

    static string ExtractComponentName(string reference)
    {
        var idx = reference.IndexOf(':');
        return idx > 0 ? reference[..idx] : "";
    }

    static string GuessCategory(string name)
    {
        if (ContainsAny(name, "套筒", "轴套", "衬套", "sleeve", "bushing"))
            return "sleeve";
        if (ContainsAny(name, "端盖", "箱体", "孔", "housing", "cover", "hole"))
            return "host_hole_component";
        if (ContainsAny(name, "轴承", "bearing"))
            return "bearing";
        return "unknown";
    }

    static bool ContainsAny(string text, params string[] tokens)
    {
        return tokens.Any(t => text.Contains(t, StringComparison.OrdinalIgnoreCase));
    }

    static bool IsSleeveOwner(string owner)
    {
        return ContainsAny(owner, "套筒", "轴套", "衬套", "sleeve", "bushing");
    }

    static bool TryArray(JsonElement obj, string name, out JsonElement value)
    {
        if (obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Array)
            return true;
        value = default;
        return false;
    }

    static bool TryObject(JsonElement obj, string name, out JsonElement value)
    {
        if (obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Object)
            return true;
        value = default;
        return false;
    }

    static string GetString(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
            return "";
        return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
    }

    static double GetDouble(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
            return 0;
        return value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var result) ? result : 0;
    }

    static double? GetNullableDouble(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
            return null;
        return value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var result) ? result : null;
    }

    static double[] GetDoubleArray(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
            return [];
        return value.EnumerateArray()
            .Where(v => v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out _))
            .Select(v => v.GetDouble())
            .ToArray();
    }

    static IEnumerable<string> ReadStringArray(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
            yield break;

        foreach (var item in value.EnumerateArray())
        {
            if (item.ValueKind == JsonValueKind.String)
                yield return item.GetString() ?? "";
            else if (item.ValueKind != JsonValueKind.Null && item.ValueKind != JsonValueKind.Undefined)
                yield return item.ToString();
        }
    }

    static string FormatDouble(double value) => value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);

    static string FormatArray(double[] values) => string.Join(",", values.Select(FormatDouble));

    static string AttributeString(JsonElement obj, string key)
    {
        if (!TryObject(obj, "Attributes", out var attrs) && !TryObject(obj, "attributes", out attrs))
            return "";
        return GetString(attrs, key);
    }

    static bool GetBool(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
            return false;
        return value.ValueKind switch
        {
            JsonValueKind.True => true,
            JsonValueKind.False => false,
            JsonValueKind.String => bool.TryParse(value.GetString(), out var b) && b,
            _ => false
        };
    }

    static string NormalizeCategory(string value)
    {
        var v = NormalizeToken(value);
        return v switch
        {
            "spacer_or_ring" => "sleeve_or_ring",
            "end_cap" or "housing" => "host_hole_component",
            _ => v
        };
    }

    static string NormalizeToken(string value)
    {
        return value.Trim().Replace(' ', '_').ToLowerInvariant();
    }
}

sealed class SketchEdgeInfo
{
    public string Id { get; set; } = "";
    public string Owner { get; set; } = "";
    public string Role { get; set; } = "";
    public string Orientation { get; set; } = "";
    public double Length { get; set; }
    public double[] Start { get; set; } = [];
    public double[] End { get; set; } = [];
    public List<string> SourceEdgeIds { get; set; } = [];
}

static class GapFeatureSynthesizer
{
    public static void Synthesize(JsonElement sketch, List<SketchEdgeInfo> edges, ModelEvidence evidence)
    {
        var usableEdges = edges
            .Where(e => e.Start.Length >= 2 && e.End.Length >= 2 && EdgeLength(e) > 0.2)
            .ToList();
        var sleeveEdges = usableEdges.Where(e => IsSleeveOwner(e.Owner)).ToList();
        var hostEdges = usableEdges.Where(e => IsHostOwner(e.Owner)).ToList();
        if (sleeveEdges.Count == 0 || hostEdges.Count == 0)
            return;

        var polygons = BuildMaterialPolygons(sketch, usableEdges);
        if (polygons.Count == 0)
            return;
        var contactPairs = BuildContactPairs(sketch);

        var bounds = Bounds(usableEdges);
        var width = bounds.MaxX - bounds.MinX;
        var height = bounds.MaxY - bounds.MinY;
        var span = Math.Max(width, height);
        if (span <= 0)
            return;

        var margin = Math.Max(5.0, span * 0.05);
        bounds = bounds.Expand(margin);
        var resolution = Math.Clamp(span / 180.0, 0.75, 2.0);
        var nx = Math.Clamp((int)Math.Ceiling((bounds.MaxX - bounds.MinX) / resolution), 20, 280);
        var ny = Math.Clamp((int)Math.Ceiling((bounds.MaxY - bounds.MinY) / resolution), 20, 280);
        resolution = Math.Max((bounds.MaxX - bounds.MinX) / nx, (bounds.MaxY - bounds.MinY) / ny);

        var solid = new bool[nx, ny];
        var outside = new bool[nx, ny];
        for (int ix = 0; ix < nx; ix++)
        for (int iy = 0; iy < ny; iy++)
        {
            var p = CellCenter(bounds, resolution, ix, iy);
            solid[ix, iy] = polygons.Any(poly => PointInPolygon(p, poly.Points));
        }

        FloodOutside(solid, outside, nx, ny);

        var candidateCells = new List<GapCell>();
        const double MinimumToolGapWidthMm = 2.0;
        var maxGapWidth = Math.Min(25.0, Math.Max(6.0, span * 0.16));
        for (int ix = 0; ix < nx; ix++)
        for (int iy = 0; iy < ny; iy++)
        {
            if (!outside[ix, iy] || solid[ix, iy])
                continue;
            var p = CellCenter(bounds, resolution, ix, iy);
            var sleeveNearest = NearestEdge(p, sleeveEdges);
            var hostNearest = NearestEdge(p, hostEdges);
            if (IsKnownContactPair(contactPairs, sleeveNearest.Edge, hostNearest.Edge))
                continue;
            if (!IsBetweenMaterialBoundaries(p, sleeveNearest.Point, hostNearest.Point))
                continue;
            var sleeveDistance = sleeveNearest.Distance;
            var hostDistance = hostNearest.Distance;
            var estimatedWidth = sleeveDistance + hostDistance;
            if (sleeveDistance <= maxGapWidth &&
                hostDistance <= maxGapWidth &&
                estimatedWidth >= MinimumToolGapWidthMm &&
                estimatedWidth <= maxGapWidth)
            {
                candidateCells.Add(new GapCell
                {
                    X = ix,
                    Y = iy,
                    Point = p,
                    SleeveDistance = sleeveDistance,
                    HostDistance = hostDistance,
                    EstimatedWidth = estimatedWidth
                });
            }
        }

        if (candidateCells.Count == 0)
            return;

        var clusters = BuildClusters(candidateCells, nx, ny);
        var best = clusters
            .Select(c => new GapCluster(c))
            .Where(c => c.MinSleeveDistance <= resolution * 2.5)
            .Where(c => c.MinEstimatedWidth >= MinimumToolGapWidthMm)
            .Where(c => c.AxialExtent >= Math.Max(2.0, resolution * 3.0))
            .Where(c => HasExteriorMouth(c, solid, nx, ny))
            .OrderBy(c => c.MinEstimatedWidth)
            .ThenByDescending(c => c.Cells.Count)
            .FirstOrDefault();
        if (best == null)
            return;

        var confidence = best.MinEstimatedWidth <= maxGapWidth * 0.65 ? 0.74 : 0.70;
        var attributes = new Dictionary<string, string>
        {
            ["estimated_min_width_mm"] = FormatDouble(best.MinEstimatedWidth),
            ["axial_extent_mm"] = FormatDouble(best.AxialExtent),
            ["radial_min_mm"] = FormatDouble(best.RadialMin),
            ["radial_max_mm"] = FormatDouble(best.RadialMax),
            ["centroid_axial_mm"] = FormatDouble(best.CentroidX),
            ["centroid_radial_mm"] = FormatDouble(best.CentroidY),
            ["exterior_mouth"] = "radial_clear_ray",
            ["cell_count"] = best.Cells.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
            ["grid_resolution_mm"] = FormatDouble(resolution),
            ["max_gap_width_mm"] = FormatDouble(maxGapWidth)
        };
        AddEntityOnce(evidence, "gap_region:auto_tool_gap", "region", "void_gap");
        AddEvidenceOnce(evidence, "outside_connected_void_gap", "gap_region", "outside", confidence, "GapFeatureSynthesizer.SectionFloodFill", attributes);
        AddEvidenceOnce(evidence, "gap_path_reaches_sleeve_push_area", "gap_region", "sleeve_push_area", confidence, "GapFeatureSynthesizer.SectionFloodFill", attributes);
        AddEvidenceOnce(evidence, "tool_force_direction_valid", "gap_region", "removal_axis", 0.70, "GapFeatureSynthesizer.SectionFloodFill", attributes);
        AddEvidenceOnce(evidence, "gap_width_sufficient", "gap_region", "h", confidence, "GapFeatureSynthesizer.SectionFloodFill", attributes);

        evidence.SectionInstances.AddRelation("connected_to_outside", "gap_region", "outside", confidence, "GapFeatureSynthesizer.SectionFloodFill");
        evidence.SectionInstances.AddRelation("reaches", "gap_region", "sleeve_push_area", confidence, "GapFeatureSynthesizer.SectionFloodFill");
        evidence.SectionInstances.AddRelation("direction_has_component", "gap_region", "removal_axis", 0.70, "GapFeatureSynthesizer.SectionFloodFill");
        evidence.SectionInstances.AddRelation("gap_width_sufficient", "gap_region", "h", confidence, "GapFeatureSynthesizer.SectionFloodFill");
    }

    static List<MaterialPolygon> BuildMaterialPolygons(JsonElement sketch, List<SketchEdgeInfo> edges)
    {
        var edgeMap = edges.ToDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
        var result = new List<MaterialPolygon>();
        if (!TryArray(sketch, "Loops", out var loops))
            return result;

        foreach (var loop in loops.EnumerateArray())
        {
            if (!GetBool(loop, "Closed") || !TryArray(loop, "EdgeRefs", out var refs))
                continue;

            var loopEdges = new List<SketchEdgeInfo>();
            foreach (var reference in refs.EnumerateArray())
            {
                if (reference.ValueKind == JsonValueKind.String &&
                    edgeMap.TryGetValue(reference.GetString() ?? "", out var edge))
                    loopEdges.Add(edge);
            }

            if (loopEdges.Count < 3)
                continue;
            var points = ChainLoopPoints(loopEdges);
            if (points.Count < 3 || Math.Abs(PolygonArea(points)) < 1.0)
                continue;

            result.Add(new MaterialPolygon { Points = points });
        }

        return result;
    }

    static HashSet<string> BuildContactPairs(JsonElement sketch)
    {
        var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        if (!TryArray(sketch, "Relations", out var relations))
            return result;

        foreach (var relation in relations.EnumerateArray())
        {
            var type = NormalizeToken(GetString(relation, "Type"));
            if (type != "contact")
                continue;
            var a = GetString(relation, "A");
            var b = GetString(relation, "B");
            if (!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b))
                result.Add(PairKey(a, b));
        }

        return result;
    }

    static List<Point2> ChainLoopPoints(List<SketchEdgeInfo> loopEdges)
    {
        var points = new List<Point2>();
        var first = ToPoint(loopEdges[0].Start);
        var current = ToPoint(loopEdges[0].End);
        points.Add(first);
        points.Add(current);

        foreach (var edge in loopEdges.Skip(1))
        {
            var start = ToPoint(edge.Start);
            var end = ToPoint(edge.End);
            if (Distance(current, start) <= Distance(current, end))
            {
                current = end;
                points.Add(end);
            }
            else
            {
                current = start;
                points.Add(start);
            }
        }

        if (points.Count > 1 && Distance(points[0], points[^1]) < 0.5)
            points.RemoveAt(points.Count - 1);
        return points;
    }

    static void FloodOutside(bool[,] solid, bool[,] outside, int nx, int ny)
    {
        var q = new Queue<(int X, int Y)>();
        void Enqueue(int x, int y)
        {
            if (x < 0 || y < 0 || x >= nx || y >= ny || solid[x, y] || outside[x, y])
                return;
            outside[x, y] = true;
            q.Enqueue((x, y));
        }

        for (int x = 0; x < nx; x++)
        {
            Enqueue(x, 0);
            Enqueue(x, ny - 1);
        }
        for (int y = 0; y < ny; y++)
        {
            Enqueue(0, y);
            Enqueue(nx - 1, y);
        }

        int[] dx = [1, -1, 0, 0];
        int[] dy = [0, 0, 1, -1];
        while (q.Count > 0)
        {
            var c = q.Dequeue();
            for (int i = 0; i < 4; i++)
                Enqueue(c.X + dx[i], c.Y + dy[i]);
        }
    }

    static List<List<GapCell>> BuildClusters(List<GapCell> cells, int nx, int ny)
    {
        var byCoord = cells.ToDictionary(c => (c.X, c.Y));
        var visited = new HashSet<(int X, int Y)>();
        var clusters = new List<List<GapCell>>();
        int[] dx = [1, -1, 0, 0];
        int[] dy = [0, 0, 1, -1];

        foreach (var seed in cells)
        {
            if (!visited.Add((seed.X, seed.Y)))
                continue;
            var cluster = new List<GapCell>();
            var q = new Queue<GapCell>();
            q.Enqueue(seed);
            while (q.Count > 0)
            {
                var cell = q.Dequeue();
                cluster.Add(cell);
                for (int i = 0; i < 4; i++)
                {
                    var key = (cell.X + dx[i], cell.Y + dy[i]);
                    if (key.Item1 < 0 || key.Item2 < 0 || key.Item1 >= nx || key.Item2 >= ny)
                        continue;
                    if (visited.Contains(key) || !byCoord.TryGetValue(key, out var next))
                        continue;
                    visited.Add(key);
                    q.Enqueue(next);
                }
            }
            clusters.Add(cluster);
        }

        return clusters;
    }

    static bool HasExteriorMouth(GapCluster cluster, bool[,] solid, int nx, int ny)
    {
        return cluster.Cells.Any(c =>
            HasClearRadialRay(c.X, c.Y, 1, solid, nx, ny) ||
            HasClearRadialRay(c.X, c.Y, -1, solid, nx, ny));
    }

    static bool HasClearRadialRay(int x, int y, int dy, bool[,] solid, int nx, int ny)
    {
        for (var iy = y; iy >= 0 && iy < ny; iy += dy)
        {
            if (x < 0 || x >= nx || solid[x, iy])
                return false;
        }
        return true;
    }

    static void AddEntityOnce(ModelEvidence evidence, string id, string kind, string role)
    {
        if (evidence.SectionInstances.Entities.Any(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)))
            return;
        evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = kind, SemanticRole = role });
    }

    static void AddEvidenceOnce(ModelEvidence evidence, string type, string owner, string related, double confidence, string source, Dictionary<string, string>? attributes = null)
    {
        if (evidence.Items.Any(i =>
            i.Type.Equals(type, StringComparison.OrdinalIgnoreCase) &&
            i.Owner.Equals(owner, StringComparison.OrdinalIgnoreCase) &&
            i.Related.Equals(related, StringComparison.OrdinalIgnoreCase)))
            return;

        evidence.Items.Add(new EvidenceItem
        {
            Id = $"{type}:{owner}:{related}:gap",
            Type = type,
            Owner = owner,
            Related = related,
            Confidence = confidence,
            Source = source,
            Attributes = attributes == null
                ? []
                : new Dictionary<string, string>(attributes, StringComparer.OrdinalIgnoreCase)
        });
    }

    static SectionBounds Bounds(List<SketchEdgeInfo> edges)
    {
        var xs = edges.SelectMany(e => new[] { e.Start[0], e.End[0] }).ToList();
        var ys = edges.SelectMany(e => new[] { e.Start[1], e.End[1] }).ToList();
        return new SectionBounds(xs.Min(), xs.Max(), ys.Min(), ys.Max());
    }

    static Point2 CellCenter(SectionBounds bounds, double resolution, int ix, int iy)
    {
        return new Point2(bounds.MinX + (ix + 0.5) * resolution, bounds.MinY + (iy + 0.5) * resolution);
    }

    static bool PointInPolygon(Point2 p, List<Point2> poly)
    {
        var inside = false;
        for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++)
        {
            var a = poly[i];
            var b = poly[j];
            if (((a.Y > p.Y) != (b.Y > p.Y)) &&
                p.X < (b.X - a.X) * (p.Y - a.Y) / ((b.Y - a.Y) == 0 ? 1e-9 : (b.Y - a.Y)) + a.X)
                inside = !inside;
        }
        return inside;
    }

    static NearestEdgeResult NearestEdge(Point2 p, List<SketchEdgeInfo> edges)
    {
        var bestEdge = edges[0];
        var bestPoint = ToPoint(bestEdge.Start);
        var bestDistance = double.PositiveInfinity;
        foreach (var edge in edges)
        {
            var point = ClosestPointOnSegment(p, ToPoint(edge.Start), ToPoint(edge.End));
            var distance = Distance(p, point);
            if (distance < bestDistance)
            {
                bestEdge = edge;
                bestPoint = point;
                bestDistance = distance;
            }
        }
        return new NearestEdgeResult(bestEdge, bestPoint, bestDistance);
    }

    static double DistancePointToSegment(Point2 p, Point2 a, Point2 b)
    {
        return Distance(p, ClosestPointOnSegment(p, a, b));
    }

    static Point2 ClosestPointOnSegment(Point2 p, Point2 a, Point2 b)
    {
        var dx = b.X - a.X;
        var dy = b.Y - a.Y;
        var len2 = dx * dx + dy * dy;
        if (len2 < 1e-9)
            return a;
        var t = Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / len2, 0, 1);
        return new Point2(a.X + t * dx, a.Y + t * dy);
    }

    static bool IsBetweenMaterialBoundaries(Point2 p, Point2 sleevePoint, Point2 hostPoint)
    {
        var a = Normalize(Sub(sleevePoint, p));
        var b = Normalize(Sub(hostPoint, p));
        return Dot(a, b) <= -0.25;
    }

    static bool IsKnownContactPair(HashSet<string> contactPairs, SketchEdgeInfo sleeveEdge, SketchEdgeInfo hostEdge)
    {
        foreach (var a in EdgeAliases(sleeveEdge))
        foreach (var b in EdgeAliases(hostEdge))
        {
            if (contactPairs.Contains(PairKey(a, b)))
                return true;
        }
        return false;
    }

    static IEnumerable<string> EdgeAliases(SketchEdgeInfo edge)
    {
        yield return edge.Id;
        foreach (var source in edge.SourceEdgeIds)
            yield return source;
    }

    static string PairKey(string a, string b)
    {
        return string.Compare(a, b, StringComparison.OrdinalIgnoreCase) <= 0
            ? $"{NormalizeToken(a)}|{NormalizeToken(b)}"
            : $"{NormalizeToken(b)}|{NormalizeToken(a)}";
    }

    static double EdgeLength(SketchEdgeInfo edge) => Distance(ToPoint(edge.Start), ToPoint(edge.End));
    static Point2 ToPoint(double[] value) => new(value[0], value[1]);
    static double Distance(Point2 a, Point2 b)
    {
        var dx = a.X - b.X;
        var dy = a.Y - b.Y;
        return Math.Sqrt(dx * dx + dy * dy);
    }
    static Point2 Sub(Point2 a, Point2 b) => new(a.X - b.X, a.Y - b.Y);
    static double Dot(Point2 a, Point2 b) => a.X * b.X + a.Y * b.Y;
    static Point2 Normalize(Point2 p)
    {
        var n = Math.Sqrt(p.X * p.X + p.Y * p.Y);
        return n < 1e-9 ? new Point2(0, 0) : new Point2(p.X / n, p.Y / n);
    }
    static double PolygonArea(List<Point2> points)
    {
        double area = 0;
        for (int i = 0; i < points.Count; i++)
        {
            var a = points[i];
            var b = points[(i + 1) % points.Count];
            area += a.X * b.Y - b.X * a.Y;
        }
        return area / 2.0;
    }

    static string FormatDouble(double value) => value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);

    static bool IsSleeveOwner(string owner)
    {
        return ContainsAny(owner, "套筒", "濂楃瓛", "轴套", "衬套", "sleeve", "bushing");
    }

    static bool IsHostOwner(string owner)
    {
        return ContainsAny(owner, "端盖", "绔洊", "箱体", "孔", "housing", "cover", "hole", "host");
    }

    static bool ContainsAny(string text, params string[] tokens)
    {
        return tokens.Any(t => text.Contains(t, StringComparison.OrdinalIgnoreCase));
    }

    static bool TryArray(JsonElement obj, string name, out JsonElement value)
    {
        if (obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Array)
            return true;
        value = default;
        return false;
    }

    static string GetString(JsonElement obj, string name)
    {
        if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
            return "";
        return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
    }

    static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();

    static bool GetBool(JsonElement obj, string name)
    {
        return obj.ValueKind == JsonValueKind.Object &&
               obj.TryGetProperty(name, out var value) &&
               value.ValueKind is JsonValueKind.True;
    }

    readonly record struct Point2(double X, double Y);
    readonly record struct NearestEdgeResult(SketchEdgeInfo Edge, Point2 Point, double Distance);
    sealed class MaterialPolygon
    {
        public List<Point2> Points { get; set; } = [];
    }
    sealed class GapCell
    {
        public int X { get; set; }
        public int Y { get; set; }
        public Point2 Point { get; set; }
        public double SleeveDistance { get; set; }
        public double HostDistance { get; set; }
        public double EstimatedWidth { get; set; }
    }
    sealed class GapCluster
    {
        public GapCluster(List<GapCell> cells)
        {
            Cells = cells;
            MinSleeveDistance = cells.Min(c => c.SleeveDistance);
            MinEstimatedWidth = cells.Min(c => c.EstimatedWidth);
            AxialExtent = cells.Max(c => c.Point.X) - cells.Min(c => c.Point.X);
            RadialMin = cells.Min(c => c.Point.Y);
            RadialMax = cells.Max(c => c.Point.Y);
            CentroidX = cells.Average(c => c.Point.X);
            CentroidY = cells.Average(c => c.Point.Y);
        }
        public List<GapCell> Cells { get; }
        public double MinSleeveDistance { get; }
        public double MinEstimatedWidth { get; }
        public double AxialExtent { get; }
        public double RadialMin { get; }
        public double RadialMax { get; }
        public double CentroidX { get; }
        public double CentroidY { get; }
    }
    readonly record struct SectionBounds(double MinX, double MaxX, double MinY, double MaxY)
    {
        public SectionBounds Expand(double amount) => new(MinX - amount, MaxX + amount, MinY - amount, MaxY + amount);
    }
}

static class PortFeatureSynthesizer
{
    public static void Synthesize(ModelEvidence evidence)
    {
        foreach (var scene in evidence.Scenes.Where(s => s.SceneType.Equals("sleeve_in_hole", StringComparison.OrdinalIgnoreCase)))
        {
            var sleeve = evidence.Components.FirstOrDefault(c => c.Id.Equals(scene.Target, StringComparison.OrdinalIgnoreCase));
            var host = evidence.Components.FirstOrDefault(c => c.Id.Equals(scene.Host, StringComparison.OrdinalIgnoreCase));
            if (sleeve == null || host == null || sleeve.Axis.Length != 3 || sleeve.AxisPointMm.Length != 3)
                continue;

            var sleeveAxis = Normalize(sleeve.Axis);
            var sleeveAxisPoint = sleeve.AxisPointMm;
            var sleeveOuterRadius = sleeve.OuterRadiusMm ?? Math.Max(host.InnerRadiusMm ?? 0, 1.0);
            var hostOuterRadius = host.OuterRadiusMm ?? Math.Max(sleeveOuterRadius, 1.0);
            var maxPortRadius = Math.Max(2.0, Math.Min(20.0, hostOuterRadius * 0.30));

            var candidates = evidence.Items
                .Where(i => i.Type.Equals("cylindrical_face", StringComparison.OrdinalIgnoreCase))
                .Where(i => i.Owner.Equals(host.Id, StringComparison.OrdinalIgnoreCase))
                .Select(ParseCylinder)
                .Where(c => c != null)
                .Select(c => c!)
                .Where(c => c.RadiusMm >= 0.5 && c.RadiusMm <= maxPortRadius)
                .Where(c => !LooksLikeMainCoaxialCylinder(c, sleeve, host))
                .Select(c => new
                {
                    Cylinder = c,
                    AxisDistance = DistanceLineToLine(c.AxisPointMm, c.Axis, sleeveAxisPoint, sleeveAxis),
                    DirectionComponent = Math.Abs(Dot(Normalize(c.Axis), sleeveAxis))
                })
                .Where(c => c.AxisDistance <= sleeveOuterRadius + c.Cylinder.RadiusMm + 2.0)
                .Where(c => c.DirectionComponent >= 0.35)
                .OrderBy(c => c.AxisDistance)
                .ThenBy(c => c.Cylinder.RadiusMm)
                .ToList();

            foreach (var candidate in candidates.Take(2))
            {
                var id = $"port_region:{host.Id}:face_{candidate.Cylinder.FaceIndex}";
                AddEntityOnce(evidence, id, "region", "ejector_or_threaded_hole");
                AddEvidenceOnce(evidence, "outside_connected_cylindrical_port", host.Id, id, 0.72, "PortFeatureSynthesizer.CylinderFaces");
                AddEvidenceOnce(evidence, "port_axis_intersects_sleeve_push_area", host.Id, id, 0.72, "PortFeatureSynthesizer.CylinderFaces");
                AddEvidenceOnce(evidence, "port_push_direction_valid", host.Id, id, 0.72, "PortFeatureSynthesizer.CylinderFaces");
                if (candidate.Cylinder.RadiusMm * 2.0 >= 1.0)
                    AddEvidenceOnce(evidence, "port_diameter_sufficient", host.Id, id, 0.74, "PortFeatureSynthesizer.CylinderFaces");

                evidence.SectionInstances.AddRelation("connected_to_outside", "port_region", "outside", 0.72, "PortFeatureSynthesizer.CylinderFaces");
                evidence.SectionInstances.AddRelation("axis_intersects", "port_axis", "sleeve_push_area", 0.72, "PortFeatureSynthesizer.CylinderFaces");
                evidence.SectionInstances.AddRelation("direction_has_component", "port_axis", "removal_axis", 0.72, "PortFeatureSynthesizer.CylinderFaces");
                evidence.SectionInstances.AddRelation("port_diameter_sufficient", "port_region", "t", 0.74, "PortFeatureSynthesizer.CylinderFaces");
            }
        }
    }

    static CylinderCandidate? ParseCylinder(EvidenceItem item)
    {
        var radius = ParseDouble(Attribute(item, "radius_mm"));
        var axis = ParseArray(Attribute(item, "axis"));
        var axisPoint = ParseArray(Attribute(item, "axis_point_mm"));
        if (radius <= 0 || axis.Length != 3 || axisPoint.Length != 3)
            return null;

        return new CylinderCandidate
        {
            FaceIndex = item.Related,
            RadiusMm = radius,
            Axis = Normalize(axis),
            AxisPointMm = axisPoint
        };
    }

    static bool LooksLikeMainCoaxialCylinder(CylinderCandidate cylinder, ComponentEvidence sleeve, ComponentEvidence host)
    {
        if (sleeve.Axis.Length != 3 || sleeve.AxisPointMm.Length != 3)
            return false;

        var sleeveAxis = Normalize(sleeve.Axis);
        var parallel = Math.Abs(Dot(cylinder.Axis, sleeveAxis)) >= 0.97;
        if (!parallel)
            return false;

        var axisDistance = DistancePointToLine(cylinder.AxisPointMm, sleeve.AxisPointMm, sleeveAxis);
        if (axisDistance > 2.0)
            return false;

        var mainRadii = new[] { sleeve.InnerRadiusMm, sleeve.OuterRadiusMm, host.InnerRadiusMm, host.OuterRadiusMm }
            .Where(r => r.HasValue && r.Value > 0)
            .Select(r => r!.Value);
        return mainRadii.Any(r => Math.Abs(r - cylinder.RadiusMm) <= Math.Max(1.5, r * 0.05));
    }

    static void AddEntityOnce(ModelEvidence evidence, string id, string kind, string role)
    {
        if (evidence.SectionInstances.Entities.Any(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)))
            return;
        evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = kind, SemanticRole = role });
    }

    static void AddEvidenceOnce(ModelEvidence evidence, string type, string owner, string related, double confidence, string source)
    {
        if (evidence.Items.Any(i =>
            i.Type.Equals(type, StringComparison.OrdinalIgnoreCase) &&
            i.Owner.Equals(owner, StringComparison.OrdinalIgnoreCase) &&
            i.Related.Equals(related, StringComparison.OrdinalIgnoreCase)))
            return;

        evidence.Items.Add(new EvidenceItem
        {
            Id = $"{type}:{owner}:{related}:port",
            Type = type,
            Owner = owner,
            Related = related,
            Confidence = confidence,
            Source = source
        });
    }

    static string Attribute(EvidenceItem item, string key)
    {
        return item.Attributes.TryGetValue(key, out var value) ? value : "";
    }

    static double ParseDouble(string value)
    {
        return double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result)
            ? result
            : 0;
    }

    static double[] ParseArray(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return [];
        return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Select(ParseDouble)
            .ToArray();
    }

    static double Dot(double[] a, double[] b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    static double[] Sub(double[] a, double[] b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
    static double[] Scale(double[] a, double s) => [a[0] * s, a[1] * s, a[2] * s];
    static double Norm(double[] a) => Math.Sqrt(Dot(a, a));
    static double[] Normalize(double[] a)
    {
        var n = Norm(a);
        return n < 1e-9 ? [0, 0, 0] : [a[0] / n, a[1] / n, a[2] / n];
    }

    static double DistancePointToLine(double[] p, double[] linePoint, double[] lineDir)
    {
        var d = Normalize(lineDir);
        var v = Sub(p, linePoint);
        var projected = Scale(d, Dot(v, d));
        return Norm(Sub(v, projected));
    }

    static double DistanceLineToLine(double[] p1, double[] d1Raw, double[] p2, double[] d2Raw)
    {
        var d1 = Normalize(d1Raw);
        var d2 = Normalize(d2Raw);
        var cross = Cross(d1, d2);
        var crossNorm = Norm(cross);
        if (crossNorm < 1e-8)
            return DistancePointToLine(p1, p2, d2);
        return Math.Abs(Dot(Sub(p2, p1), cross)) / crossNorm;
    }

    static double[] Cross(double[] a, double[] b)
    {
        return [
            a[1] * b[2] - a[2] * b[1],
            a[2] * b[0] - a[0] * b[2],
            a[0] * b[1] - a[1] * b[0]
        ];
    }

    sealed class CylinderCandidate
    {
        public string FaceIndex { get; set; } = "";
        public double RadiusMm { get; set; }
        public double[] Axis { get; set; } = [];
        public double[] AxisPointMm { get; set; } = [];
    }
}

static class SceneRecognizer
{
    public static void Recognize(ModelEvidence evidence)
    {
        RecognizeSleeveInHole(evidence);
        RecognizeBearingMounting(evidence);
        RecognizeGenericAxialLimit(evidence);
    }

    static void RecognizeSleeveInHole(ModelEvidence evidence)
    {
        var sleeves = evidence.Components
            .Where(c => c.Category is "sleeve_or_ring" || ContainsAny($"{c.Name} {c.PartName}", "套筒", "轴套", "衬套", "sleeve", "bushing"))
            .ToList();
        var hosts = evidence.Components
            .Where(c => c.Category is "host_hole_component" || ContainsAny($"{c.Name} {c.PartName}", "孔", "端盖", "箱体", "housing", "cover", "hole"))
            .ToList();

        foreach (var sleeve in sleeves)
        foreach (var host in hosts)
        {
            var scene = new SceneCandidate
            {
                Id = $"sleeve_in_hole:{sleeve.Id}:{host.Id}",
                SceneType = "sleeve_in_hole",
                Target = sleeve.Id,
                Host = host.Id,
                Confidence = 0.45
            };
            scene.Evidence.Add("sleeve_component");
            scene.Evidence.Add("host_hole_component");

            if (evidence.Has("close_fit_contact", 0.75) || evidence.SectionInstances.HasRelation("contact"))
            {
                scene.Evidence.Add("close_fit_contact");
                scene.Confidence = 0.78;
            }

            if (evidence.Has("coaxial_cylinder_pair", 0.6) || evidence.SectionInstances.HasRelation("coaxial"))
            {
                scene.Evidence.Add("coaxial_cylinder_pair");
                scene.Confidence = Math.Max(scene.Confidence, 0.78);
            }

            if (evidence.Items.Any(i => i.Type == "axial_contact" && (i.Owner == sleeve.Id || i.Related == sleeve.Id)) ||
                evidence.Has("axial_overlap", 0.6) ||
                evidence.SectionInstances.HasRelation("axial_overlap"))
            {
                scene.Evidence.Add("axial_overlap");
                scene.Confidence = Math.Max(scene.Confidence, 0.62);
            }

            AddOrMergeScene(evidence, scene);
        }
    }

    static void RecognizeBearingMounting(ModelEvidence evidence)
    {
        foreach (var bearing in evidence.Components.Where(c => c.Category == "bearing" || ContainsAny(c.Name, "轴承", "bearing")))
        {
            var scene = new SceneCandidate
            {
                Id = $"bearing_mounting:{bearing.Id}",
                SceneType = "bearing_mounting",
                Target = bearing.Id,
                Confidence = 0.75,
                Evidence = ["bearing_component"]
            };
            foreach (var item in evidence.Items.Where(i => i.Owner == bearing.Id && i.Type.StartsWith("bearing_", StringComparison.OrdinalIgnoreCase)))
                scene.Evidence.Add(item.Type);
            AddOrMergeScene(evidence, scene);
        }
    }

    static void RecognizeGenericAxialLimit(ModelEvidence evidence)
    {
        foreach (var component in evidence.Components)
        {
            bool left = evidence.Items.Any(i => i.Owner == component.Id && i.Type == "left_axial_limit");
            bool right = evidence.Items.Any(i => i.Owner == component.Id && i.Type == "right_axial_limit");
            if (!left && !right)
                continue;

            AddOrMergeScene(evidence, new SceneCandidate
            {
                Id = $"axial_limited_component:{component.Id}",
                SceneType = "axial_limited_component",
                Target = component.Id,
                Confidence = left && right ? 0.82 : 0.55,
                Evidence = left && right ? ["left_axial_limit", "right_axial_limit"] : left ? ["left_axial_limit"] : ["right_axial_limit"]
            });
        }
    }

    static void AddOrMergeScene(ModelEvidence evidence, SceneCandidate scene)
    {
        var existing = evidence.Scenes.FirstOrDefault(s =>
            s.SceneType.Equals(scene.SceneType, StringComparison.OrdinalIgnoreCase) &&
            s.Target.Equals(scene.Target, StringComparison.OrdinalIgnoreCase) &&
            s.Host.Equals(scene.Host, StringComparison.OrdinalIgnoreCase));
        if (existing == null)
        {
            evidence.Scenes.Add(scene);
            return;
        }

        existing.Confidence = Math.Max(existing.Confidence, scene.Confidence);
        foreach (var item in scene.Evidence)
        {
            if (!existing.Evidence.Contains(item, StringComparer.OrdinalIgnoreCase))
                existing.Evidence.Add(item);
        }
    }

    static bool ContainsAny(string text, params string[] tokens)
    {
        return tokens.Any(t => text.Contains(t, StringComparison.OrdinalIgnoreCase));
    }
}

static class SectionStructureSynthesizer
{
    public static void Synthesize(ModelEvidence evidence)
    {
        foreach (var item in evidence.Items.ToList())
        {
            switch (item.Type)
            {
                case "close_fit_contact":
                    evidence.SectionInstances.AddRelation("contact", "sleeve_material", "host_material", item.Confidence, item.Source);
                    evidence.SectionInstances.AddRelation("axial_overlap", "sleeve_material", "host_material", 0.65, item.Source);
                    evidence.SectionInstances.AddRelation("coaxial", "sleeve_material", "host_material", 0.55, item.Source);
                    AddDerivedEvidence(evidence, "axial_overlap", "section_graph", "sleeve_material|host_material", 0.65, item.Source);
                    AddDerivedEvidence(evidence, "coaxial_cylinder_pair", "section_graph", "sleeve_material|host_material", 0.62, item.Source);
                    break;
                case "axial_contact":
                    evidence.SectionInstances.AddRelation("adjacent_to", item.Owner, item.Related, item.Confidence, item.Source);
                    break;
                case "left_axial_limit":
                case "right_axial_limit":
                    evidence.SectionInstances.AddRelation("limits_axial_motion", item.Related, item.Owner, item.Confidence, item.Source);
                    break;
            }
        }
    }

    static void AddDerivedEvidence(ModelEvidence evidence, string type, string owner, string related, double confidence, string source)
    {
        if (evidence.Items.Any(i => i.Type == type && i.Owner == owner && i.Related == related))
            return;
        var rawId = $"{type}:{owner}:{related}:derived";
        evidence.Items.Add(new EvidenceItem
        {
            Id = rawId[..Math.Min(64, rawId.Length)],
            Type = type,
            Owner = owner,
            Related = related,
            Confidence = confidence,
            Source = source
        });
    }
}

static class DisassemblyAccessDefinitionFilter
{
    static readonly string[] DirectPushFacts =
    [
        "outside_accessible_push_path",
        "push_path_reaches_sleeve_push_area",
        "push_direction_has_removal_component"
    ];

    static readonly string[] GapFacts =
    [
        "outside_connected_void_gap",
        "gap_path_reaches_sleeve_push_area",
        "gap_width_sufficient",
        "tool_force_direction_valid"
    ];

    public static void Apply(ModelEvidence evidence)
    {
        // These are not priority rules. They remove evidence only when an extractor
        // produced a fact outside that fact's own definition:
        // - c is an open tool gap, not a cylindrical ejector/threaded port.
        // - b is a directly accessible push face, not access mediated by a gap/port.
        var hasCylindricalPortAccess = HasAll(evidence, [
            "outside_connected_cylindrical_port",
            "port_axis_intersects_sleeve_push_area",
            "port_diameter_sufficient",
            "port_push_direction_valid"
        ]);
        var hasOpenToolGapAccess = HasAll(evidence, GapFacts);

        if (hasCylindricalPortAccess)
        {
            RemoveFacts(evidence, GapFacts, sourcePrefix: "GapFeatureSynthesizer");
            RemoveGapRelations(evidence);
            hasOpenToolGapAccess = false;
        }

        if (hasCylindricalPortAccess || hasOpenToolGapAccess)
        {
            RemoveFacts(evidence, DirectPushFacts, sourcePrefix: "SketchGraph.Dimensions");
            RemoveDirectPushRelations(evidence);
        }
    }

    static bool HasAll(ModelEvidence evidence, IEnumerable<string> factTypes)
    {
        return factTypes.All(type => evidence.Has(type, 0.7));
    }

    static void RemoveFacts(ModelEvidence evidence, IEnumerable<string> factTypes, string sourcePrefix)
    {
        var set = factTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
        evidence.Items.RemoveAll(i =>
            set.Contains(i.Type) &&
            i.Source.StartsWith(sourcePrefix, StringComparison.OrdinalIgnoreCase));
    }

    static void RemoveGapRelations(ModelEvidence evidence)
    {
        evidence.SectionInstances.Entities.RemoveAll(e =>
            e.Id.Equals("gap_region:auto_tool_gap", StringComparison.OrdinalIgnoreCase) ||
            e.SemanticRole.Equals("void_gap", StringComparison.OrdinalIgnoreCase));
        evidence.SectionInstances.Relations.RemoveAll(r =>
            r.Source.StartsWith("GapFeatureSynthesizer", StringComparison.OrdinalIgnoreCase) ||
            r.A.Equals("gap_region", StringComparison.OrdinalIgnoreCase) ||
            r.B.Equals("gap_region", StringComparison.OrdinalIgnoreCase));
    }

    static void RemoveDirectPushRelations(ModelEvidence evidence)
    {
        evidence.SectionInstances.Relations.RemoveAll(r =>
            r.Source.Equals("SketchGraph.Dimensions", StringComparison.OrdinalIgnoreCase) &&
            (r.A.Equals("push_access_path", StringComparison.OrdinalIgnoreCase) ||
             r.B.Equals("push_access_path", StringComparison.OrdinalIgnoreCase)));
    }
}

static class DiagnosticPipeline
{
    public static DiagnosticRunResult Run(RuleTemplatePackage package, ModelEvidence evidence, string modelInput, List<string> evidencePaths, string rulePath)
    {
        var result = new DiagnosticRunResult
        {
            Schema = "model_diagnostic_run_v1",
            ModelInput = modelInput,
            EvidencePaths = evidencePaths.Select(Path.GetFullPath).ToList(),
            RulePackagePath = Path.GetFullPath(rulePath),
            ObservedEvidence = evidence,
            PipelineSummary = BuildPipelineSummary(package, evidence)
        };

        foreach (var rule in package.SemanticRules)
            result.RuleResults.Add(RunRule(rule, package.ImageStructureTemplates, evidence));

        return result;
    }

    static PipelineSummary BuildPipelineSummary(RuleTemplatePackage package, ModelEvidence evidence)
    {
        return new PipelineSummary
        {
            RuleCount = package.SemanticRules.Count,
            ComponentCount = evidence.Components.Count,
            EvidenceItemCount = evidence.Items.Count,
            SceneCandidateCount = evidence.Scenes.Count,
            SectionRelationCount = evidence.SectionInstances.Relations.Count,
            SceneTypes = evidence.Scenes.Select(s => s.SceneType).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList(),
            EvidenceTypes = evidence.Items.Select(i => i.Type).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList()
        };
    }

    static RuleDiagnosticResult RunRule(RuleSemanticTemplate rule, List<ImageStructureTemplate2D> templates, ModelEvidence evidence)
    {
        var scenes = evidence.Scenes
            .Where(s => s.SceneType.Equals(rule.TriggerCondition.SceneType, StringComparison.OrdinalIgnoreCase))
            .OrderByDescending(s => s.Confidence)
            .ToList();

        if (scenes.Count == 0)
        {
            return new RuleDiagnosticResult
            {
                RuleId = rule.RuleId,
                Decision = rule.DecisionLogic.IfTriggerMissing,
                ProblemType = null,
                Reason = "No scene candidate matched the rule trigger scene type.",
                Applicability = new ApplicabilityResult { Status = "not_applicable", MissingEvidence = rule.TriggerCondition.RequiredEvidence }
            };
        }

        var bestScene = scenes[0];
        var applicability = EvaluateApplicability(rule, bestScene, evidence);
        if (applicability.Status != "applicable")
        {
            return new RuleDiagnosticResult
            {
                RuleId = rule.RuleId,
                Decision = rule.DecisionLogic.IfTriggerUnknown,
                ProblemType = null,
                Reason = "Scene candidate exists, but trigger evidence is incomplete or weak.",
                Scene = bestScene,
                Applicability = applicability
            };
        }

        var matches = MatchPassEvidence(rule, templates, evidence);
        var foundPass = matches.Any(m => m.Status == "found");
        var decision = foundPass ? rule.DecisionLogic.IfTriggeredAndAnyPassEvidence : rule.DecisionLogic.IfTriggeredAndNoPassEvidence;

        return new RuleDiagnosticResult
        {
            RuleId = rule.RuleId,
            Decision = decision,
            ProblemType = decision == "problem" ? rule.Problem.ProblemType : null,
            Message = decision == "problem" ? rule.Problem.Message : "未发现该项设计问题。",
            Reason = foundPass
                ? "Rule applies and at least one pass evidence template matched the model structure/evidence."
                : "Rule applies, but no pass evidence template matched the model structure/evidence.",
            Scene = bestScene,
            Applicability = applicability,
            PassEvidenceMatches = matches
        };
    }

    static ApplicabilityResult EvaluateApplicability(RuleSemanticTemplate rule, SceneCandidate scene, ModelEvidence evidence)
    {
        var found = new List<string>();
        var missing = new List<string>();

        foreach (var required in rule.TriggerCondition.RequiredEvidence)
        {
            if (scene.Evidence.Contains(required, StringComparer.OrdinalIgnoreCase) || evidence.Has(required, 0.65) || evidence.SectionInstances.HasRelation(required))
                found.Add(required);
            else
                missing.Add(required);
        }

        var enough = missing.Count == 0 ||
                     (found.Contains("sleeve_component") && found.Contains("host_hole_component") && found.Contains("close_fit_contact"));

        return new ApplicabilityResult
        {
            Status = enough ? "applicable" : found.Count == 0 ? "not_applicable" : "candidate",
            Confidence = enough ? Math.Max(scene.Confidence, 0.75) : scene.Confidence,
            FoundEvidence = found,
            MissingEvidence = missing
        };
    }

    static List<PassEvidenceMatch> MatchPassEvidence(RuleSemanticTemplate rule, List<ImageStructureTemplate2D> templates, ModelEvidence evidence)
    {
        var matches = new List<PassEvidenceMatch>();
        foreach (var pass in rule.PassEvidenceTypes)
        {
            var template = templates.FirstOrDefault(t => t.TemplateId.Equals(pass.Required2DTemplateId, StringComparison.OrdinalIgnoreCase));
            var modelEvidenceFound = pass.RequiredModelEvidence.Where(req => evidence.Has(req, 0.7) || evidence.SectionInstances.HasRelation(req)).ToList();
            var modelEvidenceMissing = pass.RequiredModelEvidence
                .Where(req => !modelEvidenceFound.Contains(req, StringComparer.OrdinalIgnoreCase))
                .ToList();
            var structure = template != null
                ? StructureTemplateMatcher.Match(template, evidence.SectionInstances)
                : new StructureMatch { Status = "missing_template" };

            bool found = modelEvidenceFound.Count == pass.RequiredModelEvidence.Count && structure.Status == "found";
            bool candidate = modelEvidenceFound.Count > 0 || structure.Status == "candidate";
            matches.Add(new PassEvidenceMatch
            {
                EvidenceType = pass.EvidenceType,
                SourceExample = pass.SourceExample,
                Anchor = pass.Anchor,
                TemplateId = pass.Required2DTemplateId,
                Status = found ? "found" : candidate ? "candidate" : "missing",
                MatchedModelEvidence = modelEvidenceFound,
                MissingModelEvidence = modelEvidenceMissing,
                MissingModelEvidenceDiagnostics = modelEvidenceMissing
                    .Select(BrepFactCapabilityCatalog.ExplainMissing)
                    .ToList(),
                StructureMatch = structure
            });
        }
        return matches;
    }
}

static class StructureTemplateMatcher
{
    public static StructureMatch Match(ImageStructureTemplate2D template, StructureInstance2D instance)
    {
        var found = new List<string>();
        var missing = new List<string>();

        foreach (var relation in template.RequiredRelations)
        {
            if (relation.Type == "missing")
                continue;

            var relationFound = string.IsNullOrWhiteSpace(relation.A) && string.IsNullOrWhiteSpace(relation.B)
                ? instance.HasRelation(relation.Type)
                : instance.HasRelation(relation.Type, relation.A, relation.B);

            if (relationFound)
                found.Add($"{relation.Type}({relation.A},{relation.B})");
            else
                missing.Add($"{relation.Type}({relation.A},{relation.B})");
        }

        return new StructureMatch
        {
            TemplateId = template.TemplateId,
            Status = missing.Count == 0 ? "found" : found.Count > 0 ? "candidate" : "missing",
            FoundRelations = found,
            MissingRelations = missing
        };
    }
}

static class MarkdownReport
{
    public static string Write(DiagnosticRunResult run)
    {
        var sb = new StringBuilder();
        sb.AppendLine("# Model Diagnostic Verifier");
        sb.AppendLine();
        sb.AppendLine($"- Model input: `{run.ModelInput}`");
        sb.AppendLine($"- Evidence: `{string.Join("`, `", run.EvidencePaths)}`");
        sb.AppendLine($"- Rule package: `{run.RulePackagePath}`");
        sb.AppendLine();
        sb.AppendLine("## Decisions");
        foreach (var rule in run.RuleResults)
        {
            sb.AppendLine($"- `{rule.RuleId}` decision=`{rule.Decision}`, problemType=`{rule.ProblemType ?? ""}`");
            sb.AppendLine($"  - Reason: {rule.Reason}");
            sb.AppendLine($"  - Applicability: `{rule.Applicability.Status}`, found=[{string.Join(", ", rule.Applicability.FoundEvidence)}], missing=[{string.Join(", ", rule.Applicability.MissingEvidence)}]");
            foreach (var match in rule.PassEvidenceMatches)
            {
                sb.AppendLine($"  - Pass evidence `{match.EvidenceType}` example=`{match.SourceExample}` template=`{match.TemplateId}` status=`{match.Status}`");
                sb.AppendLine($"    - model found=[{string.Join(", ", match.MatchedModelEvidence)}], model missing=[{string.Join(", ", match.MissingModelEvidence)}]");
                foreach (var diagnostic in match.MissingModelEvidenceDiagnostics)
                    sb.AppendLine($"      - missing `{diagnostic.Fact}`: status=`{diagnostic.ExtractorStatus}`, source=`{diagnostic.SourceLayer}`, reason={diagnostic.MissingWhenAbsent}");
                sb.AppendLine($"    - structure found=[{string.Join(", ", match.StructureMatch.FoundRelations)}], structure missing=[{string.Join(", ", match.StructureMatch.MissingRelations)}]");
            }
        }
        sb.AppendLine();
        sb.AppendLine("## Scenes");
        foreach (var scene in run.ObservedEvidence.Scenes)
            sb.AppendLine($"- `{scene.SceneType}` target=`{scene.Target}`, host=`{scene.Host}`, confidence={scene.Confidence:0.00}, evidence={string.Join(", ", scene.Evidence)}");
        sb.AppendLine();
        sb.AppendLine("## Evidence Summary");
        foreach (var item in run.ObservedEvidence.Items.Take(80))
            sb.AppendLine($"- `{item.Type}` owner=`{item.Owner}`, related=`{item.Related}`, confidence={item.Confidence:0.00}, source={item.Source}");
        return sb.ToString();
    }
}

sealed class Args
{
    readonly Dictionary<string, string> _values;

    Args(Dictionary<string, string> values)
    {
        _values = values;
    }

    public static Args Parse(string[] args)
    {
        var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        for (int i = 0; i < args.Length; i++)
        {
            var key = args[i];
            if (!key.StartsWith("--", StringComparison.Ordinal))
                throw new ArgumentException($"Unexpected argument: {key}");
            if (i + 1 >= args.Length)
                throw new ArgumentException($"Missing value for {key}");
            values[key] = args[++i];
        }
        return new Args(values);
    }

    public string Required(string key)
    {
        if (!_values.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
            throw new ArgumentException($"{key} is required.");
        return value;
    }

    public string? Optional(string key)
    {
        return _values.TryGetValue(key, out var value) ? value : null;
    }
}

sealed class DiagnosticRunResult
{
    public string Schema { get; set; } = "model_diagnostic_run_v1";
    public string ModelInput { get; set; } = "";
    public List<string> EvidencePaths { get; set; } = [];
    public string RulePackagePath { get; set; } = "";
    public PipelineSummary PipelineSummary { get; set; } = new();
    public ModelEvidence ObservedEvidence { get; set; } = new();
    public List<RuleDiagnosticResult> RuleResults { get; set; } = [];
}

sealed class PipelineSummary
{
    public int RuleCount { get; set; }
    public int ComponentCount { get; set; }
    public int EvidenceItemCount { get; set; }
    public int SceneCandidateCount { get; set; }
    public int SectionRelationCount { get; set; }
    public List<string> SceneTypes { get; set; } = [];
    public List<string> EvidenceTypes { get; set; } = [];
}

sealed class RuleDiagnosticResult
{
    public string RuleId { get; set; } = "";
    public string Decision { get; set; } = "";
    public string? ProblemType { get; set; }
    public string Message { get; set; } = "";
    public string Reason { get; set; } = "";
    public SceneCandidate? Scene { get; set; }
    public ApplicabilityResult Applicability { get; set; } = new();
    public List<PassEvidenceMatch> PassEvidenceMatches { get; set; } = [];
}

sealed class ApplicabilityResult
{
    public string Status { get; set; } = "";
    public double Confidence { get; set; }
    public List<string> FoundEvidence { get; set; } = [];
    public List<string> MissingEvidence { get; set; } = [];
}

sealed class PassEvidenceMatch
{
    public string EvidenceType { get; set; } = "";
    public string SourceExample { get; set; } = "";
    public string Anchor { get; set; } = "";
    public string TemplateId { get; set; } = "";
    public string Status { get; set; } = "";
    public List<string> MatchedModelEvidence { get; set; } = [];
    public List<string> MissingModelEvidence { get; set; } = [];
    public List<BrepFactCapability> MissingModelEvidenceDiagnostics { get; set; } = [];
    public StructureMatch StructureMatch { get; set; } = new();
}

static class BrepFactCapabilityCatalog
{
    static readonly List<BrepFactCapability> Items =
    [
        Fact("sleeve_component", "3d_brep+component", "implemented", "SceneRecognizer.RecognizeSleeveInHole", "component category/name plus cylindrical section/contact evidence", "No sleeve-like component candidate was recognized."),
        Fact("sleeve_like_component", "3d_brep+component", "implemented", "SceneRecognizer.RecognizeSleeveInHole", "component category/name plus cylindrical section/contact evidence", "No sleeve-like component candidate was recognized."),
        Fact("host_hole_component", "3d_brep+component", "implemented", "SceneRecognizer.RecognizeSleeveInHole", "host/housing component and coaxial/contact evidence", "No host or hole component candidate was recognized."),
        Fact("coaxial_cylinder_pair", "2d_section_brep", "implemented", "SectionStructureSynthesizer/SketchGraph.Relations", "coaxial sleeve/host section geometry", "No coaxial sleeve-host pair was detected."),
        Fact("axial_overlap", "2d_section_brep", "implemented", "SectionStructureSynthesizer/SketchGraph.Relations", "overlapping axial ranges in section", "No axial overlap between sleeve and host was detected."),
        Fact("close_fit_contact", "2d_section_brep", "implemented", "SketchGraph.Relations contact", "contact or near-contact section edges", "No close-fit contact relation was detected."),
        Fact("close_fit_contact_or_small_clearance", "2d_section_brep", "implemented", "SketchGraph.Relations contact/clearance", "contact or small clearance section edges", "No close-fit contact or small clearance relation was detected."),
        Fact("outside_accessible_push_path", "2d_section_brep", "implemented", "ReadSketchDimension exposed_push_face_candidate", "exposed sleeve end edge and outside clearance", "No exposed sleeve push face was detected."),
        Fact("push_path_reaches_sleeve_push_area", "2d_section_brep", "implemented", "ReadSketchDimension exposed_push_face_candidate", "exposed sleeve end edge", "No push path reaches a sleeve push area."),
        Fact("push_direction_has_removal_component", "2d_section_brep", "implemented", "ReadSketchDimension exposed_push_face_candidate", "push face orientation against sleeve axis", "No push direction with removal-axis component was detected."),
        Fact("outside_connected_void_gap", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "outside-connected free-space cells between sleeve and host", "No outside-connected sleeve/host gap candidate was detected."),
        Fact("gap_path_reaches_sleeve_push_area", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "gap cluster adjacent to sleeve push area", "No detected gap cluster reached the sleeve push area."),
        Fact("gap_width_sufficient", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "estimated minimum sleeve-host free-space width", "No detected gap satisfied the minimum tool-width threshold."),
        Fact("tool_force_direction_valid", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "gap cluster with axial extent along removal direction", "No detected gap had a sufficient removal-direction extent."),
        Fact("outside_connected_cylindrical_port", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "small host/end-cap cylindrical face treated as port candidate", "No small host/end-cap cylindrical port candidate was detected."),
        Fact("port_axis_intersects_sleeve_push_area", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "port axis close enough to sleeve push area", "No port axis was found to reach the sleeve push area."),
        Fact("port_diameter_sufficient", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "cylindrical port radius/diameter", "No detected port diameter satisfied the minimum threshold."),
        Fact("port_push_direction_valid", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "port axis with removal-axis force component", "No detected port axis had a sufficient removal-direction component."),
        Fact("thread_or_screw_feature", "feature_metadata_or_modeled_thread", "optional_only", "optional SolidWorks feature/thread metadata or modeled helical B-rep", "thread feature metadata or real thread geometry", "Pure B-rep often does not contain reliable thread semantics; keep this optional.")
    ];

    public static BrepFactCapability ExplainMissing(string fact)
    {
        var normalized = NormalizeToken(fact);
        var capability = Items.FirstOrDefault(i => i.Fact.Equals(normalized, StringComparison.OrdinalIgnoreCase));
        return capability?.Clone() ?? new BrepFactCapability
        {
            Fact = normalized,
            SourceLayer = "unknown",
            ExtractorStatus = "unregistered",
            Extractor = "",
            RequiredPrimitive = "",
            MissingWhenAbsent = "No B-rep fact capability is registered for this evidence name.",
            AuthoringAllowed = false
        };
    }

    static BrepFactCapability Fact(string fact, string source, string status, string extractor, string primitive, string missingWhenAbsent)
    {
        return new BrepFactCapability
        {
            Fact = fact,
            SourceLayer = source,
            ExtractorStatus = status,
            Extractor = extractor,
            RequiredPrimitive = primitive,
            MissingWhenAbsent = missingWhenAbsent,
            AuthoringAllowed = status is "implemented" or "partial" or "planned"
        };
    }

    static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
}

sealed class BrepFactCapability
{
    public string Fact { get; set; } = "";
    public string SourceLayer { get; set; } = "";
    public string ExtractorStatus { get; set; } = "";
    public string Extractor { get; set; } = "";
    public string RequiredPrimitive { get; set; } = "";
    public string MissingWhenAbsent { get; set; } = "";
    public bool AuthoringAllowed { get; set; }

    public BrepFactCapability Clone()
    {
        return new BrepFactCapability
        {
            Fact = Fact,
            SourceLayer = SourceLayer,
            ExtractorStatus = ExtractorStatus,
            Extractor = Extractor,
            RequiredPrimitive = RequiredPrimitive,
            MissingWhenAbsent = MissingWhenAbsent,
            AuthoringAllowed = AuthoringAllowed
        };
    }
}

sealed class StructureMatch
{
    public string TemplateId { get; set; } = "";
    public string Status { get; set; } = "";
    public List<string> FoundRelations { get; set; } = [];
    public List<string> MissingRelations { get; set; } = [];
}

sealed class ModelEvidence
{
    public string Schema { get; set; } = "model_evidence_v1";
    public List<string> SourcePaths { get; set; } = [];
    public string ModelPath { get; set; } = "";
    public List<ComponentEvidence> Components { get; set; } = [];
    public List<EvidenceItem> Items { get; set; } = [];
    public List<SceneCandidate> Scenes { get; set; } = [];
    public StructureInstance2D SectionInstances { get; set; } = new();

    public bool Has(string type, double minConfidence)
    {
        var normalized = NormalizeToken(type);
        return Items.Any(i => i.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) && i.Confidence >= minConfidence);
    }

    static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
}

sealed class ComponentEvidence
{
    public string Id { get; set; } = "";
    public string Name { get; set; } = "";
    public string PartName { get; set; } = "";
    public string Category { get; set; } = "";
    public string Path { get; set; } = "";
    public double[] Axis { get; set; } = [];
    public double[] AxisPointMm { get; set; } = [];
    public double AxialMinMm { get; set; }
    public double AxialMaxMm { get; set; }
    public double? InnerRadiusMm { get; set; }
    public double? OuterRadiusMm { get; set; }
}

sealed class EvidenceItem
{
    public string Id { get; set; } = "";
    public string Type { get; set; } = "";
    public string Owner { get; set; } = "";
    public string Related { get; set; } = "";
    public double Confidence { get; set; }
    public string Source { get; set; } = "";
    public Dictionary<string, string> Attributes { get; set; } = [];
}

sealed class SceneCandidate
{
    public string Id { get; set; } = "";
    public string SceneType { get; set; } = "";
    public string Target { get; set; } = "";
    public string Host { get; set; } = "";
    public double Confidence { get; set; }
    public List<string> Evidence { get; set; } = [];
}

sealed class StructureInstance2D
{
    public string Schema { get; set; } = "structure_instance_2d_v1";
    public List<StructureEntity> Entities { get; set; } = [];
    public List<StructureRelation> Relations { get; set; } = [];

    public void AddRelation(string type, string a, string b, double confidence, string source)
    {
        var normalized = NormalizeToken(type);
        var existing = Relations.FirstOrDefault(r =>
            r.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) &&
            r.A.Equals(a, StringComparison.OrdinalIgnoreCase) &&
            r.B.Equals(b, StringComparison.OrdinalIgnoreCase));
        if (existing != null)
        {
            existing.Confidence = Math.Max(existing.Confidence, confidence);
            return;
        }

        Relations.Add(new StructureRelation
        {
            Type = normalized,
            A = a,
            B = b,
            Confidence = confidence,
            Source = source
        });
    }

    public bool HasRelation(string type)
    {
        var normalized = NormalizeToken(type);
        return Relations.Any(r => r.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase));
    }

    public bool HasRelation(string type, string a, string b)
    {
        var normalized = NormalizeToken(type);
        return Relations.Any(r =>
            r.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) &&
            (string.IsNullOrWhiteSpace(a) || r.A.Equals(a, StringComparison.OrdinalIgnoreCase) || r.B.Equals(a, StringComparison.OrdinalIgnoreCase)) &&
            (string.IsNullOrWhiteSpace(b) || r.A.Equals(b, StringComparison.OrdinalIgnoreCase) || r.B.Equals(b, StringComparison.OrdinalIgnoreCase)));
    }

    static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
}

sealed class StructureEntity
{
    public string Id { get; set; } = "";
    public string Kind { get; set; } = "";
    public string SemanticRole { get; set; } = "";
}

sealed class StructureRelation
{
    public string Type { get; set; } = "";
    public string A { get; set; } = "";
    public string B { get; set; } = "";
    public double Confidence { get; set; }
    public string Source { get; set; } = "";
}

sealed class RuleTemplatePackage
{
    public string Schema { get; set; } = "";
    public string PackageId { get; set; } = "";
    public List<RuleSemanticTemplate> SemanticRules { get; set; } = [];
    public List<ImageStructureTemplate2D> ImageStructureTemplates { get; set; } = [];
}

sealed class RuleSemanticTemplate
{
    public string RuleId { get; set; } = "";
    public RuleTriggerCondition TriggerCondition { get; set; } = new();
    public ProblemTemplate Problem { get; set; } = new();
    public List<PassEvidenceTemplate> PassEvidenceTypes { get; set; } = [];
    public RuleDecisionLogic DecisionLogic { get; set; } = new();
}

sealed class RuleTriggerCondition
{
    public string SceneType { get; set; } = "";
    public List<string> RequiredEvidence { get; set; } = [];
}

sealed class ProblemTemplate
{
    public string ProblemType { get; set; } = "";
    public string Message { get; set; } = "";
}

sealed class PassEvidenceTemplate
{
    public string EvidenceType { get; set; } = "";
    public string SourceExample { get; set; } = "";
    public string Anchor { get; set; } = "";
    public string Required2DTemplateId { get; set; } = "";
    public List<string> RequiredModelEvidence { get; set; } = [];
    public List<string> OptionalModelEvidence { get; set; } = [];
}

sealed class RuleDecisionLogic
{
    public string IfTriggerMissing { get; set; } = "not_applicable";
    public string IfTriggerUnknown { get; set; } = "needs_review";
    public string IfTriggeredAndAnyPassEvidence { get; set; } = "pass";
    public string IfTriggeredAndNoPassEvidence { get; set; } = "problem";
}

sealed class ImageStructureTemplate2D
{
    public string TemplateId { get; set; } = "";
    public string Role { get; set; } = "";
    public List<TemplateRelation> RequiredRelations { get; set; } = [];
}

sealed class TemplateRelation
{
    public string Type { get; set; } = "";
    public string A { get; set; } = "";
    public string B { get; set; } = "";
}

static class JsonOptions
{
    public static readonly JsonSerializerOptions Default = new()
    {
        PropertyNameCaseInsensitive = true,
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
    };
}
