# 이벤트 기반 디지털 포인트 히스토리 — 상세 코딩 플랜

## E2E 진단 수정 기록 (2026-05-11)

| # | 심각도 | 문제 | 위치 | 수정 내용 |
|---|--------|------|------|-----------|
| A | 🔴 HIGH | `GetAreaByTagNameAsync`에서 `tagName`(`FIC101.instate0`)을 `base_tag`로 그대로 조회 → `tag_metadata.base_tag`는 `.` 이전 기본 태그명(`FIC101`)만 저장하므로 area가 항상 null 반환 | `ExperionDbContext.cs:1228` | `tagName.Contains('.')` 체크 후 `tagName[..tagName.LastIndexOf('.')]`로 baseTag 추출, baseTag 기준 조회로 변경 |
| B | 🟠 MED | 디지털 태그 캐시 필드가 인스턴스 레벨(`instance field`) → `ExperionDbService`는 `Scoped` 등록이므로 스코프마다 새 인스턴스 생성, 캐시 TTL 300초가 실질적으로 무효 | `ExperionDbContext.cs:193-196` | 캐시 필드 4개 모두 `static`으로 승격 (`_digitalTagCache`, `_digitalTagCacheTime`, `_cacheRefreshTask`, `_digitalCacheLock`) — 프로세스 수명 동안 캐시 유지 |
| C | 🟠 MED | `GetDigitalTagNamesCachedAsync`의 lock 블록에서 `_cacheRefreshTask = null` 즉시 초기화 → 동시 호출 시 두 번째 호출자가 기존 Task를 재사용하지 못하고 별도 Refresh 시작 | `ExperionDbContext.cs:1323-1335` | lock 내에서는 Task 할당/참조만, Task 완료 후 별도 lock에서 null 초기화 — 동시 호출자 모두 동일 Task await |
| D | 🟡 LOW | `DigitalEventDetectorService.DetectAndRecordChangesAsync`가 매 1초마다 `GetDigitalPointsAsync()` 호출 → 내부에서 `GetDigitalTagNamesAsync()`(tag_metadata 스캔)를 불필요하게 재실행, DB 쿼리 2회/초 | `DigitalEventDetectorService.cs:105` | 서비스 레벨(Singleton)에 `_knownDigitalTags` + `_lastTagRefresh` 캐시 추가, 5분마다만 `GetDigitalTagNamesAsync()` 호출, 평상시 `GetRealtimeRecordsByTagNamesAsync(_knownDigitalTags)`로 직접 조회 → DB 쿼리 1회/초로 절감 |
| E | 🟡 LOW | DDL `curr_value TEXT`에 `NOT NULL` 제약 없음 → Entity `string CurrValue = string.Empty`(non-nullable)와 스키마 불일치, 수동 NULL 삽입 시 런타임 오류 가능 | `ExperionDbContext.cs:354` | DDL을 `curr_value TEXT NOT NULL DEFAULT ''`로 변경 |
| F | 🟡 LOW | `BuildMetadata`의 `tagName.Contains("-il-")` 등이 대소문자 구분 → Experion 태그명이 대문자인 경우 메타데이터 생성 누락 | `DigitalEventDetectorService.cs:198-199` | `StringComparison.OrdinalIgnoreCase` 추가 |

### 빌드 검증 (E2E 수정 후)
- `dotnet build src/Web/ExperionCrawler.csproj` → **성공** (0 Warning, 0 Error)
- 수정 일시: 2026-05-11

---

## 초기 구현 진단 및 수정 기록 (2026-05-11)

| # | 심각도 | 문제 | 근거 | 수정 내용 |
|---|--------|------|------|-----------|
| 1 | 🟠 MED | `Query`/`Summary` 엔드포인트의 `from`/`to` 파라미터가 `DateTime` (nullable 아님) → 클라이언트가 생략 시 `DateTime.MinValue`(0001-01-01)로 바인딩되어 전체 데이터 조회 | `ExperionControllers.cs:1183-1184`, `1218-1219` | `[FromQuery] DateTime? from`, `[FromQuery] DateTime? to`로 변경, 생략 시 `DateTime.UtcNow.AddDays(-1)` ~ `DateTime.UtcNow` 기본값 적용 |
| 2 | 🟡 LOW | `RecordDigitalEventAsync`가 개별 이벤트마다 `SaveChangesAsync` 호출 → 1초 주기 내 여러 태그 변경 시 순차적 DB round-trip 발생 | `ExperionDbContext.cs:1257-1258` | `BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord>)` 추가, `DigitalEventDetectorService`에서 1초 주기 내 모든 이벤트를 수집 후 `AddRangeAsync` → 단일 `SaveChangesAsync`로 일괄 저장 |
| 3 | 🟡 LOW | `_cacheRefreshTask == null` 체크와 할당이 atomic 아님 → 동시 요청 시 두 태스크가 동시에 캐시 리프레시 실행 가능 | `ExperionDbContext.cs:1303-1311` (수정 전) | `lock (this)` 블록으로 `_cacheRefreshTask` 할당 및 할당 해제 로직 보호 |

