using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace ExperionCrawler.Web.Controllers;

// ── 인증서 ────────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/certificate")]
public class ExperionCertificateController : ControllerBase
{
    private readonly IExperionCertificateService _certSvc;

    public ExperionCertificateController(IExperionCertificateService certSvc)
        => _certSvc = certSvc;

    /// <summary>현재 인증서 상태 조회</summary>
    [HttpGet("status")]
    public IActionResult GetStatus([FromQuery] string clientHostName = "dbsvr")
    {
        var info = _certSvc.GetCertificateInfo(clientHostName);
        return Ok(new
        {
            exists      = info.Exists,
            subjectName = info.SubjectName,
            notAfter    = info.NotAfter,
            thumbPrint  = info.ThumbPrint,
            filePath    = info.FilePath
        });
    }

    /// <summary>인증서 생성 (없으면 생성, 있으면 기존 반환)</summary>
    [HttpPost("create")]
    public async Task<IActionResult> Create([FromBody] ExperionCertCreateDto dto)
    {
        var applicationUri = $"urn:{dto.ClientHostName}:ExperionCrawlerClient";
        var result = await _certSvc.EnsureCertificateAsync(
            applicationUri, dto.ClientHostName, dto.SubjectAltNames, dto.PfxPassword);

        return Ok(new
        {
            success    = result.Success,
            message    = result.Message,
            thumbPrint = result.ThumbPrint
        });
    }
}

// ── 서버 접속 테스트 ──────────────────────────────────────────────────────────

[ApiController]
[Route("api/connection")]
public class ExperionConnectionController : ControllerBase
{
    private readonly IExperionOpcClient _opcClient;

    public ExperionConnectionController(IExperionOpcClient opcClient)
        => _opcClient = opcClient;

    /// <summary>OPC UA 서버 접속 테스트</summary>
    [HttpPost("test")]
    public async Task<IActionResult> Test([FromBody] ExperionServerConfigDto dto)
    {
        var cfg = MapConfig(dto);
        var r   = await _opcClient.TestConnectionAsync(cfg);
        return Ok(new { success = r.Success, message = r.Message, sessionId = r.SessionId, policyUri = r.PolicyUri });
    }

    /// <summary>단일 노드 태그 읽기</summary>
    [HttpPost("read")]
    public async Task<IActionResult> ReadTag([FromBody] ExperionReadTagRequestDto dto)
    {
        var cfg = MapConfig(dto.ServerConfig);
        var r   = await _opcClient.ReadTagAsync(cfg, dto.NodeId);
        return Ok(new
        {
            success    = r.Success,
            nodeId     = r.NodeId,
            value      = r.Value?.ToString(),
            statusCode = r.StatusCode,
            timestamp  = r.Timestamp,
            error      = r.ErrorMessage
        });
    }

    /// <summary>노드 탐색 (ObjectsFolder 기준)</summary>
    [HttpPost("browse")]
    public async Task<IActionResult> Browse([FromBody] ExperionBrowseRequestDto dto)
    {
        var cfg = MapConfig(dto.ServerConfig);
        var r = await _opcClient.BrowseNodesAsync(cfg, dto.StartNodeId);
        return Ok(new {
            success = r.Success,
            error   = r.ErrorMessage,
            nodes   = r.Nodes.Select(n => new {
                nodeId      = n.NodeId,
                displayName = n.DisplayName,
                nodeClass   = n.NodeClass,
                hasChildren = n.HasChildren
            })
        });
    }

    private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
    {
        ServerHostName = dto.ServerHostName,
        Port           = dto.Port,
        ClientHostName = dto.ClientHostName,
        UserName       = dto.UserName,
        Password       = dto.Password
    };
}

// ── 크롤링 ────────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/crawl")]
public class ExperionCrawlController : ControllerBase
{
    private readonly ExperionCrawlService _crawlSvc;

    public ExperionCrawlController(ExperionCrawlService crawlSvc)
        => _crawlSvc = crawlSvc;

    /// <summary>전체 노드맵 크롤 (재귀 탐색 → CSV 저장)</summary>
    [HttpPost("nodemap")]
    public async Task<IActionResult> NodeMap(
        [FromBody] ExperionNodeMapCrawlRequestDto dto,
        CancellationToken ct)
    {
        var cfg = new ExperionServerConfig
        {
            ServerHostName = dto.ServerConfig.ServerHostName,
            Port           = dto.ServerConfig.Port,
            ClientHostName = dto.ServerConfig.ClientHostName,
            UserName       = dto.ServerConfig.UserName,
            Password       = dto.ServerConfig.Password
        };

        var result = await _crawlSvc.RunNodeMapCrawlAsync(cfg, dto.MaxDepth, ct);

        return Ok(new
        {
            success    = result.Success,
            totalCount = result.TotalCount,
            csvPath    = result.CsvPath,
            error      = result.ErrorMessage
        });
    }

    /// <summary>크롤링 시작 (동기식 – 완료 후 결과 반환)</summary>
    [HttpPost("start")]
    public async Task<IActionResult> Start(
        [FromBody] ExperionCrawlRequestDto dto,
        CancellationToken ct)
    {
        var cfg = new ExperionServerConfig
        {
            ServerHostName = dto.ServerConfig.ServerHostName,
            Port           = dto.ServerConfig.Port,
            ClientHostName = dto.ServerConfig.ClientHostName,
            UserName       = dto.ServerConfig.UserName,
            Password       = dto.ServerConfig.Password
        };

        var result = await _crawlSvc.RunCrawlAsync(
            cfg, dto.NodeIds, dto.IntervalSeconds, dto.DurationSeconds, ct);

        return Ok(new
        {
            success      = true,
            sessionId    = result.SessionId,
            totalRecords = result.TotalRecords,
            csvPath      = result.CsvPath,
            preview      = result.Records.Take(5).Select(r => new
            {
                r.NodeId, r.Value, r.StatusCode,
                collectedAt = r.CollectedAt
            })
        });
    }
}

