using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Core.Domain.Entities;
using Hc900Crawler.Infrastructure.Database;
using Hc900Crawler.Infrastructure.Mcp;
using Microsoft.EntityFrameworkCore;
using netDxf;
using UglyToad.PdfPig;

namespace Hc900Crawler.Core.Application.Services;

public class PidExtractorService : IPidExtractorService
{
    private readonly McpClient _mcp;
    private readonly Hc900DbContext _dbContext;
    private readonly ILogger<PidExtractorService> _logger;
    private readonly SemaphoreSlim _cacheLock = new(1, 1);
    private List<PidPrefixRule>? _cachedRules;

    public PidExtractorService(McpClient mcp, Hc900DbContext dbContext, ILogger<PidExtractorService> logger)
    {
        _mcp = mcp;
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
    {
        await using var stream = File.OpenRead(filePath);
        return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode);
    }

    public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false)
    {
        var ext = Path.GetExtension(fileName).ToLowerInvariant();

        string text;
        Dictionary<string, (double X, double Y, double H)>? coords = null;

        if (ext == ".dxf")
            (text, coords) = ExtractDxfText(stream);
        else if (ext == ".pdf")
            text = ExtractPdfText(stream);
        else
            throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)");

        if (string.IsNullOrWhiteSpace(text))
            return new PidExtractionResult(0, 0, 0);

        // MCP → vLLM 태그 추출
        var sourceType = ext.TrimStart('.');
        var json = await _mcp.ExtractPidTagsAsync(text, sourceType);
        var extractedItems = ParseJson(json);

        if (extractedItems.Count == 0)
        {
            _logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName);
            return new PidExtractionResult(0, 0, 0);
        }

        // MCP → vLLM 태그 매핑 제안
        var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList();
        var experionTagNames = await _dbContext.RealtimePoints.Select(r => r.TagName).ToListAsync();
        var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
        var mappings = ParseMappingJson(mappingJson);

        // 중복 체크: 기존 DB에 있는 TagNo는 제외 (대소문자 구분 없음)
        var existingTagNos = new HashSet<string>(
            await _dbContext.PidEquipment.Select(e => e.TagNo).ToListAsync(),
            StringComparer.OrdinalIgnoreCase);
        var newItems = extractedItems.Where(i => !existingTagNos.Contains(i.TagNo)).ToList();
        int skippedCount = extractedItems.Count - newItems.Count;

        if (skippedCount > 0)
            _logger.LogInformation("P&ID 중복 제외: {Skipped}건 스킵 (이미 존재)", skippedCount);

        // DB 저장
        var prefixRules = await GetRulesCachedAsync();
        var dbItems = new List<PidEquipment>();
        foreach (var item in newItems)
        {
            mappings.TryGetValue(item.TagNo, out var matched);
            var experionTag = matched != null
                ? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
                : await FindFallbackTagAsync(item.TagNo);

            var category = await MatchCategoryAsync(item.TagNo);
            var tagDcs   = await ResolveTagDcsAsync(item.TagNo);
            var tagClass = ClassifyTagClass(category, tagDcs);

            var newItem = new PidEquipment
            {
                TagNo = item.TagNo,
                EquipmentName = ResolveEquipmentName(item.InstrumentType, prefixRules)
                                ?? item.EquipmentName,
                InstrumentType = item.InstrumentType,
                LineNumber = item.LineNumber,
                PidDrawingNo = item.PidDrawingNo,
                Confidence = item.Confidence,
                ExperionTagId = experionTag?.Id,
                Category = category,
                TagDcs = tagDcs,
                TagClass = tagClass,
                ExtractedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow
            };
            if (coords != null && coords.TryGetValue(item.TagNo, out var c))
            {
                newItem.PosX = c.X;
                newItem.PosY = c.Y;
            }
            if (newItem.PidDrawingNo == null)
                newItem.PidDrawingNo = fileName;
            newItem.DrawingFile = fileName;
            dbItems.Add(newItem);
        }

        if (dbItems.Count > 0)
        {
            await _dbContext.PidEquipment.AddRangeAsync(dbItems);
            await _dbContext.SaveChangesAsync();
        }

        _logger.LogInformation(
            "P&ID 추출 완료: {Total}건 저장, {Skipped}건 중복 스킵 (파일: {FileName})",
            dbItems.Count, skippedCount, fileName);

        return new PidExtractionResult(
            TotalCount: dbItems.Count,
            ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
            LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5),
            SkippedDuplicates: skippedCount);
    }

    private (string Text, Dictionary<string, (double X, double Y, double H)> Coords) ExtractDxfText(Stream stream)
    {
        var tmp = Path.GetTempFileName() + ".dxf";
        try
        {
            using (var fs = File.Create(tmp))
                stream.CopyTo(fs);

            var doc = DxfDocument.Load(tmp);
            var sb = new StringBuilder();

            var positioned = new List<(string Value, double X, double Y, double H)>();

            foreach (var txt in doc.Entities.Texts)
            {
                sb.AppendLine(txt.Value);
                if (!string.IsNullOrWhiteSpace(txt.Value))
                    positioned.Add((txt.Value.Trim(), txt.Position.X, txt.Position.Y, txt.Height));
            }
            foreach (var mtxt in doc.Entities.MTexts)
            {
                var plain = mtxt.PlainText();
                sb.AppendLine(plain);
                if (!string.IsNullOrWhiteSpace(plain))
                    positioned.Add((plain.Trim(), mtxt.Position.X, mtxt.Position.Y, mtxt.Height));
            }
            foreach (var blk in doc.Blocks)
                foreach (var attr in blk.AttributeDefinitions.Values)
                    sb.AppendLine(attr.Value);

            // CIRCLE 중심 좌표 매핑 — TEXT가 원 안에 있으면 원 중심으로 치환
            var circles = doc.Entities.Circles
                .Select(c => (X: c.Center.X, Y: c.Center.Y, R: c.Radius))
                .ToList();
            var circleCoords = new Dictionary<string, (double X, double Y)>(StringComparer.OrdinalIgnoreCase);
            foreach (var (val, x, y, _) in positioned)
            {
                double bestR = double.MaxValue;
                double cx = 0, cy = 0;
                foreach (var (ccx, ccy, cr) in circles)
                {
                    var dx = x - ccx;
                    var dy = y - ccy;
                    var d = Math.Sqrt(dx * dx + dy * dy);
                    if (d < cr && cr < bestR)
                    {
                        bestR = cr;
                        cx = ccx; cy = ccy;
                    }
                }
                if (bestR < double.MaxValue)
                    circleCoords.TryAdd(val, (cx, cy));
            }

            // 계기 풍선 재조합
            var tagRe = new Regex(@"[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*", RegexOptions.IgnoreCase);
            var balloons = ReconstructBalloonTags(positioned);
            foreach (var (tag, _, _, _) in balloons)
                sb.AppendLine(tag);

            var text = FilterDxfText(sb.ToString());

            // 필터 통과한 태그 집합
            var filteredSet = new HashSet<string>(
                text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
                StringComparer.OrdinalIgnoreCase);

            // 좌표 맵 — 태그당 첫 좌표만 저장 (CIRCLE 내부면 원 중심 우선)
            var coords = new Dictionary<string, (double X, double Y, double H)>(StringComparer.OrdinalIgnoreCase);
            foreach (var (val, x, y, h) in positioned)
            {
                if (!tagRe.IsMatch(val) || !filteredSet.Contains(val))
                    continue;
                if (circleCoords.TryGetValue(val, out var cc))
                    coords.TryAdd(val, (cc.X, cc.Y, h));
                else
                    coords.TryAdd(val, (x, y, h));
            }
            foreach (var (tag, x, y, h) in balloons)
            {
                if (!filteredSet.Contains(tag))
                    continue;
                var hyphenPos = tag.IndexOf('-');
                var func = hyphenPos > 0 ? tag[..hyphenPos] : null;
                var num = hyphenPos > 0 && hyphenPos + 1 < tag.Length ? tag[(hyphenPos + 1)..] : null;
                if ((func != null && circleCoords.TryGetValue(func, out var cc))
                    || (num != null && circleCoords.TryGetValue(num, out cc)))
                    coords.TryAdd(tag, (cc.X, cc.Y, h));
                else
                    coords.TryAdd(tag, (x, y, h));
            }

            return (text, coords);
        }
        finally
        {
            if (File.Exists(tmp)) File.Delete(tmp);
        }
    }

    /// <summary>
    /// DXF 텍스트에서 P&ID 태그 패턴에 해당하는 라인만 필터링
    /// 불필요한 텍스트를 제거하여 MCP 서버 부하 감소 및 JSON 파싱 오류 방지
    /// </summary>
    private string FilterDxfText(string text)
    {
        var lines = text.Split('\n');
        var filteredLines = new List<string>();
        
        foreach (var line in lines)
        {
            var trimmed = line.Trim();
            
            // P&ID 태그 패턴 포함 라인만 유지
            // - 단일 글자 장비 태그 포함: P-10101, T-10100, E-10119, C-10111
            // - 다중 글자 계측 태그: FCV-101, FICQ-6113, PSV-6203
            // - 복합 태그: VG-6203-15A-F1A-n, CD-10513-40A
            if (Regex.IsMatch(trimmed, @"[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*", RegexOptions.IgnoreCase))
            {
                filteredLines.Add(trimmed);
            }
        }
        
        return string.Join("\n", filteredLines);
    }

    // ISA 계기 기능코드: 측정변수(첫 글자) + 후속문자(1~3).
    // 첫 글자에 L·H 포함하되 후속문자 집합에서 L·H·P 제외 → LL/HH/HP 등
    // 셋포인트·재질 라벨은 배제, FT/PT/LT/TR/TE/TIC/FICQ/PSV/PG 등은 통과.
    private static readonly Regex _instrFuncRe = new(
        @"^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$", RegexOptions.Compiled);

    // 루프번호: 3~6자리 숫자 + 선택적 말미 접미 알파벳 (예: 9101, 9111A, 10900B)
    private static readonly Regex _loopNumRe = new(
        @"^\d{3,6}[A-Z]?$", RegexOptions.Compiled);

    /// <summary>
    /// 계기 풍선의 기능코드 TEXT와 루프번호 TEXT를 근접 좌표로 짝지어
    /// <c>FUNC-NUMBER</c> 형태로 재조합. 임계값은 텍스트 높이에 비례시켜
    /// 도면 스케일에 무관하게 동작. (TE/TI/TG가 같은 루프번호 공유 → 다대일 허용)
    /// </summary>
    private List<(string Tag, double X, double Y, double H)> ReconstructBalloonTags(
        List<(string Value, double X, double Y, double H)> texts)
    {
        var funcs = texts.Where(t => _instrFuncRe.IsMatch(t.Value)).ToList();
        var nums  = texts.Where(t => _loopNumRe.IsMatch(t.Value)).ToList();
        if (funcs.Count == 0 || nums.Count == 0)
            return [];

        var result = new List<(string Tag, double X, double Y, double H)>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var f in funcs)
        {
            double bestDist = double.MaxValue;
            string? bestNum = null;
            double nx = 0, ny = 0;
            foreach (var n in nums)
            {
                var dx = f.X - n.X;
                var dy = f.Y - n.Y;
                var d = Math.Sqrt(dx * dx + dy * dy);
                if (d < bestDist) { bestDist = d; bestNum = n.Value; nx = n.X; ny = n.Y; }
            }

            var threshold = f.H > 0 ? f.H * 5.0 : 12.0;
            if (bestNum != null && bestDist <= threshold)
            {
                var tag = $"{f.Value}-{bestNum}";
                if (seen.Add(tag))
                    result.Add((tag, (f.X + nx) / 2, (f.Y + ny) / 2, f.H));
            }
        }
        return result;
    }

    private string ExtractPdfText(Stream stream)
    {
        using var pdf = PdfDocument.Open(stream);
        var sb = new StringBuilder();
        foreach (var page in pdf.GetPages())
            sb.AppendLine(page.Text);
        return sb.ToString();
    }

    private List<ExtractedItem> ParseJson(string json)
    {
        try
        {
            // MCP 서버 응답 형식: {"success": ..., "count": ..., "tags": [...]}
            // 또는 기존 형식: [...]
            using var doc = JsonDocument.Parse(json);
            var root = doc.RootElement;
            
            // "tags" 필드가 있으면 중첩 구조로 간주
            if (root.TryGetProperty("tags", out var tagsElement))
            {
                return JsonSerializer.Deserialize<List<ExtractedItem>>(tagsElement.GetRawText(),
                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
            }
            
            // 루트가 배열이면 직접 파싱
            if (root.ValueKind == JsonValueKind.Array)
            {
                return JsonSerializer.Deserialize<List<ExtractedItem>>(json,
                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
            }
            
            _logger.LogWarning("P&ID JSON 파싱 실패: 'tags' 필드 또는 배열 형식 없음");
            return [];
        }
        catch (Exception ex)
        {
            _logger.LogWarning("P&ID JSON 파싱 실패: {Msg} / raw: {Raw}", ex.Message, json[..Math.Min(200, json.Length)]);
            return [];
        }
    }

    private Dictionary<string, string> ParseMappingJson(string json)
    {
        try
        {
            using var doc = JsonDocument.Parse(json);
            var root = doc.RootElement;

            // MCP 응답: {"success": ..., "count": ..., "mappings": [...]}
            JsonElement arrayEl = root.ValueKind == JsonValueKind.Array
                ? root
                : root.TryGetProperty("mappings", out var m) ? m : default;

            if (arrayEl.ValueKind != JsonValueKind.Array)
                return [];

            var list = JsonSerializer.Deserialize<List<MappingItem>>(arrayEl.GetRawText(),
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
            return list
                .Where(m => m.Confidence >= 0.7 && !string.IsNullOrEmpty(m.ExperionTag))
                .ToDictionary(m => m.PidTag, m => m.ExperionTag!);
        }
        catch { return []; }
    }

    private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
    {
        var baseTag = tagNo.Split('.')[0];
        return await _dbContext.RealtimePoints
            .FirstOrDefaultAsync(t => t.TagName.Equals(baseTag, StringComparison.OrdinalIgnoreCase)
                                   || t.TagName.StartsWith(baseTag + ".", StringComparison.OrdinalIgnoreCase));
    }

    public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
        string? tagNo, string? category, int page, int pageSize,
        string? sortBy = null, bool sortDesc = false)
    {
        var q = _dbContext.PidEquipment.AsQueryable();
        if (!string.IsNullOrEmpty(tagNo))
            q = q.Where(e => e.TagNo.Contains(tagNo));
        if (!string.IsNullOrEmpty(category))
        {
            if (category == "__unmatched__")
                q = q.Where(e => e.Category == null);
            else if (category == "instrument_dcs")
                q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagDcs);
            else if (category == "instrument_field")
                q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && !e.TagDcs);
            else
                q = q.Where(e => e.Category == category);
        }

        var total = await q.CountAsync();

        List<PidEquipment> items;
        if (sortBy == "tagName")
        {
            // DB 전체 로드 후 C#에서 natural sort (prefix 알파 → 숫자 정수값 → suffix 알파)
            var all = await q.ToListAsync();
            if (sortDesc)
                all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ToList();
            else
                all = all.OrderBy(e => TagSortKey(e.TagNo)).ToList();
            items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
        }
        else
        {
            items = await q.OrderByDescending(e => e.ExtractedAt)
                .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
        }

        // batch-load sub_area from tag_metadata
        if (items.Count > 0)
        {
            var tagNos = items.Select(e => e.TagNo).ToHashSet(StringComparer.OrdinalIgnoreCase);
            var subAreas = await _dbContext.TagMetadata
                .Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area")
                .Select(m => new { m.BaseTag, m.Value })
                .ToListAsync();
            var subMap = subAreas.ToDictionary(sa => sa.BaseTag, sa => sa.Value, StringComparer.OrdinalIgnoreCase);
            foreach (var e in items)
            {
                if (subMap.TryGetValue(e.TagNo, out var sa))
                    e.SubArea = sa;
            }
        }

        return (total, items);
    }

    private static string TagSortKey(string tagNo)
    {
        // (알파 prefix)(숫자부)(알파 suffix) 분해 → 숫자를 정수로 비교
        // TI-6111B → "ti-" + 0000006111 + "b"
        // TI-10103 → "ti-" + 0000010103 + ""
        var tag = tagNo.ToLowerInvariant();
        var m = Regex.Match(tag, @"^([^\d]+)(\d+)([a-z]*)$");
        if (!m.Success)
            return tag;
        var prefix = m.Groups[1].Value;
        var num    = long.TryParse(m.Groups[2].Value, out var n) ? n : 0L;
        var suffix = m.Groups[3].Value;
        return $"{prefix}\x00{num:D12}\x00{suffix}";
    }

    public async Task<PidEquipment?> GetByIdAsync(long id)
        => await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);

    public async Task UpdateConfidenceAsync(long id, double confidence)
    {
        var e = await _dbContext.PidEquipment.FindAsync(id);
        if (e == null) return;
        e.Confidence = confidence; e.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
    }

    public async Task<PidEquipment> CreateEquipmentAsync(CreateEquipmentRequest request)
    {
        var e = new PidEquipment
        {
            TagNo = request.TagNo,
            EquipmentName = request.EquipmentName,
            InstrumentType = request.InstrumentType,
            Category = request.Category,
            TagDcs = request.TagDcs ?? false,
            Role = request.Role,
            FromTag = request.FromTag,
            ToTag = request.ToTag,
            FromAt = request.FromAt,
            ToAt = request.ToAt,
            TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
            IsActive = true,
            Confidence = 1.0,
            ExtractedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow
        };
        if (request.FromTag != null || request.ToTag != null)
            e.ConnectionLocked = true;
        _dbContext.PidEquipment.Add(e);
        await _dbContext.SaveChangesAsync();
        _logger.LogInformation("[PID] Created equipment id={Id} tagNo={Tag}", e.Id, e.TagNo);
        return e;
    }

    public async Task<bool> UpdateEquipmentAsync(long id, UpdateEquipmentRequest request)
    {
        var e = await _dbContext.PidEquipment.FindAsync(id);
        if (e == null) return false;
        if (request.TagNo != null) e.TagNo = request.TagNo;
        if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName;
        if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType;
        if (request.Category != null) e.Category = request.Category;
        if (request.TagDcs.HasValue) e.TagDcs = request.TagDcs.Value;
        if (request.Role != null) e.Role = request.Role;
        if (request.FromTag != null) e.FromTag = request.FromTag;
        if (request.ToTag != null) e.ToTag = request.ToTag;
        if (request.FromAt != null) e.FromAt = request.FromAt;
        if (request.ToAt != null) e.ToAt = request.ToAt;
        if (request.TagClass != null) e.TagClass = request.TagClass;
        if (request.Category != null || request.TagDcs.HasValue)
            e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
        if (request.FromTag != null || request.ToTag != null)
            e.ConnectionLocked = true;
        e.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
        if (request.SubArea != null)
        {
            var baseTag = e.TagNo;
            if (string.IsNullOrWhiteSpace(request.SubArea))
                await _dbContext.Database.ExecuteSqlRawAsync(
                    "DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag);
            else
                await _dbContext.Database.ExecuteSqlRawAsync(
                    @"INSERT INTO tag_metadata (base_tag, attribute, value)
                      VALUES ({0}, 'sub_area', {1})
                      ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()",
                    baseTag, request.SubArea.Trim());
        }
        return true;
    }

    public async Task ActivateAsync(long id)
    {
        var e = await _dbContext.PidEquipment.FindAsync(id);
        if (e == null) return;
        e.IsActive = true; e.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
    }

    public async Task DeactivateAsync(long id)
    {
        var e = await _dbContext.PidEquipment.FindAsync(id);
        if (e == null) return;
        e.IsActive = false; e.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
    }

    public async Task<bool> DeleteAsync(long id)
    {
        var e = await _dbContext.PidEquipment.FindAsync(id);
        if (e == null) return false;
        _dbContext.PidEquipment.Remove(e);
        await _dbContext.SaveChangesAsync();
        return true;
    }

    public async Task<(bool Found, string? TagName)> AutoMapTagAsync(long id)
    {
        var e = await _dbContext.PidEquipment.FindAsync(id);
        if (e == null) return (false, null);
        var match = await FindFallbackTagAsync(e.TagNo ?? "");
        if (match == null) return (false, null);
        e.ExperionTagId = match.Id;
        e.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
        return (true, match.TagName);
    }

    public async Task<int> DeleteAllEquipmentAsync()
    {
        var count = await _dbContext.PidEquipment.CountAsync();
        if (count == 0) return 0;
        await _dbContext.Database.ExecuteSqlRawAsync("DELETE FROM pid_equipment");
        _logger.LogInformation("[PID] 전체 삭제 완료: {Count}건", count);
        return count;
    }

    public Task<int> GetTotalCountAsync() => _dbContext.PidEquipment.CountAsync();
    public Task<int> GetConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence >= 0.7);
    public Task<int> GetLowConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence < 0.5);
    public Task<int> GetDrawingCountAsync() => _dbContext.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync();

    public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
    {
        var items = await _dbContext.PidEquipment.ToListAsync();
        return new Dictionary<string, int>
        {
            ["High (>=0.7)"] = items.Count(i => i.Confidence >= 0.7),
            ["Medium (0.5-0.7)"] = items.Count(i => i.Confidence >= 0.5 && i.Confidence < 0.7),
            ["Low (<0.5)"] = items.Count(i => i.Confidence < 0.5)
        };
    }

    public async Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
    {
        return await Task.Run(() =>
        {
            var sb = new StringBuilder();
            sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId,Category,Role,From,To,TagClass");
            foreach (var i in items)
                sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.LineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId},{Csv(i.Category)},{Csv(i.Role)},{Csv(i.FromTag)},{Csv(i.ToTag)},{Csv(i.TagClass)}");
            return sb.ToString();
        });
    }

    private static string Csv(string? v)
    {
        if (string.IsNullOrEmpty(v)) return "";
        return (v.Contains(',') || v.Contains('"') || v.Contains('\n'))
            ? $"\"{v.Replace("\"", "\"\"")}\"" : v;
    }

    public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
    {
        return await Task.Run(async () =>
        {
            using var package = new OfficeOpenXml.ExcelPackage();

            var rules = await GetRulesCachedAsync();

            var grouped = items
                .GroupBy(i =>
                {
                    var cat = string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!;
                    if (cat == PidEquipment.CategoryInstrument)
                        return i.TagDcs ? "instrument_dcs" : "instrument_field";
                    return cat;
                })
                .ToDictionary(g => g.Key, g => g.ToList());

            var sheetOrder = new[]
            {
                "instrument_dcs",
                "instrument_field",
                PidEquipment.CategoryPowerEquipment,
                PidEquipment.CategoryStorageEquipment,
                PidEquipment.CategoryProcessEquipment,
                PidEquipment.CategoryUtilityEquipment,
                PidEquipment.CategoryPipings,
                "__unmatched__"
            };

            var sheetNames = new Dictionary<string, string>
            {
                ["instrument_dcs"] = "DCS 태그",
                ["instrument_field"] = "현장 계기",
                [PidEquipment.CategoryPowerEquipment] = "Power Equipment",
                [PidEquipment.CategoryStorageEquipment] = "Storage Equipment",
                [PidEquipment.CategoryProcessEquipment] = "Process Equipment",
                [PidEquipment.CategoryUtilityEquipment] = "Utility Equipment",
                [PidEquipment.CategoryPipings] = "Pipings",
                ["__unmatched__"] = "Unmatched"
            };

            foreach (var cat in sheetOrder)
            {
                if (!grouped.TryGetValue(cat, out var groupItems) || groupItems.Count == 0)
                    continue;

                var sheetName = sheetNames[cat];
                var worksheet = package.Workbook.Worksheets.Add(sheetName);

                worksheet.Cells[1, 1].Value = "태그번호";
                worksheet.Cells[1, 2].Value = "장비명";
                worksheet.Cells[1, 3].Value = "장비타입";
                worksheet.Cells[1, 4].Value = "라인번호";
                worksheet.Cells[1, 5].Value = "도면번호";
                worksheet.Cells[1, 6].Value = "신뢰도";
                worksheet.Cells[1, 7].Value = "상태";
                worksheet.Cells[1, 8].Value = "추출일시";
                worksheet.Cells[1, 9].Value = "Experion 태그";
                worksheet.Cells[1, 10].Value = "카테고리";
                worksheet.Cells[1, 11].Value = "Role";
                worksheet.Cells[1, 12].Value = "From";
                worksheet.Cells[1, 13].Value = "To";
                worksheet.Cells[1, 14].Value = "From_at";
                worksheet.Cells[1, 15].Value = "To_at";
                worksheet.Cells[1, 16].Value = "태그분류";
                worksheet.Cells[1, 17].Value = "id";          // 안정 키(라운드트립 매칭용) — col17 고정
                worksheet.Cells[1, 18].Value = "DCS태그";     // tag_dcs: DCS 함수블록 여부

                using var headerRange = worksheet.Cells[1, 1, 1, 18];
                headerRange.Style.Font.Bold = true;
                headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid;
                headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);

                int row = 2;
                foreach (var item in groupItems)
                {
                    worksheet.Cells[row, 1].Value = item.TagNo;
                    worksheet.Cells[row, 2].Value =
                        ResolveEquipmentName(item.InstrumentType, rules)
                        ?? item.EquipmentName ?? "";
                    worksheet.Cells[row, 3].Value = item.InstrumentType ?? "";
                    worksheet.Cells[row, 4].Value = item.LineNumber ?? "";
                    worksheet.Cells[row, 5].Value = item.PidDrawingNo ?? "";
                    worksheet.Cells[row, 6].Value = item.Confidence;
                    worksheet.Cells[row, 7].Value = item.IsActive ? "활성" : "비활성";
                    worksheet.Cells[row, 8].Value = item.ExtractedAt;
                    worksheet.Cells[row, 9].Value = item.ExperionTag?.TagName ?? "";
                    worksheet.Cells[row, 10].Value = item.Category ?? "";
                    worksheet.Cells[row, 11].Value = item.Role ?? "";
                    worksheet.Cells[row, 12].Value = item.FromTag ?? "";
                    worksheet.Cells[row, 13].Value = item.ToTag ?? "";
                    worksheet.Cells[row, 14].Value = item.FromAt ?? "";
                    worksheet.Cells[row, 15].Value = item.ToAt ?? "";
                    worksheet.Cells[row, 16].Value = item.TagClass switch
                    {
                        PidEquipment.TagClassSystem => "시스템(DCS)",
                        PidEquipment.TagClassField  => "현장",
                        _ => ""
                    };
                    worksheet.Cells[row, 17].Value = item.Id;   // 안정 키(라운드트립 매칭용)
                    worksheet.Cells[row, 18].Value = item.TagDcs ? "DCS" : "현장";
                    row++;
                }

                worksheet.Cells.AutoFitColumns();
            }

            return package.GetAsByteArray();
        });
    }

    /// <summary>
    /// 편집 엑셀(ExportToExcelAsync 포맷, 17컬럼) → pid_equipment UPSERT.
    /// 매칭 키 = id(col17, 안정 키). id 있으면 그 행만 in-place UPDATE(다중경로 보존),
    /// id 비어있으면 신규 INSERT. col17 헤더가 "id"가 아닌 옛 파일은 태그번호(col1) 매칭으로 폴백.
    /// 빈 셀 → null 로 기록(엑셀에서 값을 비우면 DB에서도 삭제 = 라운드트립 교정 가능).
    /// 갱신 컬럼: 장비명·장비타입·라인번호·도면번호·신뢰도·상태·카테고리·Role·From·To·From_at·To_at·태그분류.
    /// 읽기전용(미반영): 추출일시(col8), Experion 태그(col9).
    /// 갱신 행은 ConnectionLocked=true 로 표시되어, 이후 AnalyzeConnectionsAsync
    /// 재실행 시 From/To 초기화·재계산에서 제외(사람 교정 보호).
    /// </summary>
    public async Task<PidImportResult> ImportFromExcelAsync(Stream stream)
    {
        // EPPlus 는 비동기 시킹 스트림이 필요해 메모리로 복사
        using var ms = new MemoryStream();
        await stream.CopyToAsync(ms);
        ms.Position = 0;

        using var package = new OfficeOpenXml.ExcelPackage(ms);

        int sheets = 0, rowsRead = 0, rowsUpdated = 0, rowsInserted = 0;
        var unmatched = new List<string>();

        // 인덱스: id(안정 키, in-place 갱신) + TagNo(옛 파일 폴백)
        var all = await _dbContext.PidEquipment.ToListAsync();
        var byId = all.ToDictionary(e => e.Id);
        var byTag = all
            .Where(e => !string.IsNullOrWhiteSpace(e.TagNo))
            .GroupBy(e => e.TagNo.Trim(), StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);

        static string? Norm(OfficeOpenXml.ExcelWorksheet ws, int r, int c)
        {
            var t = ws.Cells[r, c].Text?.Trim();
            return string.IsNullOrEmpty(t) ? null : t;
        }

        foreach (var ws in package.Workbook.Worksheets)
        {
            // 데이터 시트만 처리 (헤더 col1 == "태그번호")
            if (ws.Dimension == null) continue;
            if (!string.Equals(ws.Cells[1, 1].Text?.Trim(), "태그번호",
                                StringComparison.Ordinal))
                continue;
            // col17 헤더가 "id" 면 안정 키 매칭, 아니면 옛 포맷(태그번호 매칭)으로 폴백
            bool hasIdCol  = string.Equals(ws.Cells[1, 17].Text?.Trim(), "id",
                                           StringComparison.Ordinal);
            // col18 헤더가 "DCS태그" 면 tag_dcs 읽기
            bool hasDcsCol = string.Equals(ws.Cells[1, 18].Text?.Trim(), "DCS태그",
                                           StringComparison.Ordinal);
            sheets++;

            for (int r = 2; r <= ws.Dimension.End.Row; r++)
            {
                var tagNo = Norm(ws, r, 1);
                if (tagNo == null) continue;
                rowsRead++;

                var equipName = Norm(ws, r, 2);
                var instType  = Norm(ws, r, 3);
                var lineNo    = Norm(ws, r, 4);
                var drawingNo = Norm(ws, r, 5);
                var confTxt   = Norm(ws, r, 6);
                var stateTxt  = Norm(ws, r, 7);
                var category  = Norm(ws, r, 10);
                var role      = Norm(ws, r, 11);
                var fromTag   = Norm(ws, r, 12);
                var toTag     = Norm(ws, r, 13);
                var fromAt    = Norm(ws, r, 14);
                var toAt      = Norm(ws, r, 15);
                var clsTxt    = Norm(ws, r, 16);

                double? conf = double.TryParse(confTxt, out var cv) ? cv : null;
                bool? active = stateTxt switch
                {
                    "활성" => true,
                    "비활성" => false,
                    _ => (bool?)null
                };
                var tagClass = clsTxt switch
                {
                    "시스템(DCS)" => PidEquipment.TagClassSystem,
                    "현장"        => PidEquipment.TagClassField,
                    _ => null
                };

                void Apply(PidEquipment e)
                {
                    e.EquipmentName  = equipName;
                    e.InstrumentType = instType;
                    e.LineNumber     = lineNo;
                    e.PidDrawingNo   = drawingNo;
                    if (conf.HasValue)   e.Confidence = conf.Value;
                    if (active.HasValue) e.IsActive   = active.Value;
                    e.Category  = category;
                    e.Role      = role;
                    e.FromTag   = fromTag;
                    e.ToTag     = toTag;
                    e.FromAt    = fromAt;
                    e.ToAt      = toAt;
                    e.TagClass  = tagClass;
                    // col18: DCS태그 (DCS=true, 현장=false). 헤더 없는 옛 파일은 기존 값 유지.
                    if (hasDcsCol)
                    {
                        var dcsVal = Norm(ws, r, 18);
                        e.TagDcs = dcsVal == "DCS";
                    }
                    // From/To 를 채운 행만 잠금(사람이 연결을 교정한 행).
                    // 둘 다 비우면 잠금 해제 → 연결분석이 다시 도출 가능.
                    e.ConnectionLocked = fromTag != null || toTag != null;
                    e.UpdatedAt = DateTime.UtcNow;
                }

                if (hasIdCol)
                {
                    // id 있으면 그 행만 in-place UPDATE(다중경로 보존), 비어있으면 신규 INSERT
                    var idTxt = Norm(ws, r, 17);
                    if (idTxt != null && long.TryParse(idTxt, out var rid) &&
                        byId.TryGetValue(rid, out var hit))
                    {
                        Apply(hit);
                        rowsUpdated++;
                    }
                    else
                    {
                        var ne = new PidEquipment { TagNo = tagNo };
                        Apply(ne);
                        _dbContext.PidEquipment.Add(ne);
                        rowsInserted++;
                    }
                }
                else
                {
                    // 옛 포맷(id 컬럼 없음): TagNo 매칭 — 같은 TagNo 다중 행이면 전부 갱신
                    if (!byTag.TryGetValue(tagNo, out var recs))
                    {
                        if (unmatched.Count < 200) unmatched.Add(tagNo);
                        continue;
                    }
                    foreach (var e in recs) { Apply(e); rowsUpdated++; }
                }
            }
        }

        await _dbContext.SaveChangesAsync();
        _logger.LogInformation(
            "[PID Import] 시트 {Sheets} · 읽음 {Read} · 갱신 {Upd} · 신규 {Ins} · 미매칭 {Un}",
            sheets, rowsRead, rowsUpdated, rowsInserted, unmatched.Count);

        return new PidImportResult
        {
            SheetsProcessed = sheets,
            RowsRead = rowsRead,
            RowsUpdated = rowsUpdated,
            RowsInserted = rowsInserted,
            Unmatched = unmatched.Count,
            UnmatchedTags = unmatched
        };
    }

    /// <summary>
    /// 장비타입(InstrumentType: PSV, TR, C 등) → prefix 규칙 설명(Description).
    /// 1) prefix 정확 매칭 우선 (예: 타입 "DP" → 규칙 "DP"=Differential Pressure,
    ///    규칙 "DP-"=Diaphragm Pump 충돌을 정확매칭으로 해소)
    /// 2) 없으면 후행 '-' 제거 매칭 (예: 타입 "C" → 규칙 "C-"=Column)
    /// 매칭 실패 시 null → 호출부에서 기존 EquipmentName 유지.
    /// </summary>
    private static string? ResolveEquipmentName(string? instrumentType, List<PidPrefixRule> rules)
    {
        if (string.IsNullOrWhiteSpace(instrumentType)) return null;
        var it = instrumentType.Trim();

        var exact = rules
            .Where(r => r.Prefix.Trim().Equals(it, StringComparison.OrdinalIgnoreCase))
            .OrderBy(r => r.SortOrder).FirstOrDefault();
        if (exact != null) return exact.Description ?? exact.Prefix;

        var stripped = rules
            .Where(r => r.Prefix.Trim().TrimEnd('-').Equals(it, StringComparison.OrdinalIgnoreCase))
            .OrderBy(r => r.SortOrder).FirstOrDefault();
        return stripped == null ? null : (stripped.Description ?? stripped.Prefix);
    }

    // ── Prefix Rule Cache ──────────────────────────────────────────────────────

    private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
    {
        var rules = _cachedRules;
        if (rules != null) return rules;

        await _cacheLock.WaitAsync();
        try
        {
            rules = _cachedRules;
            if (rules != null) return rules;
            rules = await _dbContext.PidPrefixRules
                .OrderByDescending(r => r.Prefix.Length)
                .ThenBy(r => r.SortOrder)
                .ToListAsync();
            _cachedRules = rules;
            return rules;
        }
        finally
        {
            _cacheLock.Release();
        }
    }

    private void InvalidateRulesCache()
    {
        Interlocked.Exchange(ref _cachedRules, null);
    }

    // 배관번호 패턴: SERVICE-LINENUM-SIZE(숫자+알파벳)-... 3번째 필드에 파이프 사이즈 존재
    private static readonly Regex _pipeLineNoRe = new(
        @"^[A-Z][A-Z0-9]{0,3}-\d{3,6}-\d{1,4}[A-Za-z]-",
        RegexOptions.Compiled);

    private async Task<string?> MatchCategoryAsync(string tagNo)
    {
        if (_pipeLineNoRe.IsMatch(tagNo))
            return PidEquipment.CategoryPipings;

        var rules = await GetRulesCachedAsync();
        return rules.FirstOrDefault(r =>
            tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category;
    }

    /// <summary>
    /// prefix rule에서 tag_dcs 값을 조회. StartsWith 매칭으로 compound형(FICQ/FICA 등) 자동 커버.
    /// 가장 긴 prefix 우선(FIC보다 FICQ가 더 구체적이면 FICQ rule 우선).
    /// </summary>
    private async Task<bool> ResolveTagDcsAsync(string tagNo)
    {
        var rules = await GetRulesCachedAsync();
        var upper = tagNo.ToUpperInvariant();
        var rule = rules
            .Where(r => upper.StartsWith(r.Prefix.ToUpperInvariant()))
            .OrderByDescending(r => r.Prefix.Length)   // 가장 긴 prefix 우선
            .FirstOrDefault();
        return rule?.TagDcs ?? false;
    }

    /// <summary>
    /// 계기(instrument) 하위 분류. tag_dcs(prefix rule)가 단일 기준.
    ///   tag_dcs=TRUE  → system (DCS 함수블록: FIC/TIC/PIC류)
    ///   tag_dcs=FALSE → field  (현장 물리 계기: FT/FCV류)
    /// instrument 이외 카테고리는 null.
    /// </summary>
    private static string? ClassifyTagClass(string? category, bool tagDcs)
    {
        if (category != PidEquipment.CategoryInstrument) return null;
        return tagDcs ? PidEquipment.TagClassSystem : PidEquipment.TagClassField;
    }

    // ── Prefix Rule CRUD ───────────────────────────────────────────────────────

    public async Task<List<PidPrefixRule>> GetPrefixRulesAsync()
    {
        return await _dbContext.PidPrefixRules
            .OrderBy(r => r.SortOrder)
            .ThenBy(r => r.Prefix)
            .ToListAsync();
    }

    public async Task<PidPrefixRule> CreatePrefixRuleAsync(CreatePidPrefixRuleRequest request)
    {
        var rule = new PidPrefixRule
        {
            Prefix = request.Prefix.Trim(),
            Category = request.Category,
            TagDcs = request.TagDcs,
            Description = request.Description?.Trim(),
            SortOrder = request.SortOrder,
            CreatedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow
        };
        _dbContext.PidPrefixRules.Add(rule);
        await _dbContext.SaveChangesAsync();
        InvalidateRulesCache();
        return rule;
    }

    public async Task<PidPrefixRule?> UpdatePrefixRuleAsync(int id, UpdatePidPrefixRuleRequest request)
    {
        var rule = await _dbContext.PidPrefixRules.FindAsync(id);
        if (rule == null) return null;
        rule.Prefix = request.Prefix.Trim();
        rule.Category = request.Category;
        rule.TagDcs = request.TagDcs;
        rule.Description = request.Description?.Trim();
        rule.SortOrder = request.SortOrder;
        rule.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
        InvalidateRulesCache();
        return rule;
    }

    public async Task<bool> DeletePrefixRuleAsync(int id)
    {
        var rule = await _dbContext.PidPrefixRules.FindAsync(id);
        if (rule == null) return false;
        _dbContext.PidPrefixRules.Remove(rule);
        await _dbContext.SaveChangesAsync();
        InvalidateRulesCache();
        return true;
    }

    public async Task<int> ApplyCategoriesToExistingAsync()
    {
        const int batchSize = 1000;
        int total = 0;
        long lastId = 0;
        while (true)
        {
            var batch = await _dbContext.PidEquipment
                .Where(e => e.Id > lastId)
                .OrderBy(e => e.Id)
                .Take(batchSize)
                .ToListAsync();
            if (!batch.Any()) break;

            foreach (var item in batch)
            {
                var category = await MatchCategoryAsync(item.TagNo);
                var tagDcs = await ResolveTagDcsAsync(item.TagNo);
                var tagClass = ClassifyTagClass(category, tagDcs);
                if (item.Category != category || item.TagDcs != tagDcs || item.TagClass != tagClass)
                {
                    item.Category = category;
                    item.TagDcs   = tagDcs;
                    item.TagClass = tagClass;
                    item.UpdatedAt = DateTime.UtcNow;
                    total++;
                }
                lastId = item.Id;
            }
            await _dbContext.SaveChangesAsync();
        }
        return total;
    }

    // ── 연결 분석 (from→to) ────────────────────────────────────────────────────

    // 범례 영역 제외
    private const double LegendXThreshold = 4500;
    private const double LegendYThreshold = 5400;
    private const double MaxConnectionDistance = 300.0;

    private static readonly Regex _loopNumExtractRe = new(@"-(\d{3,})", RegexOptions.Compiled);

    private static string? ExtractLoopNumber(string tagNo)
    {
        var m = _loopNumExtractRe.Match(tagNo);
        return m.Success ? m.Groups[1].Value : null;
    }

    // mcp-server/sim_line_connection.py 가 생성하는 연결 JSON 후보 경로
    private static readonly string[] _connJsonRoots =
        { "mcp-server/storage", "../mcp-server/storage", "../../mcp-server/storage" };

    /// <summary>
    /// P&ID 연결 분석 — 방향표지판(off-page connector) 태그매칭 + 방향성 기반.
    /// 기존 loop+최단거리 anchor 방식을 폐기하고 다음 2단계로 재작성:
    ///   1) Python geometric 파이프라인 산출물(<prefix>_connections.json)의 유향 엣지 적용
    ///      — ▶ 마커 V자방향(좌→우)으로 from_tag/to_tag 방향성까지 부여
    ///   2) JSON 없거나 미커버 레코드: pipings line_number 체인(PosX 오름차순=하류)으로 보강
    /// geometric V검출은 ezdxf 보유한 Python에 잔류, C#은 적용·영속만 담당(기존 아키텍처와 일치).
    /// </summary>
    public async Task<int> AnalyzeConnectionsAsync(string drawingFile)
    {
        var items = await _dbContext.PidEquipment
            .Where(e => e.DrawingFile == drawingFile && e.IsActive)
            .ToListAsync();

        if (items.Count == 0) return 0;

        // 재실행 결정성: 기존 연결 초기화 후 재계산.
        // 단, 엑셀 import 로 사람이 교정한 행(ConnectionLocked)은 보존.
        foreach (var it in items.Where(i => !i.ConnectionLocked))
        { it.FromTag = null; it.ToTag = null; }

        // TagNo → 레코드(동일 태그 다중 위치 가능) 인덱스
        var byTag = items
            .Where(i => !string.IsNullOrWhiteSpace(i.TagNo))
            .GroupBy(i => i.TagNo.Trim(), StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);

        int edgeApplied = 0;
        var touched = new HashSet<long>();

        // ── 1. Python 유향 엣지 JSON 적용 ──────────────────────────────
        var edges = LoadDirectedEdges(drawingFile);
        foreach (var (fromTag, toTag) in edges)
        {
            var f = NormalizeConnTag(fromTag);
            var t = NormalizeConnTag(toTag);
            if (f.Length == 0 || t.Length == 0 ||
                string.Equals(f, t, StringComparison.OrdinalIgnoreCase))
                continue;

            // from 레코드(들)의 ToTag = t, to 레코드(들)의 FromTag = f
            if (byTag.TryGetValue(f, out var fromRecs))
                foreach (var r in fromRecs)
                    if (!r.ConnectionLocked && r.ToTag == null)
                    { r.ToTag = t; touched.Add(r.Id); edgeApplied++; }
            if (byTag.TryGetValue(t, out var toRecs))
                foreach (var r in toRecs)
                    if (!r.ConnectionLocked && r.FromTag == null)
                    { r.FromTag = f; touched.Add(r.Id); }
        }

        // ── 2. pipings line_number 체인 보강 (PosX 오름차순 = 하류 방향) ──
        int chainApplied = 0;
        var lineGroups = items
            .Where(i => !string.IsNullOrWhiteSpace(i.LineNumber)
                        && i.PosX.HasValue
                        && string.Equals(i.Category, PidEquipment.CategoryPipings,
                                          StringComparison.OrdinalIgnoreCase))
            .GroupBy(i => i.LineNumber!.Trim(), StringComparer.OrdinalIgnoreCase)
            .Where(g => g.Count() >= 2);

        foreach (var g in lineGroups)
        {
            var ordered = g.OrderBy(i => i.PosX!.Value).ToList();
            for (int k = 0; k < ordered.Count - 1; k++)
            {
                var a = ordered[k];
                var b = ordered[k + 1];
                if (string.Equals(a.TagNo, b.TagNo, StringComparison.OrdinalIgnoreCase))
                    continue;
                if (!a.ConnectionLocked && a.ToTag == null)
                { a.ToTag = b.TagNo; chainApplied++; }
                if (!b.ConnectionLocked && b.FromTag == null)
                { b.FromTag = a.TagNo; }
            }
        }

        await _dbContext.SaveChangesAsync();
        int connectionCount = touched.Count + chainApplied;
        _logger.LogInformation(
            "[PID 분석] 연결 완료: 총 {Count}개 (JSON엣지 {Edge}건/{Touched}레코드, line_number체인 {Chain}건, drawing={File})",
            connectionCount, edgeApplied, touched.Count, chainApplied, drawingFile);
        return connectionCount;
    }

    /// <summary><prefix>_connections.json 의 edges[] → (from,to) 목록. 없으면 빈 목록.</summary>
    private List<(string From, string To)> LoadDirectedEdges(string drawingFile)
    {
        var result = new List<(string, string)>();
        var prefix = drawingFile.Split('_', '.').FirstOrDefault() ?? drawingFile;
        var fileName = $"{prefix}_connections.json";

        string? path = _connJsonRoots
            .Select(r => Path.Combine(r, fileName))
            .FirstOrDefault(File.Exists);
        if (path == null)
        {
            _logger.LogInformation("[PID 분석] 연결 JSON 없음 (prefix={Prefix}) — line_number 체인만 사용", prefix);
            return result;
        }

        try
        {
            using var doc = JsonDocument.Parse(File.ReadAllText(path));
            if (doc.RootElement.TryGetProperty("edges", out var edgesEl)
                && edgesEl.ValueKind == JsonValueKind.Array)
            {
                foreach (var e in edgesEl.EnumerateArray())
                {
                    var from = e.TryGetProperty("from", out var fe) ? fe.GetString() : null;
                    var to = e.TryGetProperty("to", out var te) ? te.GetString() : null;
                    if (!string.IsNullOrWhiteSpace(from) && !string.IsNullOrWhiteSpace(to))
                        result.Add((from!, to!));
                }
            }
            _logger.LogInformation("[PID 분석] 연결 JSON 로드: {Path} ({Count} 엣지)", path, result.Count);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[PID 분석] 연결 JSON 파싱 실패: {Path}", path);
        }
        return result;
    }

    // 유효 P&ID 태그 패턴 (예: C-10111, FCV-10101). VLINE 플레이스홀더·한글
    // 시설명·짧은 TEXT 조각(마커 태그 오할당 노이즈)을 from/to 에서 배제.
    private static readonly Regex _validTagRe =
        new(@"^[A-Za-z]{1,6}-\d{2,6}[A-Za-z]?$", RegexOptions.Compiled);

    /// <summary>"P-10101@2050,5289" → "P-10101". 좌표 접미사/공백 제거 후 유효성 검사. 무효면 빈 문자열.</summary>
    private static string NormalizeConnTag(string tag)
    {
        if (string.IsNullOrWhiteSpace(tag)) return string.Empty;
        var at = tag.IndexOf('@');
        var bare = (at >= 0 ? tag[..at] : tag).Trim();
        return _validTagRe.IsMatch(bare) ? bare : string.Empty;
    }
}

// ── 내부 파싱용 모델 ──────────────────────────────────────────────────────────
public class ExtractedItem
{
    public string TagNo { get; set; } = "";
    public string? EquipmentName { get; set; }
    public string? InstrumentType { get; set; }
    public string? LineNumber { get; set; }
    public string? PidDrawingNo { get; set; }
    public double Confidence { get; set; } = 0.5;
}

public class MappingItem
{
    public string PidTag { get; set; } = "";
    public string? ExperionTag { get; set; }
    public double Confidence { get; set; }
}