### 빌드 검증 (초기 구현)
- `dotnet build src/Web/ExperionCrawler.csproj` → **성공** (0 Warning, 0 Error)
- 수정 일시: 2026-05-11

---

## 0. 전제 조건 및 기존 코드 매핑

| 항목 | 파일 | 라인 | 비고 |
|------|------|------|------|
| `IExperionDbService` 인터페이스 | `src/Core/Application/Interfaces/IExperionServices.cs` | 53-122 | 여기에 새 메서드 추가 |
| `ExperionDbService` 구현체 | `src/Infrastructure/Database/ExperionDbContext.cs` | 174-1493 | DbSet, DDL, 구현 추가 |
| `SnapshotToHistoryAsync` | `src/Infrastructure/Database/ExperionDbContext.cs` | 730-748 | 디지털 제외 로직 추가 |
| `ExperionHistoryService` | `src/Infrastructure/OpcUa/ExperionHistoryService.cs` | 1-63 | 60초 주기 스냅샷 호출 |
| Entity 클래스 | `src/Core/Domain/Entities/ExperionEntities.cs` | 1-150 | `EventHistoryRecord` 추가 |
| HostedService 등록 | `src/Web/Program.cs` | 1-158 | 새 서비스 등록 |
| Controller | `src/Web/Controllers/ExperionControllers.cs` | 1-1158 | 새 API 엔드포인트 추가 |
| DB 초기화 DDL | `src/Infrastructure/Database/ExperionDbContext.cs` | 186-341 | `InitializeAsync()` 내부 |

---

## 1. 구현 순서 (Dependency Graph)

```
Step 1: Entity + DDL
   ↓
Step 2: 인터페이스 확장 (IExperionDbService)
   ↓
Step 3: DbContext 구현 (디지털 식별 + 이벤트 기록)
   ↓
Step 4: SnapshotToHistoryAsync 수정 (디지털 제외)
   ↓
Step 5: DigitalEventDetectorService 구현
   ↓
Step 6: Program.cs 등록
   ↓
Step 7: Controller + API 엔드포인트
   ↓
Step 8: 검증 + 테스트
```

---

## 2. Step-by-Step 코딩 계획

### Step 1: EventHistoryRecord Entity + DDL 생성

#### 1-1. Entity 클래스 추가

**파일**: `src/Core/Domain/Entities/ExperionEntities.cs`
**위치**: 파일 말미 (line 150 이후)

```csharp
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
[Table("event_history_table")]
public class EventHistoryRecord
{
    [Key]
    [Column("id")]              public long      Id              { get; set; }
    [Column("tagname")]         public string    TagName         { get; set; } = string.Empty;
    [Column("node_id")]         public string    NodeId          { get; set; } = string.Empty;
    [Column("prev_value")]      public string?   PrevValue       { get; set; }
    [Column("curr_value")]      public string    CurrValue       { get; set; } = string.Empty;
    [Column("event_type")]      public string    EventType       { get; set; } = string.Empty;
    [Column("event_time")]      public DateTime  EventTime       { get; set; } = DateTime.UtcNow;
    [Column("area")]            public string?   Area            { get; set; }
    [Column("section")]         public string?   Section         { get; set; }
    [Column("duration_seconds")] public int?     DurationSeconds { get; set; }
    [Column("metadata")]        public string?   Metadata        { get; set; }
    [Column("created_at")]      public DateTime  CreatedAt       { get; set; } = DateTime.UtcNow;
}
```

**검증 체크리스트**:
- [ ] `Id`가 `long` (BIGSERIAL 매칭)인지 확인
- [ ] 모든 Column 속성이 SQL 컬럼명과 정확히 일치하는지 확인
- [ ] `using System.ComponentModel.DataAnnotations;` `using System.ComponentModel.DataAnnotations.Schema;` import가 파일 상단에 있는지 확인

---

#### 1-2. DbSet 추가

**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
**위치**: 기존 DbSet 정의 영역 (line 18-30)

```csharp
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
```

**검증 체크리스트**:
- [ ] 기존 DbSet 패턴과 동일한 형식인지 확인

---

#### 1-3. DDL 추가 (InitializeAsync 내부)

**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
**위치**: `InitializeAsync()` 메서드 내, 기존 테이블 생성 SQL 뒤 (line 340 근처)

```csharp
await _db.ExecuteSqlAsync(@"
    CREATE TABLE IF NOT EXISTS event_history_table (
        id BIGSERIAL PRIMARY KEY,
        tagname TEXT NOT NULL,
        node_id TEXT NOT NULL,
        prev_value TEXT,
        curr_value TEXT,
        event_type TEXT NOT NULL,
        event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        area TEXT,
        section TEXT,
        duration_seconds INT,
        metadata JSONB,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );

    CREATE INDEX IF NOT EXISTS idx_event_history_tagname_time
        ON event_history_table(tagname, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_area_time
        ON event_history_table(area, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_section_time
        ON event_history_table(section, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_event_type
        ON event_history_table(event_type, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_tagname_event_type
        ON event_history_table(tagname, event_type, event_time DESC);
");
```