// ── DB 저장 ───────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/database")]
public class ExperionDatabaseController : ControllerBase
{
    private readonly ExperionCrawlService _crawlSvc;
    private readonly IExperionDbService   _dbSvc;
    private readonly IExperionCsvService  _csvSvc;
    private readonly AssetLoader          _assetLoader;

    public ExperionDatabaseController(
        ExperionCrawlService crawlSvc,
        IExperionDbService   dbSvc,
        IExperionCsvService  csvSvc,
        AssetLoader          assetLoader)
    {
        _crawlSvc    = crawlSvc;
        _dbSvc       = dbSvc;
        _csvSvc      = csvSvc;
        _assetLoader = assetLoader;
    }

    /// <summary>수집된 CSV 파일 목록</summary>
    [HttpGet("files")]
    public IActionResult GetCsvFiles()
        => Ok(new { files = _csvSvc.GetAvailableFiles() });

    /// <summary>CSV → DB 임포트 (ServerHostName 지정 시 → raw_node_map, 그 외 → ExperionRecords)</summary>
    [HttpPost("import")]
    public async Task<IActionResult> Import([FromBody] ExperionCsvImportDto dto)
    {
        // 경계 문자 및 경로 조작 방지: 파일명에 점, 역슬래시, 슬래시, 공백 제거
        var safeFileName = dto.FileName.Trim();
        var invalidChars = new char[] { '.', '\\', '/', ' ', '\t', '\n', '\r' };
        if (string.IsNullOrEmpty(safeFileName) || invalidChars.Any(c => safeFileName.Contains(c)))
        {
            return BadRequest(new { error = "파일명에 허용되지 않는 문자가 포함되었습니다." });
        }
        
        if (!string.IsNullOrEmpty(dto.ServerHostName) &&
            safeFileName.StartsWith(dto.ServerHostName, StringComparison.OrdinalIgnoreCase))
        {
            try
            {
                var fullPath    = Path.Combine("data/csv", dto.FileName);
                var rawCount    = await _assetLoader.ImportFullMapAsync(fullPath, dto.Truncate);
                var masterCount = await _dbSvc.BuildMasterFromRawAsync(dto.Truncate);
                return Ok(new
                {
                    success     = true,
                    count       = rawCount,
                    masterCount,
                    message     = $"{rawCount}개 노드 → raw_node_map 적재, {masterCount}개 → node_map_master 빌드 완료"
                });
            }
            catch (Exception ex)
            {
                return Ok(new { success = false, count = 0, masterCount = 0, message = $"오류: {ex.Message}" });
            }
        }

        var result = await _crawlSvc.ImportCsvToDbAsync(dto.FileName, dto.Truncate);
        return Ok(new { success = result.Success, count = result.Count, message = result.Message });
    }

    /// <summary>DB 레코드 조회</summary>
    [HttpGet("records")]
    public async Task<IActionResult> GetRecords(
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to,
        [FromQuery] int limit = 100)
    {
        var records = await _dbSvc.GetRecordsAsync(from, to, limit);
        var total   = await _dbSvc.GetTotalCountAsync();
        return Ok(new { total, records });
    }
}

// ── 포인트빌더 ────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/pointbuilder")]
public class ExperionPointBuilderController : ControllerBase
{
    private readonly IExperionDbService      _dbSvc;
    private readonly IExperionRealtimeService _realtimeSvc;
    private readonly IMetadataLoaderService _metaSvc;
    private readonly IConfiguration _config;
    private readonly ILogger<ExperionPointBuilderController> _logger;
    public ExperionPointBuilderController(
        IExperionDbService dbSvc,
        IExperionRealtimeService realtimeSvc,
        IMetadataLoaderService metaSvc,
        IConfiguration config,
        ILogger<ExperionPointBuilderController> logger)
    {
        _dbSvc       = dbSvc;
        _realtimeSvc = realtimeSvc;
        _metaSvc = metaSvc;
        _config = config;
        _logger = logger;
    }

    private ExperionServerConfig GetServerConfig()
    {
        var section = _config.GetSection("Experion:Default");
        return new ExperionServerConfig
        {
            ServerHostName = section["ServerHostName"] ?? "",
            Port = section.GetValue<int?>("Port") ?? 4840,
            ClientHostName = section["ClientHostName"] ?? "dbsvr",
            UserName = section["UserName"] ?? "",
            Password = section["Password"] ?? ""
        };
    }

    /// <summary>node_map_master → realtime_table 빌드 (기존 데이터 전체 교체) + 메타데이터 자동 로드</summary>
    [HttpPost("build")]
    public async Task<IActionResult> Build([FromBody] PointBuilderBuildDto dto)
    {
        var count = await _dbSvc.BuildRealtimeTableAsync(new[]
        {
            dto.Controller1, dto.AnalogMonitor1, dto.Digital1, dto.Digital2, dto.Custom
        });

        var metaCount = 0;
        try
        {
            var cfg = GetServerConfig();
            metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 빌드는 완료됨)");
        }