**검증 체크리스트**:
- [ ] `IF NOT EXISTS`로 기존 데이터베이스에 영향 없는지 확인
- [ ] `metadata` 컬럼이 JSONB 타입인지 확인 (JSON 문자열이 아닌 JSONB)
- [ ] Entity의 `Metadata` 속성이 `string?`인데 JSONB로 저장되는지 — Npgsql은 string → JSONB 자동 변환 지원 확인

---

### Step 2: IExperionDbService 인터페이스 확장

**파일**: `src/Core/Application/Interfaces/IExperionServices.cs`
**위치**: `IExperionDbService` 인터페이스 내 (line 53-122), `GetNodeIdByTagNameAsync()` (line 121) 뒤

```csharp
/// <summary>디지털 태그 이름 목록 조회 (value 패턴 또는 tag_metadata 기반)</summary>
Task<IEnumerable<string>> GetDigitalTagNamesAsync();

/// <summary>디지털 포인트 현재 값 조회</summary>
Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();

/// <summary>디지털 이벤트 기록</summary>
Task<int> RecordDigitalEventAsync(DigitalEventRecord record);

/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
Task<string?> GetAreaByTagNameAsync(string tagName);

/// <summary>이벤트 히스토리 조회</summary>
Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
    string? tagName, string? area, string? section,
    string? eventType, DateTime from, DateTime to, int limit = 500);
```

#### 2-1. DigitalEventRecord DTO 추가

**파일**: `src/Core/Application/Interfaces/IExperionServices.cs`
**위치**: 파일 말미 (기존 result record 클래스들 뒤, line 322 이후)

```csharp
/// <summary>디지털 이벤트 기록용 서비스 계층 DTO</summary>
public class DigitalEventRecord
{
    public string TagName { get; set; } = "";
    public string NodeId { get; set; } = "";
    public string? PrevValue { get; set; }
    public string CurrValue { get; set; } = "";
    public string EventType { get; set; } = "";
    public DateTime EventTime { get; set; }
    public int? DurationSeconds { get; set; }
    public string? Area { get; set; }
    public string? Section { get; set; }
    public string? Metadata { get; set; }
}

/// <summary>이벤트 히스토리 조회 결과 행</summary>
public class EventHistoryRow
{
    public long Id { get; set; }
    public string TagName { get; set; } = "";
    public string NodeId { get; set; } = "";
    public string? PrevValue { get; set; }
    public string CurrValue { get; set; } = "";
    public string EventType { get; set; } = "";
    public DateTime EventTime { get; set; }
    public string? Area { get; set; }
    public string? Section { get; set; }
    public int? DurationSeconds { get; set; }
    public string? Metadata { get; set; }
}
```

**검증 체크리스트**:
- [ ] 인터페이스에 추가한 메서드 시그니처가 구현체와 정확히 일치하는지 확인
- [ ] `EventHistoryRow`가 controller에서 camelCase anonymous object로 변환될 것임을 명시 (AGENTS.md 규칙)

---

### Step 3: DbContext 구현

**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
**위치**: `ExperionDbService` 클래스 내, `SnapshotToHistoryAsync()` (line 730) 바로 앞

#### 3-1. 디지털 태그 식별 로직

```csharp
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
{
    // tag_metadata에서 data_type='i=7594'인 태그를 우선 조회
    // fallback: realtime_table에서 value가 '{'로 시작하는 태그
    var fromMetadata = await _ctx.TagMetadata
        .Where(m => m.Value == "i=7594")
        .Select(m => m.BaseTag)
        .Distinct()
        .ToListAsync();

    if (fromMetadata.Any())
        return fromMetadata;

    // Fallback: realtime_table의 LiveValue 패턴으로 판단
    return await _ctx.RealtimePoints
        .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
        .Select(p => p.TagName)
        .Distinct()
        .ToListAsync();
}
```

**검증 체크리스트**:
- [ ] tag_metadata에 data_type 정보가 있는지 확인 (현재 tag_metadata 구조에서 Attribute/Value 매핑 확인)
- [ ] Fallback 로직이 tag_metadata가 비어있을 때도 동작하는지 확인
- [ ] `Distinct()`로 중복 제거하는지 확인

---

#### 3-2. 디지털 포인트 조회

```csharp
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
{
    var digitalTagNames = await GetDigitalTagNamesAsync();
    var tagSet = new HashSet<string>(digitalTagNames);

    if (tagSet.Count == 0)
        return Enumerable.Empty<RealtimePoint>();

    return await _ctx.RealtimePoints
        .Where(p => tagSet.Contains(p.TagName))
        .ToListAsync();
}
```

**검증 체크리스트**:
- [ ] `GetDigitalTagNamesAsync` 결과를 HashSet으로 변환하여 O(1) lookup하는지 확인
- [ ] tagSet이 비어있을 때 빈 결과 반환 (null 방지)

---

#### 3-3. area 조회

```csharp
public async Task<string?> GetAreaByTagNameAsync(string tagName)
{
    // tag_metadata에서 base_tag=tagName, attribute='area' 조회
    // value = "{12 | P6 | }" → "P6" 파싱
    var meta = await _ctx.TagMetadata
        .Where(m => m.BaseTag == tagName && m.Attribute == "area")
        .Select(m => m.Value)
        .FirstOrDefaultAsync();

    if (string.IsNullOrEmpty(meta)) return null;

    // "{12 | P6 | }" 패턴에서 area 코드 추출
    var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
    return match.Success ? match.Groups[1].Value : null;
}
```

**검증 체크리스트**:
- [ ] tag_metadata 테이블에 area attribute가 실제로 존재하는지 DB에서 확인
- [ ] 정규식이 `{12 | P6 | }` 형태에서 정확히 "P6"를 추출하는지 테스트

---

#### 3-4. 이벤트 기록

```csharp
public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
{
    var row = new EventHistoryRecord
    {
        TagName         = record.TagName,
        NodeId          = record.NodeId,
        PrevValue       = record.PrevValue,
        CurrValue       = record.CurrValue,
        EventType       = record.EventType,
        EventTime       = record.EventTime,
        DurationSeconds = record.DurationSeconds,
        Area            = record.Area,
        Section         = record.Section,
        Metadata        = record.Metadata
    };

    await _ctx.EventHistoryRecords.AddAsync(row);
    return await _ctx.SaveChangesAsync();
}
```

**검증 체크리스트**:
- [ ] `Metadata`가 string → JSONB로 자동 변환되는지 확인 (Npgsql 설정)
- [ ] SaveChangesAsync 호출 후 반환 값이 실제 저장된 행 수인지 확인

---

#### 3-5. 이벤트 히스토리 조회

```csharp
public async Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
    string? tagName, string? area, string? section,
    string? eventType, DateTime from, DateTime to, int limit = 500)
{
    var query = _ctx.EventHistoryRecords
        .Where(r => r.EventTime >= from && r.EventTime <= to);

    if (!string.IsNullOrEmpty(tagName))
        query = query.Where(r => r.TagName == tagName);
    if (!string.IsNullOrEmpty(area))
        query = query.Where(r => r.Area == area);
    if (!string.IsNullOrEmpty(section))
        query = query.Where(r => r.Section == section);
    if (!string.IsNullOrEmpty(eventType))
        query = query.Where(r => r.EventType == eventType);

    var records = await query
        .OrderByDescending(r => r.EventTime)
        .Take(limit)
        .ToListAsync();

    return records.Select(r => new EventHistoryRow
    {
        Id = r.Id,
        TagName = r.TagName,
        NodeId = r.NodeId,
        PrevValue = r.PrevValue,
        CurrValue = r.CurrValue,
        EventType = r.EventType,
        EventTime = r.EventTime,
        Area = r.Area,
        Section = r.Section,
        DurationSeconds = r.DurationSeconds,
        Metadata = r.Metadata
    });
}
```

**검증 체크리스트**:
- [ ] 모든 필터가 nullable 체크 후 Where 적용하는지 확인
- [ ] `OrderByDescending`이 인덱스(idx_event_history_tagname_time)를 활용하는지 확인
- [ ] limit 기본값이 500으로 합리적인지 확인

---

### Step 4: SnapshotToHistoryAsync 수정 (디지털 제외)

**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
**위치**: line 730-748

#### 변경 전:
```csharp
public async Task<int> SnapshotToHistoryAsync()
{
    var now    = DateTime.UtcNow;
    var points = await _ctx.RealtimePoints.ToListAsync();
    // ... 전체 포인트 스냅샷
}
```

#### 변경 후:
```csharp
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
    var now = DateTime.UtcNow;
    var query = _ctx.RealtimePoints.AsQueryable();

    if (!includeDigital)
    {
        var digitalTagNames = GetCachedDigitalTagNames();
        if (digitalTagNames.Count > 0)
        {
            query = query.Where(p => !digitalTagNames.Contains(p.TagName));
        }
    }

    var points = await query.ToListAsync();
    if (points.Count == 0) return 0;

    var rows = points.Select(p => new HistoryRecord
    {
        TagName    = p.TagName,
        NodeId     = p.NodeId,
        Value      = p.LiveValue,
        RecordedAt = now
    }).ToList();

    await _ctx.HistoryRecords.AddRangeAsync(rows);
    var saved = await _ctx.SaveChangesAsync();
    return saved;
}
```

#### 디지털 태그 캐시 (매 60초마다 조회하는 것을 방지)

`ExperionDbService` 클래스 레벨에 추가:
```csharp
private HashSet<string> _digitalTagCache = new();
private DateTime _digitalTagCacheTime = DateTime.MinValue;
private readonly object _cacheLock = new();
private const int DigitalTagCacheTtlSeconds = 300; // 5분 TTL
```

```csharp
private HashSet<string> GetCachedDigitalTagNames()
{
    lock (_cacheLock)
    {
        if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheCacheTtlSeconds)
            return _digitalTagCache;
    }

    // async 메서드 내에서 lock 후 async 호출은 데드락 위험 → 
    // 대신 백그라운드 갱신 패턴 사용
    var tags = GetDigitalTagNamesAsync().GetAwaiter().GetResult();
    
    lock (_cacheLock)
    {
        _digitalTagCache = new HashSet<string>(tags);
        _digitalTagCacheTime = DateTime.UtcNow;
    }
    return _digitalTagCache;
}
```