        return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 생성 완료 (메타데이터: {metaCount}개)" });
    }

    /// <summary>조건에 맞는 포인트 미리보기 (READ-ONLY, DB 변경 없음)</summary>
    [HttpPost("preview")]
    public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
    {
        var groups = new[]
        {
            ("controller1", dto.Controller1),
            ("analogmon1", dto.AnalogMonitor1),
            ("digital1", dto.Digital1),
            ("digital2", dto.Digital2),
            ("custom", dto.Custom)
        };

        var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);

        return Ok(new
        {
            count = result.Count,
            items = result.Items.Select(i => new
            {
                nodeId = i.NodeId,
                tagName = i.TagName,
                name = i.Name,
                dataType = i.DataType,
                group = i.Group
            })
        });
    }

    /// <summary>선택된 포인트만 realtime_table에 적용</summary>
    [HttpPost("apply")]
    public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
    {
        if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
            return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });

        var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);

        var metaCount = 0;
        try
        {
            var cfg = GetServerConfig();
            metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 적용은 완료됨)");
        }

        return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 적용 완료 (메타데이터: {metaCount}개)" });
    }

    /// <summary>선택된 포인트만 realtime_table에 추가 (기존 데이터 유지, 중복 제외)</summary>
    [HttpPost("append")]
    public async Task<IActionResult> Append([FromBody] PointBuilderApplyDto dto)
    {
        if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
            return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });

        var count = await _dbSvc.AppendPointsAsync(dto.SelectedNodeIds);

        var metaCount = 0;
        try
        {
            var cfg = GetServerConfig();
            metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 추가는 완료됨)");
        }

        return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 추가 완료 (메타데이터: {metaCount}개)" });
    }

    /// <summary>realtime_table 전체 조회</summary>
    [HttpGet("points")]
    public async Task<IActionResult> GetPoints()
    {
        var points = await _dbSvc.GetRealtimePointsAsync();
        return Ok(new
        {
            total  = points.Count(),
            items  = points.Select(p => new
            {
                id        = p.Id,
                tagName   = p.TagName,
                nodeId    = p.NodeId,
                liveValue = p.LiveValue,
                timestamp = p.Timestamp
            })
        });
    }

    /// <summary>node_id 를 직접 입력해서 수동 추가</summary>
    [HttpPost("add")]
    public async Task<IActionResult> Add([FromBody] PointBuilderAddDto dto)
    {
        if (string.IsNullOrWhiteSpace(dto.NodeId))
            return BadRequest(new { success = false, message = "node_id 는 필수입니다." });

        var nodeId = dto.NodeId.Trim();

        // DB에 먼저 추가
        var point = await _dbSvc.AddRealtimePointAsync(nodeId);

        // 구독 중이면 OPC UA 서버에 핫 추가 및 node_id 유효성 검증
        var (ok, msg) = await _realtimeSvc.AddMonitoredItemAsync(nodeId);
        if (!ok)
        {
            // OPC UA 서버가 거부 → DB 롤백
            await _dbSvc.DeleteRealtimePointAsync(point.Id);
            return BadRequest(new { success = false, message = msg });
        }

        return Ok(new { success = true, message = msg, point = new { id = point.Id, tagName = point.TagName, nodeId = point.NodeId } });
    }

    /// <summary>포인트 삭제. purgeHistory=true 면 해당 tagname 이력(history_table)까지 영구 삭제(복구 불가).
    /// 기본은 realtime 행만 삭제하고, 그 base_tag 의 잔여 행이 0이면 고아 메타데이터(sub_area 포함)도 정리.</summary>
    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id, [FromQuery] bool purgeHistory = false)
    {
        var r = await _dbSvc.DeleteRealtimePointAsync(id, purgeHistory);
        return Ok(new
        {
            success            = r.Deleted,
            message            = r.Deleted ? "삭제 완료" : "포인트를 찾을 수 없습니다.",
            baseTag            = r.BaseTag,
            metadataPurged     = r.MetadataPurged,
            historyRowsDeleted = r.HistoryRowsDeleted,
        });
    }
}

// ── 태그 메타데이터 관리 ──────────────────────────────────────────────────────

[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
    private readonly IMetadataLoaderService _metaSvc;
    private readonly ExperionDbContext _ctx;
    private readonly IConfiguration _config;

    public TagMetadataController(
        IMetadataLoaderService metaSvc,
        ExperionDbContext ctx,
        IConfiguration config)
    {
        _metaSvc = metaSvc;
        _ctx = ctx;
        _config = config;
    }

    /// <summary>
    /// 지정된 태그(또는 전체)의 메타데이터를 OPC UA에서 재조회하여 갱신
    /// 설정은 appsettings.json의 "Experion:Default" 섹션에서 읽음
    /// </summary>
    [HttpPost("reload")]
    public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
    {
        // appsettings.json에서 기본 설정 읽기 (비밀번호 하드코딩 금지)
        var section = _config.GetSection("Experion:Default");
        var cfg = new ExperionServerConfig
        {
            ServerHostName = req?.ServerHostName ?? section["ServerHostName"] ?? throw new InvalidOperationException("ServerHostName이 설정되지 않았습니다."),
            Port = req?.Port ?? section.GetValue<int?>("Port") ?? 4840,
            ClientHostName = req?.ClientHostName ?? section["ClientHostName"] ?? "dbsvr",
            UserName = req?.UserName ?? section["UserName"] ?? "",
            Password = req?.Password ?? section["Password"] ?? ""
        };

        try
        {
            var count = await _metaSvc.ReloadMetadataAsync(cfg, req?.BaseTags);
            return Ok(new { success = true, count, message = $"{count}개 메타데이터 갱신 완료" });
        }
        catch (Exception ex)
        {
            return StatusCode(500, new { success = false, message = ex.Message });
        }
    }

    /// <summary>
    /// tag_metadata 전체 조회
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
    {
        var query = _ctx.TagMetadata.AsQueryable();
        if (!string.IsNullOrEmpty(baseTag))
            query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());

        var items = await query.Select(t => new
        {
            baseTag = t.BaseTag,
            attribute = t.Attribute,
            value = t.Value,
            nodeId = t.NodeId,
            loadedAt = t.LoadedAt
        }).ToListAsync();

        return Ok(new { count = items.Count, items });
    }
}

public record MetadataReloadRequest(
    string? ServerHostName,
    int? Port,
    string? ClientHostName,
    string? UserName,
    string? Password,
    IEnumerable<string>? BaseTags);

// ── Sub-Area (세부 Area) ──────────────────────────────────────────────────────

[ApiController]
[Route("api/tags/sub-area")]
public class SubAreaController : ControllerBase
{
    private readonly IExperionDbService _dbSvc;
    private readonly ILogger<SubAreaController> _logger;

    public SubAreaController(IExperionDbService dbSvc, ILogger<SubAreaController> logger)
    {
        _dbSvc  = dbSvc;
        _logger = logger;
    }

    /// <summary>area("P6") 또는 sub_area("P6-1")별 태그 + sub_area 현황 조회</summary>
    [HttpGet]
    public async Task<IActionResult> GetSubAreaList(
        [FromQuery] string area,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 50)
    {
        if (string.IsNullOrWhiteSpace(area))
            return BadRequest(new { success = false, message = "area 파라미터가 필요합니다." });

        var (tags, total) = await _dbSvc.GetSubAreaListByAreaAsync(area, page, pageSize);
        return Ok(new
        {
            success = true,
            total,
            page,
            pageSize,
            tags = tags.Select(t => new
            {
                baseTag     = t.BaseTag,
                area        = t.Area,
                subArea     = t.SubArea,
                description = t.Description,
            }),
        });
    }

    /// <summary>단일 태그 sub_area 수정/삭제 (subArea=null이면 미분류로 초기화)</summary>
    [HttpPut]
    public async Task<IActionResult> UpdateSubArea([FromBody] SubAreaUpdateRequest req)
    {
        if (req is null || string.IsNullOrWhiteSpace(req.BaseTag))
            return BadRequest(new { success = false, message = "baseTag가 필요합니다." });

        var success = await _dbSvc.UpdateSubAreaAsync(req.BaseTag, req.SubArea);
        return Ok(new { success, baseTag = req.BaseTag, subArea = req.SubArea });
    }

    /// <summary>pid_equipment 공용 검출 + 번호 prefix 규칙으로 sub_area 일괄 seed (dryRun 권장)</summary>
    [HttpPost("seed")]
    public async Task<IActionResult> SeedSubArea([FromBody] SubAreaSeedRequest? req)
    {
        var dryRun = req?.DryRun ?? true;
        try
        {
            var result = await _dbSvc.SeedSubAreaAsync(dryRun);
            return Ok(new
            {
                success      = true,
                dryRun       = result.DryRun,
                assigned     = result.Assigned,
                shared       = result.Shared,
                unmatched    = result.Unmatched,
                bySubArea    = result.BySubArea,
                details      = result.Details.Select(d => new
                {
                    baseTag = d.BaseTag,
                    area    = d.Area,
                    action  = d.Action,
                    subArea = d.SubArea,
                    reason  = d.Reason,
                }),
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "sub_area seed 실패");
            return StatusCode(500, new { success = false, message = ex.Message });
        }
    }
}

public class SubAreaUpdateRequest
{
    public string  BaseTag { get; set; } = "";
    public string? SubArea { get; set; }
}

public class SubAreaSeedRequest
{
    public bool DryRun { get; set; } = true;
}

// ── 실시간 서비스 ─────────────────────────────────────────────────────────────

[ApiController]
[Route("api/realtime")]
public class ExperionRealtimeController : ControllerBase
{
    private readonly IExperionRealtimeService _rtSvc;
    public ExperionRealtimeController(IExperionRealtimeService rtSvc) => _rtSvc = rtSvc;

    [HttpPost("start")]
    public async Task<IActionResult> Start([FromBody] ExperionServerConfigDto dto)
    {
        var cfg = MapConfig(dto);
        await _rtSvc.StartAsync(cfg);
        return Ok(new { success = true, message = "실시간 구독 시작" });
    }

    [HttpPost("stop")]
    public async Task<IActionResult> Stop()
    {
        await _rtSvc.StopAsync();
        return Ok(new { success = true, message = "실시간 구독 중지" });
    }

    [HttpGet("status")]
    public IActionResult Status()
    {
        var s = _rtSvc.GetStatus();
        return Ok(new { running = s.Running, subscribedCount = s.SubscribedCount, message = s.Message });
    }

    private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
    {
        ServerHostName = dto.ServerHostName,
        Port           = dto.Port,
        ClientHostName = dto.ClientHostName,
        UserName       = dto.UserName,
        Password       = dto.Password
    };
}

// ── 이력 조회 ─────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/history")]
public class ExperionHistoryController : ControllerBase
{
    private readonly IExperionDbService _dbSvc;
    private readonly ILogger<ExperionHistoryController> _logger;
    
    public ExperionHistoryController(IExperionDbService dbSvc, ILogger<ExperionHistoryController> logger)
    {
        _dbSvc = dbSvc;
        _logger = logger;
    }

    /// <summary>realtime_table 의 tagname 목록</summary>
    [HttpGet("tagnames")]
    public async Task<IActionResult> TagNames()
    {
        try
        {
            _logger.LogDebug("[History] tagname 목록 조회 시작");
            var names = await _dbSvc.GetTagNamesAsync();
            var count = names.Count();
            _logger.LogDebug("[History] tagname 목록 조회 완료: {Count}개 태그", count);
            return Ok(new { tagNames = names });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[History] tagname 목록 조회 실패");
            return StatusCode(500, new { success = false, message = $"tagname 조회 실패: {ex.Message}" });
        }
    }

    /// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
    [HttpGet("query")]
    public async Task<IActionResult> Query(
        [FromQuery] List<string>? tagNames,
        [FromQuery] DateTime?     from,
        [FromQuery] DateTime?     to,
        [FromQuery] int           limit = 1000)
    {
        try
        {
            var tagList = tagNames ?? Enumerable.Empty<string>().ToList();
            _logger.LogDebug("[History] 이력 조회 시작 - 태그 수: {TagCount}, FROM: {From}, TO: {To}, LIMIT: {Limit}",
                tagList.Count(), from, to, limit);
            
            var result = await _dbSvc.QueryHistoryAsync(
                tagList, from, to, limit);

            _logger.LogDebug("[History] 이력 조회 완료: {RowCount}행, {TagCount}태그",
                result.Rows.Count(), result.TagNames.Count());
            
            return Ok(new
            {
                tagNames = result.TagNames,
                rows = result.Rows.Select(r => new
                {
                    recordedAt = r.RecordedAt,
                    values = r.Values
                })
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[History] 이력 조회 실패 - tagNames: {@TagNames}, from: {From}, to: {To}",
                tagNames, from, to);
            return StatusCode(500, new
            {
                success = false,
                message = "이력 조회 중 오류가 발생했습니다. 관리자에게 문의하세요.",
                detail = (string?)null
            });
        }
    }
}

// ── OPC UA 서버 ───────────────────────────────────────────────────────────────

[ApiController]
[Route("api/opcserver")]
public class ExperionOpcServerController : ControllerBase
{
    private readonly IExperionOpcServerService _svc;
    private readonly IExperionDbService        _db;