**⚠️ 진단 체크리스트 교차 검증 **(STEP 6)
- [ ] Q2: `GetAwaiter().GetResult()` 사용 — 하지만 이는 scoped 메서드 내에서 한 번만 호출되며 캐싱되므로 실제 블로킹 문제는 없음 (STEP 5의 async blocking 체크와 비교)
- [ ] Q3: `lock` + async 패턴 — 캐시 갱신 시 데드락 가능성 있음. **수정 필요**: async/await 전용 패턴으로 변경

#### 수정된 캐시 패턴 (async-safe):

```csharp
private HashSet<string> _digitalTagCache = new();
private DateTime _digitalTagCacheTime = DateTime.MinValue;
private Task? _cacheRefreshTask = null;

private async Task<HashSet<string>> GetDigitalTagNamesCachedAsync()
{
    if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
        return _digitalTagCache;

    if (_cacheRefreshTask == null)
    {
        _cacheRefreshTask = RefreshDigitalTagCacheAsync();
    }
    else
    {
        var existing = _cacheRefreshTask;
        _cacheRefreshTask = null;
        await existing;
    }

    return _digitalTagCache;
}

private async Task RefreshDigitalTagCacheAsync()
{
    var tags = await GetDigitalTagNamesAsync();
    _digitalTagCache = new HashSet<string>(tags);
    _digitalTagCacheTime = DateTime.UtcNow;
}
```

#### 그리고 SnapshotToHistoryAsync에서:
```csharp
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
    var now = DateTime.UtcNow;
    var query = _ctx.RealtimePoints.AsQueryable();

    if (!includeDigital)
    {
        var digitalTagNames = await GetDigitalTagNamesCachedAsync();
        if (digitalTagNames.Count > 0)
        {
            query = query.Where(p => !digitalTagNames.Contains(p.TagName));
        }
    }

    var points = await query.ToListAsync();
    if (points.Count == 0) return 0;

    var rows = points.Select(p => new HistoryRecord
    {
        TagName    = p.TagName,
        NodeId     = p.NodeId,
        Value      = p.LiveValue,
        RecordedAt = now
    }).ToList();

    await _ctx.HistoryRecords.AddRangeAsync(rows);
    var saved = await _ctx.SaveChangesAsync();
    return saved;
}
```

**검증 체크리스트**:
- [ ] 기존 `ExperionHistoryService`에서 `SnapshotToHistoryAsync()` 호출 시 기본값(`includeDigital=false`)으로 동작하는지 확인
- [ ] 인터페이스 시그니처 변경으로 인한 breaking change 없는지 확인 (기본 파라미터 사용)
- [ ] 캐시 TTL(5분)이 디지털 태그 변경 주기에 적합한지 확인

---

### Step 5: DigitalEventDetectorService 구현

**파일**: `src/Infrastructure/OpcUa/DigitalEventDetectorService.cs` (새 파일)

```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Text.Json;

namespace ExperionCrawler.Infrastructure.OpcUa;

/// <summary>
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
/// </summary>
public class DigitalEventDetectorService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<DigitalEventDetectorService> _logger;
    private readonly ConcurrentDictionary<string, DigitalPointState> _previousStates = new();
    private readonly ConcurrentDictionary<string, string> _areaCache = new();
    private readonly int _checkIntervalMs = 1000;
    private readonly int _debounceSeconds = 5;

    private readonly JsonSerializerOptions _jsonOptions = new()
    {
        PropertyNamingPolicy = null  // PascalCase 유지 (JSONB 저장용)
    };

    private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);

    public DigitalEventDetectorService(
        IServiceScopeFactory scopeFactory,
        ILogger<DigitalEventDetectorService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);

        try
        {
            await LoadDigitalTagNamesAsync(stoppingToken);
            await LoadCurrentStatesAsync(stoppingToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[DigitalEventDetector] 초기화 실패 — 서비스 계속 실행 (감지 루프에서 재시도)");
        }

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(_checkIntervalMs, stoppingToken);
                await DetectAndRecordChangesAsync(stoppingToken);
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                _logger.LogError(ex, "[DigitalEventDetector] 감지 루프 오류");
            }
        }

        _logger.LogInformation("[DigitalEventDetector] 종료");
    }

    private async Task LoadDigitalTagNamesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

        var digitalTags = await db.GetDigitalTagNamesAsync();
        foreach (var tag in digitalTags)
        {
            // 이전 상태 초기화 (존재하지 않는 태그만)
            if (!_previousStates.ContainsKey(tag))
            {
                _previousStates.TryAdd(tag, null!);
            }
        }
        _logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", digitalTags.Count());
    }

    private async Task LoadCurrentStatesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        
        var points = await db.GetDigitalPointsAsync();
        foreach (var p in points)
        {
            _previousStates[p.TagName] = new DigitalPointState(p.LiveValue ?? "", DateTime.UtcNow, null);
        }
        _logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
    }

    private async Task DetectAndRecordChangesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

        var currentPoints = await db.GetDigitalPointsAsync();

        foreach (var point in currentPoints)
        {
            if (ct.IsCancellationRequested) break;

            var tagName = point.TagName;
            var currValue = point.LiveValue ?? "";

            var prevState = _previousStates.GetValueOrDefault(tagName);

            // 첫 등장 시 이전 값 초기화 (이벤트 기록 안 함)
            if (prevState == null)
            {
                _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
                continue;
            }

            // 값 변경 감지
            if (prevState.Value != currValue)
            {
                var eventType = DetermineEventType(prevState.Value, currValue);
                var now = DateTime.UtcNow;
                var elapsed = (now - prevState.Timestamp).TotalSeconds;

                // Debounce: 동일 event_type + 동일 값으로의 짧은 시간 내 반복 방지
                // 상태 전환(TRIP→RUN 등)은 항상 기록
                if (prevState.EventType == eventType && elapsed < _debounceSeconds)
                {
                    _previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
                    continue;
                }

                var duration = (int)elapsed;
                var area = await GetAreaAsync(db, tagName);
                var section = ExtractSection(tagName);

                await db.RecordDigitalEventAsync(new DigitalEventRecord
                {
                    TagName = tagName,
                    NodeId = point.NodeId,
                    PrevValue = prevState.Value,
                    CurrValue = currValue,
                    EventType = eventType,
                    EventTime = now,
                    DurationSeconds = duration,
                    Area = area,
                    Section = section,
                    Metadata = BuildMetadata(tagName, eventType, currValue)
                });

                _logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr}, {Duration}s)",
                    tagName, eventType, prevState.Value, currValue, duration);

                _previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
            }
        }
    }

    private string DetermineEventType(string prevValue, string currValue)
    {
        if (currValue.Contains("L-STOP") || currValue.Contains("STOP") || currValue.Contains("TRIP"))
            return "TRIP";
        if (currValue.Contains("RUN") || currValue.Contains("START"))
            return "RUN";
        if (currValue.Contains("ALARM"))
            return "ALARM";
        if (prevValue.Contains("ALARM") && !currValue.Contains("ALARM"))
            return "NORMAL";
        return "CHANGE";
    }

    private async Task<string?> GetAreaAsync(IExperionDbService db, string tagName)
    {
        if (_areaCache.TryGetValue(tagName, out var cached)) return cached;

        var area = await db.GetAreaByTagNameAsync(tagName);
        if (area != null)
            _areaCache[tagName] = area;
        return area;
    }

    private string? ExtractSection(string tagName)
    {
        var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
        if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
        return null;
    }

    private string? BuildMetadata(string tagName, string eventType, string currValue)
    {
        if (tagName.Contains("-il-") || tagName.Contains("-trip"))
        {
            return JsonSerializer.Serialize(new
            {
                interlock_tag = tagName,
                event_type = eventType,
                raw_value = currValue
            }, _jsonOptions);
        }
        return null;
    }
}
```

**⚠️ 진단 체크리스트 교차 검증 **(STEP 5+6)

| 체크 항목 | 결과 | 비고 |
|-----------|------|------|
| async 내 blocking 호출 | ✅ OK | 모든 DB 호출이 async |
| Race Condition | ✅ OK | `ConcurrentDictionary` 사용 |
| 예외 삼킴 | ✅ OK | catch에서 `LogError` 호출 |
| CancellationToken 전달 | ✅ OK | `ExecuteAsync` → 하위 메서드까지 전달 |
| Q1: 이미 수정된 문제인가? | N/A | 새 코드 |
| Q2: 다른 레이어에서 처리되는가? | N/A | 독립 서비스 |
| Q3: 의도적 설계인가? | ✅ | debounce는 의도적 |
| Q4: 재현 시나리오 있는가? | ✅ | 서비스 재시작 시 상태 초기화 → 초기 이벤트 누락 가능 (LOW) |

**검증 체크리스트**:
- [ ] `ConcurrentDictionary`의 `GetValueOrDefault`가 .NET 8에서 지원되는지 확인
- [ ] `record` 타입이 `ConcurrentDictionary` value로 사용 가능할지 확인 (immutability 고려)
- [ ] CancellationToken이 모든 async 메서드에 전달되는지 확인
- [ ] Debounce 로직이 상태 전환(TRIP→RUN)을 항상 기록하는지 확인

---

### Step 6: Program.cs 등록

**파일**: `src/Web/Program.cs`
**위치**: `ExperionHistoryService` 등록 (line 83) 뒤

```csharp
builder.Services.AddHostedService<DigitalEventDetectorService>();
```

**검증 체크리스트**:
- [ ] `using ExperionCrawler.Infrastructure.OpcUa;` namespace가 Program.cs 상단에 있는지 확인
- [ ] Singleton + HostedService로 등록되는지 확인 (AddHostedService는 기본 Singleton)

---

### Step 7: Controller + API 엔드포인트

**파일**: `src/Web/Controllers/ExperionControllers.cs`
**위치**: 파일 말미 (line 1158 이후), `ExperionPidController` 뒤