    public ExperionOpcServerController(
        IExperionOpcServerService svc, IExperionDbService db)
    {
        _svc = svc;
        _db  = db;
    }

    /// <summary>OPC UA 서버 상태 조회</summary>
    [HttpGet("status")]
    public IActionResult Status()
    {
        var s = _svc.GetStatus();
        return Ok(new
        {
            running              = s.Running,
            connectedClientCount = s.ConnectedClientCount,
            nodeCount            = s.NodeCount,
            endpointUrl          = s.EndpointUrl,
            startedAt            = s.StartedAt
        });
    }

    /// <summary>OPC UA 서버 시작 (자동 재시작 플래그 저장)</summary>
    [HttpPost("start")]
    public async Task<IActionResult> Start()
    {
        try
        {
            await _svc.StartServerAsync();
            return Ok(new { success = true, message = "OPC UA 서버 시작 완료" });
        }
        catch (Exception ex)
        {
            return StatusCode(500, new { success = false, message = ex.Message });
        }
    }

    /// <summary>OPC UA 서버 중지 (자동 재시작 플래그 삭제)</summary>
    [HttpPost("stop")]
    public async Task<IActionResult> Stop()
    {
        await _svc.StopServerAsync();
        return Ok(new { success = true, message = "OPC UA 서버 중지 완료" });
    }

    /// <summary>주소 공간 재구성 (포인트빌더 빌드 후 호출)</summary>
    [HttpPost("rebuild")]
    public async Task<IActionResult> Rebuild()
    {
        var points = (await _db.GetRealtimePointsAsync()).ToList();
        _svc.RebuildAddressSpace(points);
        return Ok(new { success = true, nodeCount = points.Count });
    }
}

// ── 노드맵 대시보드 ───────────────────────────────────────────────────────────

[ApiController]
[Route("api/nodemap")]
public class ExperionNodeMapController : ControllerBase
{
    private readonly IExperionDbService _dbSvc;
    public ExperionNodeMapController(IExperionDbService dbSvc) => _dbSvc = dbSvc;

    /// <summary>node_map_master 의 name 컬럼 고유값 목록</summary>
    [HttpGet("names")]
    public async Task<IActionResult> Names()
    {
        var names = await _dbSvc.GetNameListAsync();
        return Ok(new { names });
    }

    /// <summary>node_map_master 통계 (총 수, 클래스별, 최대 레벨, 데이터타입 목록)</summary>
    [HttpGet("stats")]
    public async Task<IActionResult> Stats()
    {
        var s = await _dbSvc.GetMasterStatsAsync();
        return Ok(new
        {
            total         = s.Total,
            objectCount   = s.ObjectCount,
            variableCount = s.VariableCount,
            maxLevel      = s.MaxLevel,
            dataTypes     = s.DataTypes
        });
    }

    /// <summary>node_map_master 필터 조회 (페이지네이션 포함, names OR 조건)</summary>
    [HttpGet("query")]
    public async Task<IActionResult> Query(
        [FromQuery] int?         minLevel,
        [FromQuery] int?         maxLevel,
        [FromQuery] string?      nodeClass,
        [FromQuery] List<string>? names,
        [FromQuery] string?      nodeId,
        [FromQuery] string?      dataType,
        [FromQuery] int          limit  = 100,
        [FromQuery] int          offset = 0)
    {
        var r = await _dbSvc.QueryMasterAsync(
            minLevel, maxLevel, nodeClass, names, nodeId, dataType, limit, offset);

        return Ok(new
        {
            total = r.Total,
            items = r.Items.Select(x => new
            {
                id       = x.Id,
                level    = x.Level,
                @class   = x.Class,
                name     = x.Name,
                nodeId   = x.NodeId,
                dataType = x.DataType
            })
        });
    }
}

// ── 하이퍼테이블 관리 ───────────────────────────────────────────────────────────

[ApiController]
[Route("api/experion/hypertable")]
public class ExperionHypertableController : ControllerBase
{
    private readonly IExperionDbService _dbSvc;

    public ExperionHypertableController(IExperionDbService dbSvc) => _dbSvc = dbSvc;

    /// <summary>하이퍼테이블 상태 조회</summary>
    [HttpGet("status")]
    public async Task<IActionResult> GetStatus()
    {
        var status = await _dbSvc.GetHypertableStatusAsync();
        return Ok(new
        {
            isHypertable           = status.IsHypertable,
            tableName              = status.TableName,
            statusMessage          = status.StatusMessage,
            recordCount            = status.RecordCount,
            hasRetentionPolicy     = status.HasRetentionPolicy,
            hasCompression         = status.HasCompression,
            hasContinuousAggregate = status.HasContinuousAggregate
        });
    }

    private static readonly System.Text.RegularExpressions.Regex _pgIdentifier =
        new(@"^[a-z_][a-z0-9_]{0,62}$", System.Text.RegularExpressions.RegexOptions.Compiled);

    private static readonly HashSet<string> _allowedTables =
        new(StringComparer.OrdinalIgnoreCase) { "history_table" };

    /// <summary>하이퍼테이블 수동 생성</summary>
    [HttpPost("create")]
    public async Task<IActionResult> Create([FromBody] HypertableCreateDto request)
    {
        var tableName  = request.TableName  ?? "history_table";
        var timeColumn = request.TimeColumn ?? "recorded_at";

        if (!_allowedTables.Contains(tableName))
            return BadRequest(new { success = false, message = $"허용되지 않는 테이블명: {tableName}" });

        if (!_pgIdentifier.IsMatch(tableName) || !_pgIdentifier.IsMatch(timeColumn))
            return BadRequest(new { success = false, message = "테이블명/컬럼명은 영문 소문자, 숫자, 언더스코어만 허용됩니다." });

        var createRequest = new HypertableCreateRequest
        {
            TableName = tableName,
            TimeColumn = timeColumn,
            TimeInterval = request.TimeInterval ?? "1 day",
            MigrateData = request.MigrateData,
            SetRetentionPolicy = request.SetRetentionPolicy,
            RetentionPeriod = request.RetentionPeriod ?? "90 days",
            EnableCompression = request.EnableCompression,
            CompressionPeriod = request.CompressionPeriod ?? "1 day",
            CreateContinuousAggregate = request.CreateContinuousAggregate
        };

        var result = await _dbSvc.CreateHypertableAsync(createRequest);

        return result.Success
            ? Ok(new { result.Success, result.Message, result.TableName })
            : (IActionResult)(result.TableName != null
                ? StatusCode(409, new { result.Success, result.Message })
                : StatusCode(500, new { result.Success, result.Message }));
    }
}

// ── FastTable / FastRecord ────────────────────────────────────────────────────

[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
    private readonly IExperionFastService _fastSvc;

    public ExperionFastController(IExperionFastService fastSvc)
        => _fastSvc = fastSvc;

    /// <summary>새 fastSession 시작</summary>
    [HttpPost("start")]
    public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
    {
        try
        {
            var session = await _fastSvc.StartSessionAsync(request);
            return Ok(new { id = session.Id, name = session.Name, status = session.Status, startedAt = session.StartedAt });
        }
        catch (ArgumentException ex)         { return BadRequest(new { error = ex.Message }); }
        catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
        catch (Exception ex)
        {
            var msgs = new List<string>();
            for (var e = ex; e != null; e = e.InnerException) msgs.Add(e.Message);
            return StatusCode(500, new { error = msgs[0], detail = string.Join(" → ", msgs.Skip(1)) });
        }
    }

    /// <summary>세션 중지</summary>
    [HttpPost("{id:int}/stop")]
    public async Task<IActionResult> Stop(int id)
    {
        try
        {
            await _fastSvc.StopSessionAsync(id);
            return Ok(new { success = true, message = "세션이 중지되었습니다." });
        }
        catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
    }

    /// <summary>세션 목록 조회</summary>
    [HttpGet("sessions")]
    public async Task<IActionResult> GetSessions()
    {
        var sessions = await _fastSvc.GetSessionsAsync();
        return Ok(new
        {
            total = sessions.Count(),
            items = sessions.Select(s => new
            {
                id            = s.Id,
                name          = s.Name,
                status        = s.Status,
                samplingMs    = s.SamplingMs,
                durationSec   = s.DurationSec,
                tagCount      = s.TagList.Length,
                rowCount      = s.RowCount,
                startedAt     = s.StartedAt,
                endedAt       = s.EndedAt,
                retentionDays = s.RetentionDays,
                pinned        = s.Pinned
            })
        });
    }

    /// <summary>세션 상세 정보</summary>
    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetSession(int id)
    {
        var session = await _fastSvc.GetSessionAsync(id);
        if (session == null) return NotFound();
        return Ok(new
        {
            id            = session.Id,
            name          = session.Name,
            status        = session.Status,
            samplingMs    = session.SamplingMs,
            durationSec   = session.DurationSec,
            tagList       = session.TagList,
            rowCount      = session.RowCount,
            startedAt     = session.StartedAt,
            endedAt       = session.EndedAt,
            retentionDays = session.RetentionDays,
            pinned        = session.Pinned
        });
    }

    /// <summary>레코드 조회 (Long 포맷)</summary>
    [HttpGet("{id:int}/records")]
    public async Task<IActionResult> GetRecords(int id,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to,
        [FromQuery] string format = "long")
    {
        var result = await _fastSvc.GetRecordsAsync(id, from, to, format);
        return Ok(new
        {
            sessionId = result.SessionId,
            from      = result.From,
            to        = result.To,
            tagNames  = result.TagNames,
            total     = result.TotalCount,
            items     = result.Items.Select(r => new
            {
                sessionId  = r.SessionId,
                recordedAt = r.RecordedAt,
                tagName    = r.TagName,
                value      = r.Value
            })
        });
    }

    /// <summary>CSV Export (스트리밍)</summary>
    [HttpGet("{id:int}/csv")]
    public async Task<IActionResult> ExportCsv(int id,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to)
    {
        var ms = new MemoryStream();
        await _fastSvc.ExportCsvAsync(id, ms, from, to);
        ms.Position = 0;
        return File(ms, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv");
    }

    /// <summary>세션 삭제</summary>
    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        try
        {
            await _fastSvc.DeleteSessionAsync(id);
            return Ok(new { success = true, message = "세션이 삭제되었습니다." });
        }
        catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
    }

    /// <summary>세션 고정/해제</summary>
    [HttpPost("{id:int}/pin")]
    public async Task<IActionResult> Pin(int id, [FromBody] PinRequest request)
    {
        try
        {
            await _fastSvc.PinSessionAsync(id, request.Pinned);
            return Ok(new { success = true, pinned = request.Pinned });
        }
        catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
    }
}

// ── P&ID Controller ────────────────────────────────────────────────────────────
[ApiController]
[Route("api/pid")]
public class ExperionPidController : ControllerBase
{
    private readonly IPidExtractorService _extractor;
    private readonly ITagMappingService _mapping;

    public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
    {
        _extractor = extractor;
        _mapping = mapping;
    }

    // NOTE: extract/equipment/statistics/{id}/confidence/{id}/activate/{id}/deactivate/mappings/* 액션은
    // PidController(api/pid)와 중복되어 ASP.NET Core ambiguous route → HTTP 500 원인이었음.
    // 신규 PidController 쪽으로 일원화하고 여기에는 PidController에 없는 export 액션만 남김.

    [HttpGet("export/csv")]
    public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo)
    {
        var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
        var csv = await _extractor.ExportToCsvAsync(items);
        var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
        return File(bytes, "text/csv", $"pid-equipment-{DateTime.Now:yyyyMMdd}.csv");
    }

    [HttpGet("export/excel")]
    public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo)
    {
        var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
        var excelBytes = await _extractor.ExportToExcelAsync(items);
        return File(excelBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"pid-equipment-{DateTime.Now:yyyyMMdd}.xlsx");
    }
}