```csharp
[ApiController]
[Route("api/[controller]")]
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? section,
        [FromQuery] string? eventType,
        [FromQuery] DateTime from,
        [FromQuery] DateTime to,
        [FromQuery] int limit = 500)
    {
        try
        {
            var rows = await _db.QueryEventHistoryAsync(tagName, area, section, eventType, from, to, 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,
                section = r.Section,
                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? section,
        [FromQuery] DateTime from,
        [FromQuery] DateTime to)
    {
        try
        {
            var rows = await _db.QueryEventHistoryAsync(null, area, section, null, from, to, 10000);
            
            var summary = rows.GroupBy(r => r.Section ?? "기타")
                .Select(g => new
                {
                    section = 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>() });
        }
    }
}
```

**⚠️ 진단 체크리스트 교차 검증 **(STEP 5+6)

| 체크 항목 | 결과 | 비고 |
|-----------|------|------|
| camelCase JSON | ✅ OK | 명시적 anonymous object 사용 |
| 예외 노출 | ✅ OK | catch 후 `error` 필드만 반환, 스택 트레이스 없음 |
| 에러 응답 형식 | ✅ OK | 모든 경로에서 `{success, data}` 형식 통일 |
| SQL Injection | ✅ OK | EF Core Where 사용 (parameterized) |
| Q4: 재현 시나리오 | N/A | GET only, 읽기 전용 |

**검증 체크리스트**:
- [ ] 모든 응답이 camelCase anonymous object인지 확인 (AGENTS.md 규칙)
- [ ] `from`/`to` 파라미터가 DateTime으로 바인딩되는지 확인 (ISO 8601 또는 Unix timestamp)
- [ ] error 응답도 `success: false` 형식으로 통일되어 있는지 확인

---

## 3. 검증 및 테스트 절차

### Stage A: 빌드 검증

```bash
dotnet build src/Web/ExperionCrawler.csproj
```

**체크리스트**:
- [ ] 컴파일 오류 없음
- [ ] Entity 클래스의 using 문 누락 없음
- [ ] 인터페이스-구현체 매칭 오류 없음

---

### Stage B: DB 초기화 검증

애플리케이션 시작 후:
```sql
-- 테이블 존재 확인
SELECT table_name FROM information_schema.tables WHERE table_name = 'event_history_table';

-- 컬럼 확인
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'event_history_table' ORDER BY ordinal_position;

-- 인덱스 확인
SELECT indexname FROM pg_indexes WHERE tablename = 'event_history_table';
```

**체크리스트**:
- [ ] 테이블이 생성되었는지 확인
- [ ] 컬럼 타입이 설계와 일치하는지 확인 (특히 metadata = jsonb)
- [ ] 모든 인덱스가 생성되었는지 확인

---

### Stage C: 디지털 태그 식별 검증

```bash
# API 호출
curl "http://localhost:5000/api/event-history/digital-tags" | jq
```

**체크리스트**:
- [ ] 반환된 태그 목록이 실제 디지털 태그와 일치하는지 확인
- [ ] 아날로그 태그가 포함되지 않는지 확인
- [ ] tag_metadata 기반 식별이 우선 적용되는지 확인

---

### Stage D: 스냅샷 디지털 제외 검증

```sql
-- 최근 스냅샷에서 디지털 값 패턴 확인
SELECT tagname, value FROM history_table 
WHERE recorded_at > NOW() - INTERVAL '5 minutes'
AND value LIKE '{%'
LIMIT 10;
```

**체크리스트**:
- [ ] 최근 history_table에 디지털 태그 값(`{`로 시작)이 없는지 확인
- [ ] 아날로그 태그는 정상적으로 기록되는지 확인

---

### Stage E: 이벤트 감지 검증

1. 실제 디지털 태그 상태 변경 유발 (또는 DB에 직접 테스트 데이터 삽입)
2. event_history_table 확인:
```sql
SELECT * FROM event_history_table ORDER BY event_time DESC LIMIT 20;
```

**체크리스트**:
- [ ] 상태 변경이 이벤트로 기록되는지 확인
- [ ] event_type이 올바르게 분류되는지 확인 (TRIP, RUN, CHANGE 등)
- [ ] duration_seconds가 이전 상태 지속 시간을 정확히 나타내는지 확인
- [ ] area와 section이 올바르게 추출되는지 확인
- [ ] debounce가 동작하여 짧은 시간 내 중복 이벤트가 없는지 확인

---

### Stage F: API 조회 검증

```bash
# 전체 조회
curl "http://localhost:5000/api/event-history?from=2026-05-01T00:00:00Z&to=2026-05-11T00:00:00Z&limit=100" | jq

# area 필터
curl "http://localhost:5000/api/event-history?area=P6&from=...&to=..." | jq

# 요약 조회
curl "http://localhost:5000/api/event-history/summary?area=P6&from=...&to=..." | jq
```

**체크리스트**:
- [ ] 응답 형식이 `{success, count, data}`인지 확인
- [ ] data 내 각 항목이 camelCase인지 확인
- [ ] 필터가 올바르게 동작하는지 확인
- [ ] 요약 API가 구간별 집계를 올바르게 반환하는지 확인

---

### Stage G: 성능 검증