public record PinRequest(bool Pinned);

// ── Event History Controller ────────────────────────────────────────────────────

[ApiController]
[Route("api/event-history")]
public class EventHistoryController : ControllerBase
{
    private readonly IExperionDbService _db;
    private readonly ILogger<EventHistoryController> _logger;

    public EventHistoryController(
        IExperionDbService db,
        ILogger<EventHistoryController> logger)
    {
        _db = db;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IActionResult> Query(
        [FromQuery] string? tagName,
        [FromQuery] string? area,
        [FromQuery] string? subArea,
        [FromQuery] string? eventType,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to,
        [FromQuery] int limit = 500)
    {
        try
        {
            var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
            var toDt = to ?? DateTime.UtcNow;
            var rows = await _db.QueryEventHistoryAsync(tagName, area, subArea, eventType, fromDt, toDt, limit);
            var list = rows.Select(r => new
            {
                id = r.Id,
                tagName = r.TagName,
                nodeId = r.NodeId,
                prevValue = r.PrevValue,
                currValue = r.CurrValue,
                eventType = r.EventType,
                eventTime = r.EventTime,
                area = r.Area,
                subArea = r.SubArea,
                durationSeconds = r.DurationSeconds,
                metadata = r.Metadata
            }).ToList();

            return Ok(new { success = true, count = list.Count, data = list });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[EventHistoryController] 조회 실패");
            return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
        }
    }

    [HttpGet("summary")]
    public async Task<IActionResult> GetSummary(
        [FromQuery] string? area,
        [FromQuery] string? subArea,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to)
    {
        try
        {
            var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
            var toDt = to ?? DateTime.UtcNow;
            var rows = await _db.QueryEventHistoryAsync(null, area, subArea, null, fromDt, toDt, 10000);

            var summary = rows.GroupBy(r => r.SubArea ?? "기타")
                .Select(g => new
                {
                    subArea = g.Key,
                    totalEvents = g.Count(),
                    tripCount = g.Count(r => r.EventType == "TRIP"),
                    runCount = g.Count(r => r.EventType == "RUN"),
                    alarmCount = g.Count(r => r.EventType == "ALARM"),
                    changeCount = g.Count(r => r.EventType == "CHANGE")
                }).ToList();

            return Ok(new { success = true, count = summary.Count, data = summary });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[EventHistoryController] 요약 조회 실패");
            return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
        }
    }

    [HttpGet("digital-tags")]
    public async Task<IActionResult> GetDigitalTags()
    {
        try
        {
            var tags = await _db.GetDigitalTagNamesAsync();
            var list = tags.Select(t => new { tagName = t }).ToList();
            return Ok(new { success = true, count = list.Count, data = list });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[EventHistoryController] 디지털 태그 조회 실패");
            return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
        }
    }
}

// ── LLM 설정 ──────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/llm")]
public class LlmConfigController : ControllerBase
{
    private readonly IConfiguration _config;
    private readonly ILogger<LlmConfigController> _logger;

    public LlmConfigController(
        IConfiguration config,
        ILogger<LlmConfigController> logger)
    {
        _config = config;
        _logger = logger;
    }

    string LlmModelPath
    {
        get
        {
            var mcpDir = _config["McpServer:WorkingDirectory"] ?? "../../mcp-server";
            if (!Path.IsPathRooted(mcpDir))
                mcpDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), mcpDir));
            return Path.Combine(mcpDir, "llm-model.json");
        }
    }

    [HttpGet("config")]
    public IActionResult GetConfig()
    {
        try
        {
            var path = LlmModelPath;
            if (!System.IO.File.Exists(path))
            {
                return Ok(new { success = false, error = "llm-model.json not found", vllmModel = "Qwen3.6-27B-FP8" });
            }
            var json = System.IO.File.ReadAllText(path);
            var doc = System.Text.Json.JsonDocument.Parse(json);
            var model = doc.RootElement.TryGetProperty("vllm_model", out var v)
                ? v.GetString() ?? "Qwen3.6-27B-FP8"
                : "Qwen3.6-27B-FP8";
            return Ok(new { success = true, vllmModel = model });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[LlmConfigController] 설정 조회 실패");
            return Ok(new { success = false, error = ex.Message, vllmModel = (string?)null });
        }
    }

    [HttpPost("config")]
    public IActionResult SetConfig([FromBody] System.Text.Json.JsonElement body)
    {
        try
        {
            var path = LlmModelPath;
            var model = body.TryGetProperty("vllm_model", out var v) ? v.GetString() : null;
            if (string.IsNullOrEmpty(model))
            {
                return Ok(new { success = false, error = "vllm_model is required" });
            }

            var json = System.Text.Json.JsonSerializer.Serialize(new { vllm_model = model });
            System.IO.File.WriteAllText(path, json);
            _logger.LogInformation("[LlmConfigController] 모델 변경: {Model}", model);
            return Ok(new { success = true, vllmModel = model });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[LlmConfigController] 설정 저장 실패");
            return Ok(new { success = false, error = ex.Message });
        }
    }
}

// ── OPC UA Write Controller ────────────────────────────────────────────────────

[ApiController]
[Route("api/points")]
public class ExperionWriteController : ControllerBase
{
    private readonly IExperionOpcWriteClient _writeClient;
    private readonly IExperionOpcClient _readClient;
    private readonly IConfiguration _config;
    private readonly ILogger<ExperionWriteController> _logger;

    public ExperionWriteController(
        IExperionOpcWriteClient writeClient,
        IExperionOpcClient readClient,
        IConfiguration config,
        ILogger<ExperionWriteController> logger)
    {
        _writeClient = writeClient;
        _readClient = readClient;
        _config = config;
        _logger = logger;
    }

    private ExperionServerConfig GetServerConfig()
    {
        var section = _config.GetSection("Experion:Default");
        return new ExperionServerConfig
        {
            ServerHostName = section["ServerHostName"] ?? "",
            Port = section.GetValue<int?>("Port") ?? 4840,
            ClientHostName = section["ClientHostName"] ?? "dbsvr",
            UserName = section["UserName"] ?? "",
            Password = section["Password"] ?? ""
        };
    }

    /// <summary>SP/OP 쓰기 (WriteValue)</summary>
    [HttpPost("write")]
    public async Task<IActionResult> WriteTag([FromBody] WriteTagRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.NodeId))
            return BadRequest(new { success = false, error = "nodeId는 필수입니다." });

        var cfg = GetServerConfig();
        var r = await _writeClient.WriteTagAsync(cfg, req.NodeId, req.Value);
        _logger.LogInformation("[Write] {NodeId} = {Value} → {Success} ({Status})", req.NodeId, req.Value, r.Success, r.Status);

        return Ok(new { success = r.Success, nodeId = r.NodeId, status = r.Status, error = r.Error });
    }

    /// <summary>MD/MODE 변경 (EnumValueType)</summary>
    [HttpPost("mode")]
    public async Task<IActionResult> SetMode([FromBody] SetModeRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.NodeId))
            return BadRequest(new { success = false, error = "nodeId는 필수입니다." });
        if (string.IsNullOrWhiteSpace(req.Mode))
            return BadRequest(new { success = false, error = "mode는 필수입니다. (MAN 또는 AUTO)"});

        var cfg = GetServerConfig();
        var r = await _writeClient.SetModeAsync(cfg, req.NodeId, req.Mode);
        _logger.LogInformation("[Write] Mode {NodeId} = {Mode} → {Success} ({Status})", req.NodeId, req.Mode, r.Success, r.Status);

        return Ok(new { success = r.Success, nodeId = r.NodeId, mode = r.Mode, enumValue = r.EnumValue, status = r.Status, error = r.Error });
    }

    /// <summary>통합 제어: MD→MAN → OP 쓰기 → AUTO 복귀</summary>
    [HttpPost("control")]
    public async Task<IActionResult> ControlOp([FromBody] ControlOpRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.TagName))
            return BadRequest(new { success = false, error = "tagName은 필수입니다." });

        var cfg = GetServerConfig();
        var tagName = req.TagName.ToLowerInvariant();
        var mdNodeId = $"ns=1;s=sinamserver:{tagName}.md";
        var modeNodeId = $"ns=1;s=sinamserver:{tagName}.mode";
        var opNodeId = $"ns=1;s=sinamserver:{tagName}.op";
        var restoreAuto = req.RestoreAuto ?? true;

        var results = new List<object>();

        try
        {
            // 1. MD를 MAN으로 변경
            _logger.LogInformation("[Control] MD→MAN {MdNodeId}", mdNodeId);
            var mdResult = await _writeClient.SetModeAsync(cfg, mdNodeId, "MAN");
            results.Add(new { step = "set_md_man", success = mdResult.Success, nodeId = mdNodeId, status = mdResult.Status, error = mdResult.Error });

            if (!mdResult.Success)
                return Ok(new { success = false, steps = results, error = "MD 변경 실패" });

            // 2. Mode를 MAN으로 변경
            _logger.LogInformation("[Control] Mode→MAN {ModeNodeId}", modeNodeId);
            var modeResult = await _writeClient.SetModeAsync(cfg, modeNodeId, "MAN");
            results.Add(new { step = "set_mode_man", success = modeResult.Success, nodeId = modeNodeId, status = modeResult.Status, error = modeResult.Error });

            // 3. OP 쓰기
            _logger.LogInformation("[Control] OP={OpValue} {OpNodeId}", req.OpValue, opNodeId);
            var opResult = await _writeClient.WriteTagAsync(cfg, opNodeId, req.OpValue);
            results.Add(new { step = "write_op", success = opResult.Success, nodeId = opNodeId, value = req.OpValue, status = opResult.Status, error = opResult.Error });

            if (!opResult.Success)
                return Ok(new { success = false, steps = results, error = "OP 쓰기 실패" });

            // 4. AUTO 복귀 (선택)
            if (restoreAuto)
            {
                _logger.LogInformation("[Control] MD→AUTO {MdNodeId}", mdNodeId);
                var restoreMd = await _writeClient.SetModeAsync(cfg, mdNodeId, "AUTO");
                results.Add(new { step = "restore_md_auto", success = restoreMd.Success, nodeId = mdNodeId, status = restoreMd.Status, error = restoreMd.Error });

                _logger.LogInformation("[Control] Mode→AUTO {ModeNodeId}", modeNodeId);
                var restoreMode = await _writeClient.SetModeAsync(cfg, modeNodeId, "AUTO");
                results.Add(new { step = "restore_mode_auto", success = restoreMode.Success, nodeId = modeNodeId, status = restoreMode.Status, error = restoreMode.Error });
            }

            return Ok(new { success = true, steps = results });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[Control] 통합 제어 실패 TagName={TagName}", req.TagName);
            return Ok(new { success = false, steps = results, error = ex.Message });
        }
    }

    /// <summary>태그 현재값 읽기 (확인용)</summary>
    [HttpPost("read")]
    public async Task<IActionResult> ReadTag([FromBody] ReadTagRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.NodeId))
            return BadRequest(new { success = false, error = "nodeId는 필수입니다." });

        var cfg = GetServerConfig();
        var r = await _readClient.ReadTagAsync(cfg, req.NodeId);

        return Ok(new
        {
            success = r.Success,
            nodeId = r.NodeId,
            value = r.Value?.ToString(),
            statusCode = r.StatusCode,
            timestamp = r.Timestamp,
            error = r.ErrorMessage
        });
    }
}

public class WriteTagRequest
{
    public string NodeId { get; set; } = "";
    public double Value { get; set; }
}

public class SetModeRequest
{
    public string NodeId { get; set; } = "";
    public string Mode { get; set; } = "";
}

public class ControlOpRequest
{
    public string TagName { get; set; } = "";
    public double OpValue { get; set; }
    public bool? RestoreAuto { get; set; } = true;
}

public class ReadTagRequest
{
    public string NodeId { get; set; } = "";
}