**체크리스트**:
- [ ] 500개 디지털 태그 처리 시 1초 간격 감지가 정상 동작하는지 확인
- [ ] DB 쿼리 실행 시간 확인 (`GetDigitalPointsAsync`가 1초 주기 내에서 완료되는지)
- [ ] 메모리 사용량 모니터링 (`_previousStates`, `_areaCache` 크기)

---

## 4. 진단 체크리스트 최종 교차 검증

### STEP 5 패턴 매칭 결과

| 체크 | 항목 | 결과 |
|------|------|------|
| 미정의 변수 참조 | ❌ 없음 | 모든 변수 정의 후 사용 |
| async 내 blocking | ✅ 확인 | `GetAwaiter().GetResult()` 제거됨 (async-safe 캐시 패턴 사용) |
| Race Condition | ✅ 확인 | `ConcurrentDictionary` 사용, 캐시 갱신은 단일 태스크 |
| 예외 삼킴 | ✅ 확인 | 모든 catch에서 `LogError` 호출 |
| SQL Injection | ✅ 확인 | EF Core LINQ Where 사용 |
| 에러 응답 형식 | ✅ 확인 | `{success, data}` 통일 |
| camelCase | ✅ 확인 | Controller에서 명시적 anonymous object |

### STEP 6 교차 검증 결과

| 질문 | 항목 | 결과 |
|------|------|------|
| Q1: 이미 수정된 문제인가? | N/A | 새 코드 |
| Q2: 다른 레이어에서 처리되는가? | 예외 처리 | Controller 레벨 + Service 레벨 모두 처리 |
| Q3: 의도적 설계인가? | debounce, 캐시 TTL | 계획 문서에 명시된 의도적 설계 |
| Q4: 재현 시나리오 있는가? | 서비스 재시작 시 상태 손실 | LOW — 재시작 시 `_previousStates` 초기화되어 첫 변경이 기록되지 않을 수 있음. `LoadCurrentStatesAsync`로 완화 |

---

## 5. Todo List (구현 순서)

| # | 작업 | 파일 | 상태 | 검증 방법 |
|---|------|------|------|-----------|
| 1-1 | `EventHistoryRecord` Entity 추가 | `ExperionEntities.cs` | ⬜ | 빌드 성공 |
| 1-2 | `DbSet<EventHistoryRecord>` 추가 | `ExperionDbContext.cs` | ⬜ | 빌드 성공 |
| 1-3 | DDL (테이블 + 인덱스) 추가 | `ExperionDbContext.cs` | ⬜ | DB 테이블 확인 |
| 2-1 | 인터페이스 메서드 5개 추가 | `IExperionServices.cs` | ⬜ | 빌드 성공 |
| 2-2 | `DigitalEventRecord` DTO 추가 | `IExperionServices.cs` | ⬜ | 빌드 성공 |
| 2-3 | `EventHistoryRow` DTO 추가 | `IExperionServices.cs` | ⬜ | 빌드 성공 |
| 3-1 | `GetDigitalTagNamesAsync` 구현 | `ExperionDbContext.cs` | ⬜ | API 테스트 |
| 3-2 | `GetDigitalPointsAsync` 구현 | `ExperionDbContext.cs` | ⬜ | API 테스트 |
| 3-3 | `GetAreaByTagNameAsync` 구현 | `ExperionDbContext.cs` | ⬜ | DB 직접 확인 |
| 3-4 | `RecordDigitalEventAsync` 구현 | `ExperionDbContext.cs` | ⬜ | INSERT 테스트 |
| 3-5 | `QueryEventHistoryAsync` 구현 | `ExperionDbContext.cs` | ⬜ | API 테스트 |
| 3-6 | 디지털 태그 캐시 로직 구현 | `ExperionDbContext.cs` | ⬜ | 성능 확인 |
| 4 | `SnapshotToHistoryAsync` 수정 | `ExperionDbContext.cs` | ⬜ | history_table 확인 |
| 5 | `DigitalEventDetectorService` 생성 | 새 파일 | ⬜ | 이벤트 기록 확인 |
| 6 | Program.cs 등록 | `Program.cs` | ⬜ | 앱 시작 확인 |
| 7 | `EventHistoryController` 생성 | `ExperionControllers.cs` | ⬜ | API 호출 테스트 |
| 8 | 전체 빌드 + 테스트 | — | ⬜ | `dotnet build` |

---

## 6. 주의사항

1. **breaking change 방지**: `SnapshotToHistoryAsync()`의 기본 파라미터(`includeDigital = false`)를 사용하므로 기존 호출 코드 수정 불필요
2. **기존 데이터 마이그레이션 불필요**: `event_history_table`은 새 테이블로 생성, 기존 `history_table`에 영향 없음
3. **서비스 재시작 시 상태 손실**: `LoadCurrentStatesAsync`가 재시작 시 현재 상태를 로드하여 첫 이벤트를 방지하지만, 재시작 직전의 상태는 손실됨 (LOW, 수용 가능)
4. **JSONB 메타데이터**: Npgsql에서 `string` → `JSONB` 자동 변환이 동작하는지 실제 테스트 필요. 문제가 발생하면 `NpgsqlTypes.NpgsqlJson` 타입으로 변경
