using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Core.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using System.Text.Json;
using System.Globalization;

namespace Hc900Crawler.Infrastructure.Database;

// ── DbContext ────────────────────────────────────────────────────────────────

public class Hc900DbContext : DbContext
{
    public Hc900DbContext(DbContextOptions<Hc900DbContext> options) : base(options) { }

    public DbSet<RealtimePoint>  RealtimePoints  => Set<RealtimePoint>();
    public DbSet<HistoryRecord>  HistoryRecords  => Set<HistoryRecord>();
    public DbSet<FastSession>    FastSessions    => Set<FastSession>();
    public DbSet<FastRecord>     FastRecords     => Set<FastRecord>();
    public DbSet<TagMetadata>    TagMetadata     => Set<TagMetadata>();
    public DbSet<Hc900MapEntry>  Hc900MapEntries => Set<Hc900MapEntry>();

    // P&ID 데이터베이스용 DbSet
    public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
    public DbSet<PidPrefixRule> PidPrefixRules => Set<PidPrefixRule>();
    public DbSet<PidAuditLog>  PidAuditLog  => Set<PidAuditLog>();
    public DbSet<FfOperatorAction> FfOperatorActions => Set<FfOperatorAction>();
    public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
    public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();

    // ── Knowledge Base ────────────────────────────────────────────────────────
    public DbSet<KbCollection>        KbCollections        => Set<KbCollection>();
    public DbSet<KbDocument>          KbDocuments          => Set<KbDocument>();
    public DbSet<KbIngestJob>         KbIngestJobs         => Set<KbIngestJob>();
    public DbSet<KbAdminCredential>   KbAdminCredentials   => Set<KbAdminCredential>();
    public DbSet<KbAdminSession>      KbAdminSessions      => Set<KbAdminSession>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("hc900");

        modelBuilder.Entity<Hc900MapEntry>(e =>
        {
            e.HasKey(x => x.Id);
            e.HasIndex(x => new { x.ControllerId, x.TagName }).IsUnique();
            e.HasIndex(x => x.Hc900Tag);
        });

        modelBuilder.Entity<RealtimePoint>(e =>
        {
            e.HasKey(x => x.Id);
            e.HasIndex(x => x.NodeId).IsUnique();
            e.HasIndex(x => x.TagName);
        });

        modelBuilder.Entity<HistoryRecord>(e =>
        {
            e.HasKey(x => x.Id);
            e.HasIndex(x => x.TagName);
            e.HasIndex(x => x.RecordedAt);
        });

        modelBuilder.Entity<FastSession>(e =>
        {
            e.HasKey(x => x.Id);
            e.HasIndex(x => x.Status);
            e.HasIndex(x => x.StartedAt);
            e.Property(x => x.TagList).HasColumnType("jsonb");
        });

        modelBuilder.Entity<FastRecord>(e =>
        {
            e.HasKey(x => new { x.SessionId, x.RecordedAt, x.TagName });
            e.HasIndex(x => x.SessionId);
        });

        modelBuilder.Entity<TagMetadata>(e =>
        {
            e.HasKey(x => x.Id);
            e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique();
            e.HasIndex(x => x.BaseTag);
        });
        
        // P&ID 엔티티 설정
        modelBuilder.Entity<PidEquipment>(entity =>
        {
            entity.ToTable("pid_equipment");
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.TagNo)
                .IsRequired()
                .HasMaxLength(50);
            
            entity.Property(e => e.EquipmentName)
                .HasMaxLength(200);
            
            entity.Property(e => e.InstrumentType)
                .HasMaxLength(10);
            
            entity.Property(e => e.LineNumber)
                .HasMaxLength(100);
            
            entity.Property(e => e.PidDrawingNo)
                .HasMaxLength(50);
            
            entity.Property(e => e.Confidence)
                .HasPrecision(4, 3);
            
            entity.Property(e => e.IsActive)
                .HasDefaultValue(true);

            entity.Property(e => e.ConnectionLocked)
                .HasDefaultValue(false);
            
            entity.Property(e => e.ExtractedAt)
                .HasDefaultValueSql("NOW()");
            
            entity.Property(e => e.UpdatedAt)
                .ValueGeneratedOnAdd()
                .HasDefaultValueSql("NOW()");
            
            entity.HasIndex(e => e.TagNo);
            entity.HasIndex(e => e.InstrumentType);
            entity.HasIndex(e => e.ExtractedAt);
            entity.HasIndex(e => e.Category);

            entity.Property(e => e.Category)
                .HasMaxLength(30);
            entity.Property(e => e.Role)
                .HasMaxLength(100);
            entity.Property(e => e.FromTag)
                .HasMaxLength(50);
            entity.Property(e => e.ToTag)
                .HasMaxLength(50);

            entity.HasOne(e => e.ExperionTag)
                  .WithMany()
                  .HasForeignKey(e => e.ExperionTagId)
                  .OnDelete(DeleteBehavior.SetNull);
        });
        
        modelBuilder.Entity<PidAuditLog>(entity =>
        {
            entity.ToTable("pid_audit_log");
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Source)
                .HasMaxLength(50)
                .HasDefaultValue("WebUI");
            
            entity.Property(e => e.Action)
                .HasMaxLength(50);
            
            entity.Property(e => e.TargetTagNo)
                .HasMaxLength(50);
            
            entity.Property(e => e.LoggedAt)
                .HasDefaultValueSql("NOW()");
            
            entity.HasIndex(e => e.LoggedAt);
        });

        modelBuilder.Entity<FfOperatorAction>(entity =>
        {
            entity.ToTable("ff_operator_action");
            entity.HasKey(e => e.Id);

            entity.Property(e => e.StreamKey).HasMaxLength(50);
            entity.Property(e => e.ActionType).HasMaxLength(50);
            entity.Property(e => e.NodeId).HasMaxLength(255);
            entity.Property(e => e.Result).HasMaxLength(50);
            entity.Property(e => e.OperatorName).HasMaxLength(100);
            entity.Property(e => e.CreatedAt).HasDefaultValueSql("NOW()");
            entity.HasIndex(e => e.ColumnId);
            entity.HasIndex(e => e.CreatedAt);
        });

        modelBuilder.Entity<PidPrefixRule>(entity =>
        {
            entity.ToTable("pid_prefix_rules");
            entity.HasKey(e => e.Id);

            entity.Property(e => e.Prefix)
                .IsRequired()
                .HasMaxLength(30);

            entity.HasIndex(e => e.Prefix).IsUnique();

            entity.Property(e => e.Category)
                .IsRequired()
                .HasMaxLength(30);

            entity.Property(e => e.Description)
                .HasMaxLength(200);

            entity.Property(e => e.SortOrder)
                .HasDefaultValue(0);

            entity.Property(e => e.CreatedAt)
                .HasDefaultValueSql("NOW()");

            entity.Property(e => e.UpdatedAt)
                .HasDefaultValueSql("NOW()");

            entity.HasIndex(e => e.Category);
        });

        modelBuilder.Entity<PidGraphStatus>(entity =>
        {
            entity.ToTable("pid_graph_status");
            entity.HasKey(e => e.TaskId);

            entity.Property(e => e.Status)
                .HasMaxLength(20);

            entity.Property(e => e.Message)
                .HasMaxLength(500);

            entity.HasIndex(e => e.UpdatedAt);
        });

        modelBuilder.Entity<EventHistoryRecord>(entity =>
        {
            entity.ToTable("event_history_table");
            entity.HasKey(e => e.Id);

            entity.Property(e => e.Metadata)
                .HasColumnType("jsonb");

            entity.HasIndex(e => new { e.TagName, e.EventTime });
            entity.HasIndex(e => new { e.Area, e.EventTime });
            entity.HasIndex(e => new { e.EventType, e.EventTime });
        });

        // ── Knowledge Base ───────────────────────────────────────────────────
        modelBuilder.Entity<KbCollection>(e =>
        {
            e.HasKey(x => x.CollectionKey);
        });

        modelBuilder.Entity<KbDocument>(e =>
        {
            e.HasKey(x => x.Id);
            e.Property(x => x.Tags).HasColumnType("text[]");
            e.HasIndex(x => new { x.CollectionKey, x.Status, x.UploadedAt });
            e.HasIndex(x => x.Title);
        });

        modelBuilder.Entity<KbIngestJob>(e =>
        {
            e.HasKey(x => x.Id);
            e.HasIndex(x => new { x.Stage, x.FinishedAt });
        });

        modelBuilder.Entity<KbAdminCredential>(e =>
        {
            e.HasKey(x => x.Id);
        });

        modelBuilder.Entity<KbAdminSession>(e =>
        {
            e.HasKey(x => x.Token);
            e.HasIndex(x => x.ExpiresAt);
        });
    }
}

// ── Service ──────────────────────────────────────────────────────────────────

public class Hc900DbService : IExperionDbService
{
    private readonly Hc900DbContext _ctx;
    private readonly ILogger<Hc900DbService> _logger;

    private static HashSet<string> _digitalTagCache = new();
    private static DateTime _digitalTagCacheTime = DateTime.MinValue;
    private static Task? _cacheRefreshTask = null;
    private static readonly object _digitalCacheLock = new();
    private const int DigitalTagCacheTtlSeconds = 300;

    public Hc900DbService(Hc900DbContext ctx, ILogger<Hc900DbService> logger)
    {
        _ctx    = ctx;
        _logger = logger;
    }

    public async Task<bool> InitializeAsync()
    {
        try
        {
            await _ctx.Database.EnsureCreatedAsync();

            // ── fast_session / fast_record 테이블 생성 ────────────────────────────────
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS fast_session (
                    id             SERIAL PRIMARY KEY,
                    name           TEXT        NOT NULL,
                    started_at     TIMESTAMPTZ NOT NULL,
                    ended_at       TIMESTAMPTZ,
                    status         TEXT        NOT NULL DEFAULT 'Pending',
                    sampling_ms    INTEGER     NOT NULL,
                    duration_sec   INTEGER     NOT NULL,
                    tag_list       JSONB       NOT NULL DEFAULT '[]',
                    row_count      INTEGER     NOT NULL DEFAULT 0,
                    retention_days INTEGER,
                    pinned         BOOLEAN     NOT NULL DEFAULT FALSE
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS fast_record (
                    session_id  INTEGER     NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE,
                    recorded_at TIMESTAMPTZ NOT NULL,
                    tagname     TEXT        NOT NULL,
                    value       TEXT,
                    PRIMARY KEY (session_id, recorded_at, tagname)
                )
                """);

            // PK 마이그레이션: 기존 테이블 PK에 recorded_at 없으면 수정 (TimescaleDB hypertable 요건)
            await _ctx.Database.ExecuteSqlRawAsync("""
                DO $$
                BEGIN
                    IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'fast_record' AND schemaname = 'public')
                       AND NOT EXISTS (
                           SELECT 1 FROM pg_constraint c
                           JOIN pg_class t ON t.oid = c.conrelid
                           JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
                           WHERE t.relname = 'fast_record' AND c.contype = 'p' AND a.attname = 'recorded_at'
                       )
                    THEN
                        ALTER TABLE fast_record DROP CONSTRAINT IF EXISTS fast_record_pkey;
                        ALTER TABLE fast_record ADD PRIMARY KEY (session_id, recorded_at, tagname);
                    END IF;
                END $$;
                """);

            // TimescaleDB hypertable 생성 — EF Core를 거치지 않아야 fail 로그 없음
            try
            {
                var connStr = _ctx.Database.GetConnectionString()!;
                await using var conn = new NpgsqlConnection(connStr);
                await conn.OpenAsync();
                await using var cmd = conn.CreateCommand();
                cmd.CommandText = "SELECT create_hypertable('fast_record'::regclass, 'recorded_at'::name, if_not_exists => TRUE, migrate_data => TRUE)";
                await cmd.ExecuteNonQueryAsync();
                cmd.CommandText = "SELECT set_chunk_time_interval('fast_record'::regclass, INTERVAL '1 day')";
                await cmd.ExecuteNonQueryAsync();
            }
            catch
            {
                // TimescaleDB 미설치 또는 이미 하이퍼테이블 — 무시하고 계속
            }

            // EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
            // raw_node_map / node_map_master 는 DDL로 직접 보장
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS raw_node_map (
                    id        SERIAL PRIMARY KEY,
                    level     INTEGER NOT NULL,
                    class     TEXT    NOT NULL,
                    name      TEXT    NOT NULL,
                    node_id   TEXT    NOT NULL,
                    data_type TEXT    NOT NULL
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS node_map_master (
                    id        SERIAL PRIMARY KEY,
                    level     INTEGER NOT NULL,
                    class     TEXT    NOT NULL,
                    name      TEXT    NOT NULL,
                    node_id   TEXT    NOT NULL,
                    data_type TEXT    NOT NULL
                )
                """);

            // realtime_table 생성 (실시간 모니터링 포인트)
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS realtime_table (
                    id            SERIAL PRIMARY KEY,
                    tagname       TEXT        NOT NULL,
                    node_id       TEXT        NOT NULL,
                    livevalue     TEXT,
                    timestamp     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    controller_id TEXT        NOT NULL DEFAULT 'HC1'
                )
                """);

            // realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음

            // tag_metadata 테이블 생성 (메타데이터 - 변경 드묾)
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS tag_metadata (
                    id          SERIAL PRIMARY KEY,
                    base_tag    TEXT        NOT NULL,
                    attribute   TEXT        NOT NULL,
                    value       TEXT,
                    node_id     TEXT,
                    loaded_at   TIMESTAMPTZ DEFAULT NOW(),
                    UNIQUE(base_tag, attribute)
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
                """);

            // pid_equipment.connection_locked — EnsureCreated 는 기존 테이블에 컬럼을
            // 추가하지 않으므로 멱등 ALTER 로 보장 (엑셀 import 수동교정 보호 플래그)
            await _ctx.Database.ExecuteSqlRawAsync("""
                ALTER TABLE pid_equipment
                ADD COLUMN IF NOT EXISTS connection_locked BOOLEAN NOT NULL DEFAULT FALSE
                """);

            // event_history_table 생성 (디지털 포인트 상태 변경 이벤트)
            await _ctx.Database.ExecuteSqlRawAsync("""
                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        NOT NULL DEFAULT '',
                    event_type      TEXT        NOT NULL,
                    event_time      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    area            TEXT,
                    sub_area        TEXT,
                    duration_seconds INT,
                    metadata        JSONB,
                    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                DROP INDEX IF EXISTS idx_event_history_section_time;
                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_sub_area_time
                    ON event_history_table(sub_area, 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);
                """);

            // ── Multi-Controller Migration (controller_id) ─────────────────────
            // 기존 테이블에 controller_id 컬럼 추가 (멱등)
            await _ctx.Database.ExecuteSqlRawAsync(
                "ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
            await _ctx.Database.ExecuteSqlRawAsync(
                "ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
            await _ctx.Database.ExecuteSqlRawAsync(
                "ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
            await _ctx.Database.ExecuteSqlRawAsync(
                "ALTER TABLE tag_metadata ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
            await _ctx.Database.ExecuteSqlRawAsync(
                "ALTER TABLE hc900_map_master ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");

            // hc900_map_master: tagname is unique PER controller, not globally — the same
            // SignalTag name can exist on several controllers (peer comms). Replace the old
            // UNIQUE(tagname) with UNIQUE(controller_id, tagname).
            await _ctx.Database.ExecuteSqlRawAsync("""
                DO $$
                BEGIN
                    IF EXISTS (
                        SELECT 1 FROM pg_constraint
                        WHERE conrelid = 'hc900_map_master'::regclass
                          AND conname = 'hc900_map_master_tagname_key'
                    ) THEN
                        ALTER TABLE hc900_map_master DROP CONSTRAINT hc900_map_master_tagname_key;
                    END IF;
                    IF NOT EXISTS (
                        SELECT 1 FROM pg_indexes
                        WHERE tablename = 'hc900_map_master'
                          AND indexname = 'ux_hc900_map_ctrl_tag'
                    ) THEN
                        CREATE UNIQUE INDEX ux_hc900_map_ctrl_tag
                            ON hc900_map_master(controller_id, tagname);
                    END IF;
                END $$;
                """);

            // realtime_table: UNIQUE(controller_id, tagname) for ON CONFLICT upsert
            await _ctx.Database.ExecuteSqlRawAsync("""
                DO $$
                BEGIN
                    IF NOT EXISTS (
                        SELECT 1 FROM pg_indexes
                        WHERE tablename = 'realtime_table'
                          AND indexname = 'idx_realtime_table_ctrl_tag_unique'
                    ) THEN
                        CREATE UNIQUE INDEX idx_realtime_table_ctrl_tag_unique
                            ON realtime_table(controller_id, tagname);
                    END IF;
                END $$;
                """);

            // v_tag_summary 뷰 재생성 (controller_id 포함)
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_tag_summary CASCADE");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_tag_summary AS
                SELECT
                  rt_base.base_tag,
                  pv_rt.livevalue AS pv,
                  sp_rt.livevalue AS sp,
                  op_rt.livevalue AS op,
                  instate0_rt.livevalue AS instate0,
                  instate1_rt.livevalue AS instate1,
                  instate2_rt.livevalue AS instate2,
                  desc_md.value AS description,
                  area_md.value AS area,
                  sub_area_md.value AS sub_area,
                  rt_base.controller_id
                FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag, controller_id FROM realtime_table) rt_base
                LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv' AND pv_rt.controller_id = rt_base.controller_id
                LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp' AND sp_rt.controller_id = rt_base.controller_id
                LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op' AND op_rt.controller_id = rt_base.controller_id
                LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0' AND instate0_rt.controller_id = rt_base.controller_id
                LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1' AND instate1_rt.controller_id = rt_base.controller_id
                LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2' AND instate2_rt.controller_id = rt_base.controller_id
                LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
                LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
                LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area'
                """);

            // v_plant_running_state 뷰 — area별 펌프 RUN/STOP/TRIP 집계 (v_tag_summary 의존)
            // view만 재생성 (DROP VIEW v_plant_running_state IF EXISTS + CREATE)
            // v_tag_summary를 CASCADE로 drop했으므로 여기서 재생성 필요
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_plant_running_state AS
                WITH pump_state AS (
                    SELECT
                        trim(split_part(area, '|', 2)) AS area_code,
                        area AS area_raw,
                        base_tag,
                        pv,
                        controller_id
                    FROM v_tag_summary
                    WHERE area IS NOT NULL
                      AND (base_tag LIKE 'p-%' OR base_tag LIKE 'vp-%')
                      AND pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
                )
                SELECT
                    area_code,
                    MAX(area_raw)                                              AS area_raw,
                    COUNT(*)                                                   AS total_pumps,
                    COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|')          AS running_pumps,
                    COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-TRIP\s*\|')         AS tripped_pumps,
                    COUNT(*) FILTER (WHERE pv ~ '\|\s*(L-STOP|R-STOP)\s*\|')   AS stopped_pumps,
                    CASE
                        WHEN COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') > 0 THEN 'RUNNING'
                        WHEN COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-TRIP\s*\|') > 0 THEN 'TRIPPED'
                        ELSE 'STOPPED'
                    END                                                        AS status,
                    array_agg(base_tag) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') AS running_pump_tags
                FROM pump_state
                WHERE area_code IS NOT NULL AND area_code <> ''
                GROUP BY area_code
                ORDER BY area_code
                """);

            // ── P&ID 테이블 ──────────────────────────────────────────────────
            // PidEquipment/PidAuditLog/PidGraphStatus 엔티티는 OnModelCreating에
            // 등록되어 있지만 EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
            // DDL로 직접 보장한다.
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS pid_equipment (
                    id               BIGSERIAL PRIMARY KEY,
                    tag_no           VARCHAR(50)  NOT NULL,
                    equipment_name   VARCHAR(200),
                    instrument_type  VARCHAR(10),
                    line_number      VARCHAR(100),
                    pid_drawing_no   VARCHAR(50),
                    confidence       NUMERIC(4,3) NOT NULL DEFAULT 0,
                    is_active        BOOLEAN      NOT NULL DEFAULT TRUE,
                    extracted_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
                    updated_at       TIMESTAMPTZ,
                    experion_tag_id  INTEGER REFERENCES realtime_table(id) ON DELETE SET NULL
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE INDEX IF NOT EXISTS idx_pid_equipment_tag_no
                    ON pid_equipment(tag_no);
                CREATE INDEX IF NOT EXISTS idx_pid_equipment_instrument_type
                    ON pid_equipment(instrument_type);
                CREATE INDEX IF NOT EXISTS idx_pid_equipment_extracted_at
                    ON pid_equipment(extracted_at DESC);
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS pid_audit_log (
                    id             BIGSERIAL PRIMARY KEY,
                    source         VARCHAR(50)  NOT NULL DEFAULT 'WebUI',
                    action         VARCHAR(50)  NOT NULL,
                    target_tag_no  VARCHAR(50)  NOT NULL,
                    old_value      TEXT,
                    new_value      TEXT,
                    logged_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE INDEX IF NOT EXISTS idx_pid_audit_log_logged_at
                    ON pid_audit_log(logged_at DESC);
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS pid_graph_status (
                    task_id     TEXT         PRIMARY KEY,
                    progress    DOUBLE PRECISION NOT NULL DEFAULT 0,
                    status      VARCHAR(20)  NOT NULL DEFAULT 'Pending',
                    message     VARCHAR(500) NOT NULL DEFAULT '',
                    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE INDEX IF NOT EXISTS idx_pid_graph_status_updated_at
                    ON pid_graph_status(updated_at DESC);
                """);

            // pid_equipment 카테고리 컬럼 추가 (migration 없이)
            await _ctx.Database.ExecuteSqlRawAsync("""
                DO $$
                BEGIN
                    IF NOT EXISTS (
                        SELECT 1 FROM information_schema.columns
                        WHERE table_name='pid_equipment' AND column_name='category'
                    ) THEN
                        ALTER TABLE pid_equipment
                            ADD COLUMN category   TEXT,
                            ADD COLUMN role       TEXT,
                            ADD COLUMN from_tag   TEXT,
                            ADD COLUMN to_tag     TEXT;
                        CREATE INDEX IF NOT EXISTS idx_pid_equipment_category
                            ON pid_equipment(category);
                    END IF;
                END $$;
                """);

            // pid_equipment 계기 하위분류 컬럼 (field/system) — 기존 DB에도 안전 추가
            await _ctx.Database.ExecuteSqlRawAsync(
                "ALTER TABLE pid_equipment ADD COLUMN IF NOT EXISTS tag_class TEXT;");

            // ── tag_dcs 마이그레이션 (DCS 함수블록 vs 현장 계기 구별) ─────────────────
            // Step 1: pid_prefix_rules 컬럼 추가
            await _ctx.Database.ExecuteSqlRawAsync("""
                ALTER TABLE pid_prefix_rules
                  ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
                """);

            // Step 2: DCS prefix 마킹 (기본형 — compound형은 Step 4 StartsWith로 커버)
            await _ctx.Database.ExecuteSqlRawAsync("""
                UPDATE pid_prefix_rules
                SET tag_dcs = TRUE
                WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV');
                """);

            // Step 3+4: pid_equipment 컬럼 추가 + backfill (최초 1회만 실행)
            // ADD COLUMN IF NOT EXISTS는 멱등하지만 뒤따르는 UPDATE backfill이
            // 매번 재실행되면 수동 변경(FIT-9128 tag_dcs=false)을 덮어쓰는 문제가 있음.
            // → PL/pgSQL 블록으로 컬럼 존재 여부를 검사하여 최초 1회만 실행
            await _ctx.Database.ExecuteSqlRawAsync("""
                DO $$
                BEGIN
                    IF NOT EXISTS (
                        SELECT 1 FROM information_schema.columns
                        WHERE table_name='pid_equipment' AND column_name='tag_dcs'
                    ) THEN
                        ALTER TABLE pid_equipment
                          ADD COLUMN tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;

                        UPDATE pid_equipment pe
                        SET tag_dcs = TRUE
                        FROM pid_prefix_rules pr
                        WHERE pe.instrument_type LIKE (pr.prefix || '%')
                          AND pr.tag_dcs = TRUE;
                    END IF;
                END $$;
                """);
            // ─────────────────────────────────────────────────────────────────────────

            // pid_equipment 좌표/파일명 컬럼
            await _ctx.Database.ExecuteSqlRawAsync("""
                DO $$
                BEGIN
                    IF NOT EXISTS (
                        SELECT 1 FROM information_schema.columns
                        WHERE table_name='pid_equipment' AND column_name='pos_x'
                    ) THEN
                        ALTER TABLE pid_equipment
                            ADD COLUMN pos_x DOUBLE PRECISION,
                            ADD COLUMN pos_y DOUBLE PRECISION,
                            ADD COLUMN drawing_file VARCHAR(255);
                        CREATE INDEX IF NOT EXISTS idx_pid_equipment_drawing_file
                            ON pid_equipment(drawing_file);
                    END IF;
                END $$;
                """);

            // pid_prefix_rules 테이블
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS pid_prefix_rules (
                    id          SERIAL PRIMARY KEY,
                    prefix      TEXT NOT NULL UNIQUE,
                    category    TEXT NOT NULL,
                    description TEXT,
                    sort_order  INT NOT NULL DEFAULT 0,
                    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
                )
                """);

            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE INDEX IF NOT EXISTS idx_pid_prefix_rules_category
                    ON pid_prefix_rules(category);
                """);

            // pid_prefix_rules 시드 데이터 (SQL 주석 제거 — ExecuteSqlRawAsync에서 구문 오류 발생)
            await _ctx.Database.ExecuteSqlRawAsync("""
                INSERT INTO pid_prefix_rules (prefix, category, description, sort_order) VALUES
                    ('FT',  'instrument',        'Flow Transmitter',              10),
                    ('TT',  'instrument',        'Temperature Transmitter',       10),
                    ('PT',  'instrument',        'Pressure Transmitter',          10),
                    ('LT',  'instrument',        'Level Transmitter',             10),
                    ('FCV', 'instrument',        'Flow Control Valve',            10),
                    ('PCV', 'instrument',        'Pressure Control Valve',        10),
                    ('LCV', 'instrument',        'Level Control Valve',           10),
                    ('TCV', 'instrument',        'Temperature Control Valve',     10),
                    ('PSV', 'instrument',        'Pressure Safety Valve',         10),
                    ('XV',  'instrument',        'Shut-off Valve',                10),
                    ('FIC', 'instrument',        'Flow Indicator Controller',     10),
                    ('TIC', 'instrument',        'Temperature Indicator Controller', 10),
                    ('PIC', 'instrument',        'Pressure Indicator Controller', 10),
                    ('LIC', 'instrument',        'Level Indicator Controller',    10),
                    ('DP',  'instrument',        'Differential Pressure',         10),
                    ('FV',  'instrument',        'Flow Valve',                    10),
                    ('TV',  'instrument',        'Temperature Valve',             10),
                    ('PV',  'instrument',        'Pressure Valve',                10),
                    ('LV',  'instrument',        'Level Valve',                   10),
                    ('FG',  'instrument',        'Flow Gauge',                    10),
                    ('TG',  'instrument',        'Temperature Gauge',             10),
                    ('PG',  'instrument',        'Pressure Gauge',                10),
                    ('LG',  'instrument',        'Level Gauge',                   10),
                    ('FY',  'instrument',        'Flow Relay/Converter',          10),
                    ('TY',  'instrument',        'Temperature Relay/Converter',   10),
                    ('PY',  'instrument',        'Pressure Relay/Converter',      10),
                    ('LY',  'instrument',        'Level Relay/Converter',         10),
                    ('BV',  'instrument',        'Ball/Butterfly Valve',          10),
                    ('VIP', 'instrument',        'Vibration Probe',               10),
                    ('VIT', 'instrument',        'Vibration Transmitter',         10),
                    ('P-',  'power_equipment',   'Pump',                          20),
                    ('C-',  'power_equipment',   'Compressor',                    20),
                    ('K-',  'power_equipment',   'Agitator/Mixer',                20),
                    ('F-',  'power_equipment',   'Fan/Blower',                    20),
                    ('M-',  'power_equipment',   'Motor',                         20),
                    ('T-',  'storage_equipment', 'Tank',                          30),
                    ('D-',  'storage_equipment', 'Drum',                          30),
                    ('V-',  'storage_equipment', 'Vessel',                        30),
                    ('TK-', 'storage_equipment', 'Tank (alt)',                    30),
                    ('E-',  'process_equipment', 'Heat Exchanger',                40),
                    ('H-',  'process_equipment', 'Heater/Furnace',                40),
                    ('R-',  'process_equipment', 'Reactor',                       40),
                    ('COL-','process_equipment', 'Column',                        40),
                    ('S-',  'process_equipment', 'Separator',                     40),
                    ('FIL-','process_equipment', 'Filter',                        40),
                    ('SP-', 'process_equipment', 'Separator',                     40),
                    ('CWR-','utility_equipment', 'Cooling Water Return',          50),
                    ('CWS-','utility_equipment', 'Cooling Water Supply',          50),
                    ('IA-', 'utility_equipment', 'Instrument Air',                50),
                    ('ST-', 'utility_equipment', 'Steam',                         50),
                    ('WW-', 'utility_equipment', 'Waste Water',                   50),
                    ('PW-', 'utility_equipment', 'Process/Potable Water',         50),
                    ('SW-', 'utility_equipment', 'Service Water',                 50),
                    ('VG-', 'utility_equipment', 'Vent Gas',                      50),
                    ('CD-', 'utility_equipment', 'Condensate',                    50),
                    ('SAM-','utility_equipment', 'Sample Connection',             50),
                    ('CHR-','utility_equipment', 'Chemical Return',               50),
                    ('CHS-','utility_equipment', 'Chemical Supply',               50),
                    ('CH-', 'utility_equipment', 'Chemical',                      50),
                    ('NG-', 'utility_equipment', 'Natural Gas',                   50),
                    ('BD-', 'utility_equipment', 'Blowdown',                      50),
                    ('FL-', 'utility_equipment', 'Flare',                         50),
                    ('LO-', 'utility_equipment', 'Lube Oil',                      50)
                ON CONFLICT (prefix) DO NOTHING;
                """);

            // history 테이블은 수동으로 하이퍼테이블 생성 필요
            // CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
            // 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
            // CreateHypertableAsync() 메서드에서 선택적으로 설정 가능

            // ── Knowledge Base (RAG) 테이블 ──────────────────────────────────
            // 주의: KB DDL은 JSONB '{}' / TEXT[] '{}' / 시드 JSON 객체에 중괄호가 들어가는데
            // EF Core의 ExecuteSqlRawAsync는 SQL을 String.Format으로 통과시켜 {}를 placeholder로 오해함.
            // 이를 피하기 위해 raw NpgsqlCommand를 직접 사용한다.
            await using (var kbConn = new NpgsqlConnection(_ctx.Database.GetConnectionString()))
            {
                await kbConn.OpenAsync();

                async Task ExecKbAsync(string sql)
                {
                    await using var cmd = new NpgsqlCommand(sql, kbConn);
                    await cmd.ExecuteNonQueryAsync();
                }

                await ExecKbAsync("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"");

                await ExecKbAsync("""
                    CREATE TABLE IF NOT EXISTS kb_collections (
                        collection_key   TEXT PRIMARY KEY,
                        display_name     TEXT NOT NULL,
                        qdrant_name      TEXT NOT NULL UNIQUE,
                        chunking_policy  JSONB NOT NULL DEFAULT '{}'::jsonb,
                        description      TEXT,
                        is_active        BOOLEAN NOT NULL DEFAULT TRUE,
                        created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
                    )
                    """);

                await ExecKbAsync("""
                    CREATE TABLE IF NOT EXISTS kb_documents (
                        id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                        collection_key  TEXT NOT NULL REFERENCES kb_collections(collection_key),
                        title           TEXT NOT NULL,
                        original_path   TEXT NOT NULL,
                        file_sha256     TEXT NOT NULL,
                        file_size       BIGINT,
                        mime_type       TEXT,
                        tags            TEXT[] NOT NULL DEFAULT '{}',
                        status          TEXT NOT NULL DEFAULT 'pending',
                        chunk_count     INTEGER NOT NULL DEFAULT 0,
                        error_message   TEXT,
                        uploaded_by     TEXT,
                        uploaded_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                        indexed_at      TIMESTAMPTZ,
                        disabled_at     TIMESTAMPTZ
                    )
                    """);

                await ExecKbAsync("""
                    CREATE INDEX IF NOT EXISTS idx_kb_docs_coll_status
                        ON kb_documents(collection_key, status, uploaded_at DESC);
                    CREATE INDEX IF NOT EXISTS idx_kb_docs_title
                        ON kb_documents(title);
                    """);

                await ExecKbAsync("""
                    CREATE TABLE IF NOT EXISTS kb_ingest_jobs (
                        id             BIGSERIAL PRIMARY KEY,
                        doc_id         UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
                        stage          TEXT NOT NULL,
                        attempts       INTEGER NOT NULL DEFAULT 0,
                        last_error     TEXT,
                        enqueued_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                        started_at     TIMESTAMPTZ,
                        finished_at    TIMESTAMPTZ
                    )
                    """);

                await ExecKbAsync("""
                    CREATE INDEX IF NOT EXISTS idx_kb_jobs_pending
                        ON kb_ingest_jobs(stage, finished_at)
                        WHERE finished_at IS NULL;
                    """);

                await ExecKbAsync("""
                    CREATE TABLE IF NOT EXISTS kb_admin_credential (
                        id              INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
                        password_hash   TEXT NOT NULL,
                        salt            TEXT NOT NULL,
                        algorithm       TEXT NOT NULL DEFAULT 'argon2id',
                        updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
                    )
                    """);

                await ExecKbAsync("""
                    CREATE TABLE IF NOT EXISTS kb_admin_sessions (
                        token       TEXT PRIMARY KEY,
                        issued_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                        expires_at  TIMESTAMPTZ NOT NULL,
                        client_ip   TEXT
                    )
                    """);

                await ExecKbAsync("""
                    CREATE INDEX IF NOT EXISTS idx_kb_sessions_expires
                        ON kb_admin_sessions(expires_at);
                    """);

                // ── 시드: kb_collections 5종 ─────────────────────────────────
                await ExecKbAsync("""
                    INSERT INTO kb_collections (collection_key, display_name, qdrant_name, chunking_policy, description)
                    VALUES
                        ('system_instrument', '시스템 & 계기 정보', 'kb_system_instrument',
                         '{"pdf":"section+table","xlsx":"row+sheet","docx":"heading"}'::jsonb,
                         '계기 datasheet, P&ID, 사양서, 노드맵'),
                        ('plant_operation', '공장 운전 정보', 'kb_plant_operation',
                         '{"xlsx":"row","docx":"heading","md":"heading"}'::jsonb,
                         '재고, 생산현황, 고장이력, 교대일지'),
                        ('procedure', '절차서/SOP', 'kb_procedure',
                         '{"docx":"heading","md":"heading","pdf":"section"}'::jsonb,
                         'SOP, 정비 절차, 알람 대응 매뉴얼'),
                        ('report', '보고서', 'kb_report',
                         '{"pdf":"section+table","docx":"heading"}'::jsonb,
                         '일/주/월 보고, 사고보고, 분석보고'),
                        ('vendor_doc', '벤더 자료', 'kb_vendor_doc',
                         '{"pdf":"section+table","docx":"heading"}'::jsonb,
                         '카탈로그, 매뉴얼, 인증서')
                    ON CONFLICT (collection_key) DO NOTHING
                    """);
            }

            // ── 운전판정 교차검증(Corroboration) — 펌프 RUN을 유량(kg/hr)·진공압(torr)으로 검증 ──
            // 펌프 enum(RUN)만으론 deadhead·센서오류·frozen 데이터 등 '허위 운전'을 못 거른다.
            //   신선도 게이트: realtime_table.timestamp가 NOW()-120s 이내일 때만 값 신뢰(아니면 STALE=판정보류)
            //   매핑: 토폴로지(FT.from_tag=펌프 → 같은 번호 FICQ 컨트롤러, 1:N) + 수동 예외 테이블
            // (pid_equipment·from_tag/category 컬럼이 모두 보장된 이 지점 이후에 생성)

            // 수동 매핑 — 토폴로지로 못 잡는 예외 (펌프-FT 사이 중간설비, VP↔진공압). 운전원 편집 가능.
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS pump_corroboration_manual (
                    id             BIGSERIAL PRIMARY KEY,
                    pump_base_tag  TEXT NOT NULL,
                    signal_tag     TEXT NOT NULL,
                    signal_kind    TEXT NOT NULL CHECK (signal_kind IN ('flow','vacuum')),
                    unit           TEXT,
                    note           TEXT,
                    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    UNIQUE (pump_base_tag, signal_tag)
                )
                """);

            // 수동 매핑 시드 (P6 알려진 예외). ON CONFLICT DO NOTHING → 부팅 시 운전원 수정 보존.
            await _ctx.Database.ExecuteSqlRawAsync("""
                INSERT INTO pump_corroboration_manual (pump_base_tag, signal_tag, signal_kind, unit, note) VALUES
                    ('p-6102',  'ficq-6101.pv', 'flow',   'kg/hr', '원료 투입(P-6102→필터→FT-6101), 토폴로지 2-hop'),
                    ('vp-6117', 'pica-6111.pv', 'vacuum', 'torr',  'C-6111 진공 (PT-6111 압력제어 PV)'),
                    ('vp-6217', 'pica-6211.pv', 'vacuum', 'torr',  'C-6211 진공')
                ON CONFLICT (pump_base_tag, signal_tag) DO NOTHING
                """);

            // 펌프→신호 매핑 뷰 (토폴로지 유량 1:N + 수동)
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_pump_signal_map");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_pump_signal_map AS
                SELECT lower(ft.from_tag)                                AS pump_base_tag,
                       'ficq-' || split_part(ft.tag_no, '-', 2) || '.pv' AS signal_tag,
                       'flow'::text  AS signal_kind,
                       'kg/hr'::text AS unit,
                       'topology'::text AS mapping_source
                FROM pid_equipment ft
                WHERE ft.category = 'instrument'
                  AND ft.tag_no LIKE 'FT-%'
                  AND lower(ft.from_tag) LIKE 'p-%'
                  AND ft.from_tag NOT LIKE '%,%'
                UNION ALL
                SELECT pump_base_tag, signal_tag, signal_kind, unit, 'manual'
                FROM pump_corroboration_manual
                """);

            // 계기 단위/레인지 — tag_metadata(EAV) 피벗·타입캐스트 (별도 테이블 없이 재사용).
            // corroboration FS 5% 임계 + 향후 SP/제어 계산용. 단위/레인지는 메타갱신 시 자동 적재됨.
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_instrument_range");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_instrument_range AS
                SELECT base_tag,
                    max(value) FILTER (WHERE attribute='units')                AS unit,
                    max(CASE WHEN attribute='eulo' AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
                             THEN value::double precision END)                 AS eu_lo,
                    max(CASE WHEN attribute='euhi' AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
                             THEN value::double precision END)                 AS eu_hi
                FROM tag_metadata
                WHERE attribute IN ('units','eulo','euhi')
                GROUP BY base_tag
                """);

            // ── 트렌드 워크스페이스 ────────────────────────────────────────────────
            // trend_group: 멤버는 JSONB([{tag,color,axis}]). 운전원 그룹 영속(공유).
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS trend_group (
                    id          SERIAL PRIMARY KEY,
                    name        TEXT        NOT NULL,
                    description TEXT,
                    members     JSONB       NOT NULL DEFAULT '[]',
                    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
                )
                """);

            // v_analog_points: 숫자 livevalue = 아날로그 (enum/디지털 '{n | LABEL | }' 자동 제외).
            // v_instrument_range(단위/레인지) + v_tag_summary(설명/구역) 조인으로 그룹빌더 표 정보 제공.
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_analog_points");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_analog_points AS
                SELECT rt.tagname,
                       split_part(rt.tagname, '.', 1)  AS base_tag,
                       rt.livevalue::double precision   AS value,
                       rt.timestamp,
                       ir.unit,
                       ir.eu_lo,
                       ir.eu_hi,
                       ts.description,
                       ts.area
                FROM realtime_table rt
                LEFT JOIN v_instrument_range ir ON ir.base_tag = split_part(rt.tagname, '.', 1)
                LEFT JOIN v_tag_summary     ts ON ts.base_tag = split_part(rt.tagname, '.', 1)
                WHERE rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
                ORDER BY rt.tagname
                """);

            // 펌프별 교차검증 상세 — 신선도 게이트(120s) + STALE + 유량(FS 5%, fallback 0.5 kg/hr)·진공(300 torr)
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state_corroborated");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_plant_running_state_corroborated AS
                WITH pump_base AS (
                    SELECT trim(split_part(area, '|', 2)) AS area_code,
                           area AS area_raw, base_tag, pv, description, sub_area
                    FROM v_tag_summary
                    WHERE area IS NOT NULL
                      AND (base_tag LIKE 'p-%' OR base_tag LIKE 'vp-%')
                      AND pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
                ),
                sig_eval AS (
                    SELECT m.pump_base_tag, m.signal_kind,
                           (rt.tagname IS NOT NULL) AS has_row,
                           (rt.tagname IS NOT NULL
                            AND (NOW() - rt.timestamp) < interval '120 seconds') AS fresh,
                           CASE WHEN rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
                                THEN rt.livevalue::double precision END AS val,
                           -- 유량 '흐름': 신선 + deadhead floor 초과. 임계 = FS 5%지만 [1~5 kg/hr]로 캡
                           --  (계기가 운전유량 대비 과대사이징 — 예: FS 2000인데 운전 ~11 → raw 5%FS=100은 정상을 SUSPICIOUS로 오판)
                           (m.signal_kind='flow'
                            AND rt.tagname IS NOT NULL
                            AND (NOW() - rt.timestamp) < interval '120 seconds'
                            AND rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
                            AND rt.livevalue::double precision > GREATEST(1.0, LEAST(COALESCE(ir.eu_hi, 100) * 0.05, 5.0))) AS flow_ok,
                           -- 진공 '유지': 신선 + 깊은 진공 (torr; 레인지 기반은 향후, 현재 절대 300 fallback)
                           (m.signal_kind='vacuum'
                            AND rt.tagname IS NOT NULL
                            AND (NOW() - rt.timestamp) < interval '120 seconds'
                            AND rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
                            AND rt.livevalue::double precision < 300) AS vac_ok
                    FROM v_pump_signal_map m
                    LEFT JOIN realtime_table rt    ON rt.tagname = m.signal_tag
                    LEFT JOIN v_instrument_range ir ON ir.base_tag = split_part(m.signal_tag, '.', 1)
                ),
                pump_sig AS (
                    SELECT pump_base_tag,
                        count(*) FILTER (WHERE signal_kind='flow')               AS flow_mapped,
                        count(*) FILTER (WHERE signal_kind='flow' AND has_row)   AS flow_have,
                        count(*) FILTER (WHERE signal_kind='flow' AND fresh)     AS flow_fresh,
                        bool_or(flow_ok)                                         AS any_flow_ok,
                        max(val) FILTER (WHERE signal_kind='flow' AND fresh)     AS flow_max,
                        count(*) FILTER (WHERE signal_kind='vacuum')             AS vac_mapped,
                        count(*) FILTER (WHERE signal_kind='vacuum' AND has_row) AS vac_have,
                        count(*) FILTER (WHERE signal_kind='vacuum' AND fresh)   AS vac_fresh,
                        bool_or(vac_ok)                                          AS any_vac_ok,
                        min(val) FILTER (WHERE signal_kind='vacuum' AND fresh)   AS vac_min
                    FROM sig_eval GROUP BY pump_base_tag
                )
                SELECT
                    b.area_code, b.area_raw, b.base_tag, b.pv AS raw_pv, b.description, b.sub_area,
                    ps.flow_max AS flow_kg_hr, ps.vac_min AS vacuum_torr,
                    CASE
                        WHEN b.pv ~ '\|\s*[LR]-TRIP\s*\|'       THEN 'TRIPPED'
                        WHEN b.pv ~ '\|\s*(L-STOP|R-STOP)\s*\|' THEN 'STOPPED'
                        WHEN b.base_tag LIKE 'vp-%' THEN
                            CASE
                                WHEN COALESCE(ps.vac_mapped,0)=0 OR COALESCE(ps.vac_have,0)=0 THEN 'INDETERMINATE_RUNNING'
                                WHEN ps.vac_fresh=0  THEN 'STALE'
                                WHEN ps.any_vac_ok   THEN 'CONFIRMED_RUNNING'
                                ELSE 'SUSPICIOUS_RUNNING'
                            END
                        ELSE
                            CASE
                                WHEN COALESCE(ps.flow_mapped,0)=0 OR COALESCE(ps.flow_have,0)=0 THEN 'INDETERMINATE_RUNNING'
                                WHEN ps.flow_fresh=0 THEN 'STALE'
                                WHEN ps.any_flow_ok  THEN 'CONFIRMED_RUNNING'
                                ELSE 'SUSPICIOUS_RUNNING'
                            END
                    END AS corroborated_status
                FROM pump_base b
                LEFT JOIN pump_sig ps ON ps.pump_base_tag = b.base_tag
                WHERE b.area_code IS NOT NULL AND b.area_code <> ''
                """);

            // area별 집계 — overall은 CONFIRMED 기준 RUNNING, suspicious/stale는 부가 카운트(전체 오염 금지)
            await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state_agg");
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE VIEW v_plant_running_state_agg AS
                SELECT
                    area_code, MAX(area_raw) AS area_raw, COUNT(*) AS total_pumps,
                    COUNT(*) FILTER (WHERE corroborated_status='CONFIRMED_RUNNING')     AS confirmed_running,
                    COUNT(*) FILTER (WHERE corroborated_status='SUSPICIOUS_RUNNING')    AS suspicious_running,
                    COUNT(*) FILTER (WHERE corroborated_status='STALE')                 AS stale_running,
                    COUNT(*) FILTER (WHERE corroborated_status='INDETERMINATE_RUNNING') AS indeterminate_running,
                    COUNT(*) FILTER (WHERE corroborated_status='TRIPPED')               AS tripped_pumps,
                    COUNT(*) FILTER (WHERE corroborated_status='STOPPED')               AS stopped_pumps,
                    CASE
                        WHEN COUNT(*) FILTER (WHERE corroborated_status IN
                             ('CONFIRMED_RUNNING','SUSPICIOUS_RUNNING','STALE','INDETERMINATE_RUNNING')) > 0 THEN 'RUNNING'
                        WHEN COUNT(*) FILTER (WHERE corroborated_status='TRIPPED') > 0 THEN 'TRIPPED'
                        ELSE 'STOPPED'
                    END AS status,
                    array_agg(base_tag) FILTER (WHERE corroborated_status='CONFIRMED_RUNNING')  AS confirmed_tags,
                    array_agg(base_tag) FILTER (WHERE corroborated_status='SUSPICIOUS_RUNNING') AS suspicious_tags,
                    array_agg(base_tag) FILTER (WHERE corroborated_status='STALE')              AS stale_tags,
                    array_agg(DISTINCT sub_area) FILTER (WHERE sub_area IS NOT NULL)            AS sub_areas
                FROM v_plant_running_state_corroborated
                WHERE area_code IS NOT NULL AND area_code <> ''
                GROUP BY area_code ORDER BY area_code
                """);

            // ── Feedforward advisory engine config tables ─────────────────────
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS ff_column_config (
                    id          SERIAL PRIMARY KEY,
                    name        TEXT NOT NULL,
                    enabled     BOOLEAN NOT NULL DEFAULT FALSE,
                    feed_tag    TEXT NOT NULL,
                    pressure_tag TEXT,
                    level_tags  TEXT,
                    scan_sec    DOUBLE PRECISION NOT NULL DEFAULT 2,
                    feed_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 300,
                    feed_move_thr_per_min DOUBLE PRECISION NOT NULL DEFAULT 0,
                    press_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 60,
                    pressure_band DOUBLE PRECISION NOT NULL DEFAULT 1e9,
                    settle_sec  DOUBLE PRECISION NOT NULL DEFAULT 0,
                    stale_sec   DOUBLE PRECISION NOT NULL DEFAULT 120,
                    product_key TEXT NOT NULL DEFAULT 'P',
                    advisory_only BOOLEAN NOT NULL DEFAULT TRUE
                );
                """);
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS ff_stream_config (
                    id          SERIAL PRIMARY KEY,
                    column_id   INTEGER NOT NULL REFERENCES ff_column_config(id) ON DELETE CASCADE,
                    key         TEXT NOT NULL,
                    flow_tag    TEXT NOT NULL,
                    role        TEXT NOT NULL,
                    target_coeff DOUBLE PRECISION NOT NULL DEFAULT 0,
                    theta_up_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
                    theta_dn_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
                    tau_sec     DOUBLE PRECISION NOT NULL DEFAULT 0,
                    sp_min      DOUBLE PRECISION NOT NULL DEFAULT 0,
                    sp_max      DOUBLE PRECISION NOT NULL DEFAULT 1e9,
                    rate_up_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
                    rate_dn_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
                    reflux_from_product BOOLEAN NOT NULL DEFAULT FALSE,
                    grade       TEXT NOT NULL DEFAULT 'A',
                    level_tag   TEXT
                );
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS is_reflux    BOOLEAN NOT NULL DEFAULT FALSE;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS recovery_sp  DOUBLE PRECISION;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS sp_node_id   TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_tags    TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS sensitive_tray_tag TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS dtdp         DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS p_ref        DOUBLE PRECISION;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS steam_op_tag TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS theta_auto_tune BOOLEAN NOT NULL DEFAULT FALSE;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS bias_ma_window_sec DOUBLE PRECISION NOT NULL DEFAULT 21600;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_enabled  BOOLEAN NOT NULL DEFAULT FALSE;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_auto_arm BOOLEAN NOT NULL DEFAULT FALSE;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_frac DOUBLE PRECISION NOT NULL DEFAULT 0.10;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_sec  DOUBLE PRECISION NOT NULL DEFAULT 600;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_settle_sec    DOUBLE PRECISION NOT NULL DEFAULT 1800;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS return_ramp_sec        DOUBLE PRECISION NOT NULL DEFAULT 600;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp       DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag            TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit    DOUBLE PRECISION NOT NULL DEFAULT 1e9;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_high_limit        DOUBLE PRECISION NOT NULL DEFAULT 1e9;
                -- migration: missing columns from the original CREATE TABLE (schema was json-based)
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_tag              TEXT NOT NULL DEFAULT '';
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS pressure_tag          TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS level_tags            TEXT;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_filter_tau_sec   DOUBLE PRECISION NOT NULL DEFAULT 300;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_move_thr_per_min DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS press_filter_tau_sec   DOUBLE PRECISION NOT NULL DEFAULT 60;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS pressure_band         DOUBLE PRECISION NOT NULL DEFAULT 1e9;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS settle_sec            DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS stale_sec             DOUBLE PRECISION NOT NULL DEFAULT 120;
                ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS product_key           TEXT NOT NULL DEFAULT 'P';
                DO $$
                BEGIN
                    IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='hc900' AND table_name='ff_stream_config' AND column_name='stream_key')
                       AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='hc900' AND table_name='ff_stream_config' AND column_name='key') THEN
                        ALTER TABLE hc900.ff_stream_config RENAME COLUMN stream_key TO key;
                    END IF;
                END $$;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS flow_tag              TEXT NOT NULL DEFAULT '';
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS role                  TEXT NOT NULL DEFAULT 'Monitor';
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS target_coeff          DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS theta_up_sec          DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS theta_dn_sec          DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS tau_sec               DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS sp_min                DOUBLE PRECISION NOT NULL DEFAULT 0;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS sp_max                DOUBLE PRECISION NOT NULL DEFAULT 1e9;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS rate_up_per_min       DOUBLE PRECISION NOT NULL DEFAULT 1e9;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS rate_dn_per_min       DOUBLE PRECISION NOT NULL DEFAULT 1e9;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS reflux_from_product   BOOLEAN NOT NULL DEFAULT FALSE;
                ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS grade                 TEXT NOT NULL DEFAULT 'A';
                """);

            // ── FF operator action audit log ────────────────────────────────
            await _ctx.Database.ExecuteSqlRawAsync("""
                CREATE TABLE IF NOT EXISTS ff_operator_action (
                    id               BIGSERIAL PRIMARY KEY,
                    column_id        INTEGER NOT NULL,
                    stream_key       VARCHAR(50),
                    action_type      VARCHAR(50) NOT NULL,
                    sp_value         DOUBLE PRECISION,
                    node_id          VARCHAR(255),
                    result           VARCHAR(50) NOT NULL,
                    writeguard_reason TEXT,
                    operator_name    VARCHAR(100),
                    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
                );
                CREATE INDEX IF NOT EXISTS idx_ff_op_action_column ON ff_operator_action(column_id);
                CREATE INDEX IF NOT EXISTS idx_ff_op_action_created ON ff_operator_action(created_at DESC);
                """);

            _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
            return true;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[ExperionDb] 초기화 실패");
            return false;
        }
    }

    /// <summary>
    /// history 테이블을 TimeScaleDB 하이퍼테이블로 생성/마이그레이션
    /// </summary>
    /// <param name="hypertableName">생성할 하이퍼테이블 이름 (예: history_hypertable)</param>
    /// <param name="tableName">기존 테이블 이름 (예: history_table, null이면 새 테이블 생성)</param>
    /// <param name="timeColumn">시간 컬럼 이름</param>
    /// <param name="interval">청크 간격 (예: '1 day')</param>
    /// <returns>하이퍼테이블 생성 여부 (true: 생성됨/기존 있음, false: 실패/스킵)</returns>
    private async Task<bool> CreateHistoryHypertableIfNotExistsAsync(
        string hypertableName, string? tableName, string timeColumn, string interval)
    {
        try
        {
            // SQL injection 방지를 위해 식별자 검증
            if (!IsValidSqlIdentifier(hypertableName))
            {
                _logger.LogWarning("[ExperionDb] 하이퍼테이블 이름 '{HypertableName}'이(가) 유효하지 않음", hypertableName);
                return false;
            }

            // 1️⃣ 하이퍼테이블이 이미 존재하면 스킵
            await using var conn = new NpgsqlConnection(_ctx.Database.GetConnectionString());
            await conn.OpenAsync();
            
            await using var cmd1 = new NpgsqlCommand(
                $"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{hypertableName.Replace("'", "''")}' LIMIT 1", conn);
            var result = await cmd1.ExecuteScalarAsync();
            if (result != null)
            {
                _logger.LogInformation("[ExperionDb] 하이퍼테이블 '{HypertableName}' 이미 존재함", hypertableName);
                return true;
            }

            // 2️⃣ 기존 테이블 이름이 제공되면 검증
            if (tableName != null && !IsValidSqlIdentifier(tableName))
            {
                _logger.LogWarning("[ExperionDb] 테이블 이름 '{TableName}'이(가) 유효하지 않음", tableName);
                return false;
            }

            // 3️⃣ 기존 테이블이 존재하면 → 하이퍼테이블로 마이그레이션
            if (tableName != null)
            {
                await using var cmd2 = new NpgsqlCommand(
                    "SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = @tableName LIMIT 1", conn);
                cmd2.Parameters.AddWithValue("@tableName", tableName.Replace("'", "''"));
                result = await cmd2.ExecuteScalarAsync();
                if (result != null)
                {
                    // 데이터가 있는 경우 migrate_data => true 옵션 필요
                    await using var cmd3 = new NpgsqlCommand(
                        "SELECT create_hypertable(@tableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true, migrate_data => true)", conn);
                    cmd3.Parameters.AddWithValue("@tableName", tableName);
                    cmd3.Parameters.AddWithValue("@timeColumn", timeColumn);
                    cmd3.Parameters.AddWithValue("@interval", interval);
                    await cmd3.ExecuteNonQueryAsync();
                    _logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName);
                    return true;
                }
            }

            // 4️⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성
            // TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함
            await using var cmd4 = new NpgsqlCommand(
                @"
                CREATE TABLE IF NOT EXISTS @hypertableName (
                    id          SERIAL,
                    tagname     TEXT        NOT NULL,
                    node_id     TEXT,
                    value       TEXT,
                    livevalue   TEXT,
                    @timeColumn TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    PRIMARY KEY (id, @timeColumn)
                )", conn);
            cmd4.Parameters.AddWithValue("@hypertableName", hypertableName);
            cmd4.Parameters.AddWithValue("@timeColumn", timeColumn);
            await cmd4.ExecuteNonQueryAsync();
            
            await using var cmd5 = new NpgsqlCommand(
                "SELECT create_hypertable(@hypertableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true)", conn);
            cmd5.Parameters.AddWithValue("@hypertableName", hypertableName);
            cmd5.Parameters.AddWithValue("@timeColumn", timeColumn);
            cmd5.Parameters.AddWithValue("@interval", interval);
            await cmd5.ExecuteNonQueryAsync();
            _logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName);
            return true;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "[ExperionDb] 하이퍼테이블 '{HypertableName}' 생성 실패 (기본 테이블 사용 계속)", hypertableName);
            return false;
        }
    }

    public async Task<int> ClearRecordsAsync()
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    public async Task<int> BuildMasterFromRawAsync(bool truncate = false)
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    public Task<int> GetTotalCountAsync()
        => Task.FromResult(0);

    public async Task<IEnumerable<string>> GetNameListAsync()
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    public async Task<NodeMapStats> GetMasterStatsAsync()
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    // ── RealtimeTable ─────────────────────────────────────────────────────────

    private static string ExtractTagName(string nodeId)
    {
        var idx = nodeId.LastIndexOf(':');
        return idx >= 0 ? nodeId[(idx + 1)..] : nodeId;
    }

    public async Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups)
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(
        IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups)
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
    {
        var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
        if (nodeIds.Count == 0) return 0;

        await _ctx.Database.ExecuteSqlRawAsync(
            "TRUNCATE TABLE realtime_table RESTART IDENTITY");

        var points = nodeIds.Select(nodeId => new RealtimePoint
        {
            TagName   = ExtractTagName(nodeId),
            NodeId    = nodeId,
            LiveValue = null,
            Timestamp = DateTime.UtcNow
        }).ToList();

        await _ctx.RealtimePoints.AddRangeAsync(points);
        await _ctx.SaveChangesAsync();

        _logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
        return points.Count;
    }

    public async Task<int> AppendPointsAsync(IEnumerable<string> nodeIds)
    {
        var nodeIdsList = nodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
        if (nodeIdsList.Count == 0) return 0;

        var existingNodeIds = await _ctx.RealtimePoints
            .Where(p => nodeIdsList.Contains(p.NodeId))
            .Select(p => p.NodeId)
            .ToListAsync();

        var newPoints = nodeIdsList
            .Where(n => !existingNodeIds.Contains(n))
            .Select(nodeId => new RealtimePoint
            {
                TagName   = ExtractTagName(nodeId),
                NodeId    = nodeId,
                LiveValue = null,
                Timestamp = DateTime.UtcNow
            }).ToList();

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

        await _ctx.RealtimePoints.AddRangeAsync(newPoints);
        await _ctx.SaveChangesAsync();

        _logger.LogInformation("[ExperionDb] realtime_table 추가: {Count}건 (중복 제외)", newPoints.Count);
        return newPoints.Count;
    }

    public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
    {
        try
        {
            var points = await _ctx.RealtimePoints
                .OrderBy(x => x.TagName)
                .ToListAsync();
            
            _logger.LogInformation("[Realtime] 포인트 조회 완료: {Count}건", points.Count);
            return points;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[Realtime] 포인트 조회 실패");
            return Enumerable.Empty<RealtimePoint>();
        }
    }

    public async Task<RealtimePoint> AddRealtimePointAsync(string nodeId)
    {
        var existing = await _ctx.RealtimePoints.FirstOrDefaultAsync(x => x.NodeId == nodeId);
        if (existing != null) return existing;

        var point = new RealtimePoint
        {
            TagName   = ExtractTagName(nodeId),
            NodeId    = nodeId,
            LiveValue = null,
            Timestamp = DateTime.UtcNow
        };
        _ctx.RealtimePoints.Add(point);
        await _ctx.SaveChangesAsync();
        _logger.LogInformation("[ExperionDb] 수동 추가: {NodeId}", nodeId);
        return point;
    }

    public async Task<PointDeleteResult> DeleteRealtimePointAsync(int id, bool purgeHistory = false)
    {
        var point = await _ctx.RealtimePoints.FindAsync(id);
        if (point == null) return new PointDeleteResult { Deleted = false };

        var tagName = point.TagName;                                   // 예: "FICQ-6101.pv"
        var baseTag = tagName.Contains('.') ? tagName[..tagName.IndexOf('.')] : tagName;

        _ctx.RealtimePoints.Remove(point);
        await _ctx.SaveChangesAsync();
        // (pid_equipment.experion_tag_id 는 FK ON DELETE SET NULL 로 자동 처리)

        var result = new PointDeleteResult { Deleted = true, BaseTag = baseTag };

        // 같은 base_tag 의 잔여 realtime 행이 0개면 → 고아 메타데이터(desc/area/sub_area) 정리.
        // v_tag_summary 는 뷰라 자동 반영되므로 별도 처리 불필요.
        var remaining = await _ctx.RealtimePoints
            .CountAsync(p => p.TagName == baseTag || p.TagName.StartsWith(baseTag + "."));
        if (remaining == 0)
        {
            var metaRemoved = await _ctx.Database.ExecuteSqlRawAsync(
                "DELETE FROM tag_metadata WHERE base_tag = {0}", baseTag);
            result.MetadataPurged = metaRemoved > 0;
        }

        // history_table 이력 삭제는 복구 불가 → 명시적 opt-in 일 때만.
        if (purgeHistory)
        {
            result.HistoryRowsDeleted = await _ctx.Database.ExecuteSqlRawAsync(
                "DELETE FROM history_table WHERE tagname = {0}", tagName);
        }

        return result;
    }

    public async Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp)
    {
        return await _ctx.RealtimePoints
            .Where(x => x.NodeId == nodeId)
            .ExecuteUpdateAsync(s => s
                .SetProperty(x => x.LiveValue, value)
                .SetProperty(x => x.Timestamp, timestamp));
    }

    public async Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates)
    {
        var list = updates.ToList();
        if (list.Count == 0) return 0;

        // 단일 DbContext(단일 DB 커넥션)에서 순차 업데이트 — 커넥션 폭발 방지
        int total = 0;
        foreach (var u in list)
        {
            total += await _ctx.RealtimePoints
                .Where(x => x.NodeId == u.NodeId)
                .ExecuteUpdateAsync(s => s
                    .SetProperty(x => x.LiveValue, u.Value)
                    .SetProperty(x => x.Timestamp, u.Timestamp));
        }
        return total;
    }

    // ── HistoryTable ──────────────────────────────────────────────────────────

    public async Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false)
    {
        var now = DateTime.UtcNow;
        var query = _ctx.RealtimePoints.AsQueryable();

        if (!string.IsNullOrEmpty(controllerId))
            query = query.Where(p => p.ControllerId == controllerId);

        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,
            ControllerId = p.ControllerId
        }).ToList();

        await _ctx.HistoryRecords.AddRangeAsync(rows);
        var saved = await _ctx.SaveChangesAsync();
        _logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now);
        return saved;
    }

    public async Task<IEnumerable<string>> GetTagNamesAsync()
        => await _ctx.RealtimePoints.Select(x => x.TagName).OrderBy(x => x).ToListAsync();

    public async Task<HistoryQueryResult> QueryHistoryAsync(
        IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit)
    {
        var tags = tagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();

        var q = _ctx.HistoryRecords.AsQueryable();
        if (tags.Count > 0) q = q.Where(x => tags.Contains(x.TagName));
        if (from.HasValue)  q = q.Where(x => x.RecordedAt >= from.Value);
        if (to.HasValue)    q = q.Where(x => x.RecordedAt <= to.Value);

        var rows = await q.OrderBy(x => x.RecordedAt)
                          .Take(Math.Min(limit, 5000))
                          .ToListAsync();

        // recorded_at 기준으로 행을 묶어서 pivot 구성
        var grouped = rows
            .GroupBy(x => x.RecordedAt)
            .Select(g => new HistoryRow(
                g.Key,
                g.GroupBy(r => r.TagName)
                 .ToDictionary(tg => tg.Key, tg => tg.Last().Value)
                    as IReadOnlyDictionary<string, string?>))
            .ToList();

        var usedTags = tags.Count > 0
            ? tags
            : rows.Select(x => x.TagName).Distinct().OrderBy(x => x).ToList();

        return new HistoryQueryResult(usedTags, grouped);
    }

    // ── History Interval Query ──────────────────────────────────────────────────

    public async Task<HistoryIntervalQueryResult> QueryHistoryWithIntervalAsync(
        HistoryIntervalQueryRequest request)
    {
        const int BaseIntervalSeconds = 60; // history_table 기본 저장 간격 (60초)

        var tags = request.TagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();
        var limit = Math.Min(request.Limit, 5000);

        // SQL 인젝션 방지를 위해 식별자 검증
        if (!IsValidSqlIdentifier("history_table"))
        {
            throw new ArgumentException("Invalid table name");
        }

        // 간격 파싱 (예: "1 minute", "5 minutes", "1 hour", "10 seconds")
        var intervalStr = ParseIntervalToPostgresInterval(request.Interval);

        await _ctx.Database.GetDbConnection().OpenAsync();

        try
        {
            // TimeScaleDB time_bucket 함수를 사용한 간격별 집계 쿼리
            var sql = BuildHistoryIntervalQuerySql(tags, request.From, request.To, intervalStr, limit);

            using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection);
            
            // 파라미터 바인딩 (Npgsql는 @paramName 형식 지원)
            if (tags.Count > 0)
            {
                var tagParam = cmd.CreateParameter();
                tagParam.ParameterName = "tagNames";
                tagParam.Value = tags.ToArray();
                cmd.Parameters.Add(tagParam);
            }
            if (request.From.HasValue)
            {
                var fromParam = cmd.CreateParameter();
                fromParam.ParameterName = "fromTime";
                fromParam.Value = request.From.Value.ToUniversalTime();  // 타임존 포맷 무관 UTC 정규화('+00:00'/Local 입력 9h 시프트 방지)
                cmd.Parameters.Add(fromParam);
            }
            if (request.To.HasValue)
            {
                var toParam = cmd.CreateParameter();
                toParam.ParameterName = "toTime";
                toParam.Value = request.To.Value.ToUniversalTime();  // 타임존 포맷 무관 UTC 정규화
                cmd.Parameters.Add(toParam);
            }

            using var reader = await cmd.ExecuteReaderAsync();
            var rows = new List<HistoryIntervalRow>();
            var allTagNames = new HashSet<string>();

            while (await reader.ReadAsync())
            {
                var timeBucket = reader.GetDateTime(0);
                var values = new Dictionary<string, string?>();

                for (int i = 1; i < reader.FieldCount; i++)
                {
                    var tagName = reader.GetName(i);
                    allTagNames.Add(tagName);
                    values[tagName] = reader.IsDBNull(i) ? null : reader.GetString(i);
                }

                rows.Add(new HistoryIntervalRow(timeBucket, values));
            }

            var usedTags = tags.Count > 0
                ? tags
                : allTagNames.OrderBy(x => x).ToList();

            return new HistoryIntervalQueryResult(
                usedTags,
                rows,
                BaseIntervalSeconds,
                request.Interval);
        }
        finally
        {
            await _ctx.Database.GetDbConnection().CloseAsync();
        }
    }

    /// <summary>
    /// 사용자 지정 간격으로 history 이력 조회용 SQL 생성
    /// TimeScaleDB time_bucket 함수를 사용하여 간격별 집계 수행
    /// </summary>
    private string BuildHistoryIntervalQuerySql(
        List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
    {
        var selectParts = new List<string>();
        selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket");

        // 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE)
        foreach (var tag in tags)
        {
            var safeTag = tag.Replace("'", "''");
            selectParts.Add(
                $"MAX(CASE WHEN tagname = '{safeTag}' THEN value END) AS \"{tag}\"");
        }

        var sql = $"SELECT {string.Join(", ", selectParts)} FROM history_table WHERE 1=1";

        if (tags.Count > 0)
        {
            sql += $" AND tagname = ANY(ARRAY[{string.Join(", ", tags.Select(t => $"'{t.Replace("'", "''")}'"))}])";
        }

        if (from.HasValue)
        {
            sql += $" AND recorded_at >= @fromTime";
        }

        if (to.HasValue)
        {
            sql += $" AND recorded_at <= @toTime";
        }

        sql += $" GROUP BY time_bucket ORDER BY time_bucket LIMIT {limit}";

        return sql;
    }

    /// <summary>
    /// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환
    /// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour"
    /// </summary>
    private string ParseIntervalToPostgresInterval(string interval)
    {
        if (string.IsNullOrWhiteSpace(interval))
            return "1 minute";

        var lower = interval.ToLower().Trim();

        // 이미 PostgreSQL 형식인 경우 (예: "1 minute", "5 minutes", "1 hour")
        if (System.Text.RegularExpressions.Regex.IsMatch(lower, @"^\d+\s+(second|minute|hour|day|week)s?$"))
        {
            return lower;
        }

        // 숫자만 있는 경우 (초 단위)
        if (int.TryParse(lower, out var seconds))
        {
            if (seconds >= 3600 && seconds % 3600 == 0)
                return $"{seconds / 3600} hour{(seconds / 3600 > 1 ? "s" : "")}";
            if (seconds >= 60 && seconds % 60 == 0)
                return $"{seconds / 60} minute{(seconds / 60 > 1 ? "s" : "")}";
            return $"{seconds} second{(seconds > 1 ? "s" : "")}";
        }

        // 기본값
        return "1 minute";
    }

    public async Task<NodeMapQueryResult> QueryMasterAsync(
        int? minLevel, int? maxLevel, string? nodeClass,
        IEnumerable<string>? names, string? nodeId, string? dataType,
        int limit, int offset)
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    /// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
    public async Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync()
    {
        throw new NotImplementedException("OPC UA method not applicable in HC900Crawler");
    }

    public async Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames)
    {
        try
        {
            var tags = tagNames.ToList();
            if (tags.Count == 0) return Enumerable.Empty<RealtimePoint>();
            
            var records = await _ctx.RealtimePoints
                .Where(x => tags.Contains(x.TagName))
                .ToListAsync();
            
            _logger.LogDebug("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
            return records;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[Realtime] 태그 라이브 데이터 조회 실패");
            return Enumerable.Empty<RealtimePoint>();
        }
    }

    // ── FastSession / FastRecord ─────────────────────────────────────────────────

    public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
    {
        var session = new FastSession
        {
            Name          = request.Name,
            SamplingMs    = request.SamplingMs,
            DurationSec   = request.DurationSec,
            TagList       = JsonSerializer.Serialize(request.TagList),   // string[] → JSONB
            StartedAt     = DateTime.UtcNow,
            Status        = "Running",
            RowCount      = 0,
            RetentionDays = request.RetentionDays,
            Pinned        = false
        };
        _ctx.FastSessions.Add(session);
        await _ctx.SaveChangesAsync();
        return session;
    }

    public async Task UpdateFastSessionStatusAsync(int sessionId, string status)
    {
        var session = await _ctx.FastSessions.FindAsync(sessionId);
        if (session == null) return;
        session.Status = status;
        if (status is "Completed" or "Cancelled" or "Failed" or "RowLimitReached")
            session.EndedAt = DateTime.UtcNow;
        await _ctx.SaveChangesAsync();
    }

    public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount)
    {
        var session = await _ctx.FastSessions.FindAsync(sessionId);
        if (session == null) return;
        session.RowCount = rowCount;
        await _ctx.SaveChangesAsync();
    }

    public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned)
    {
        var session = await _ctx.FastSessions.FindAsync(sessionId);
        if (session == null) return;
        session.Pinned = pinned;
        await _ctx.SaveChangesAsync();
    }

    public async Task<FastSession?> GetFastSessionAsync(int sessionId)
        => await _ctx.FastSessions.FindAsync(sessionId);

    public async Task<IEnumerable<FastSession>> GetFastSessionsAsync()
        => await _ctx.FastSessions.OrderByDescending(x => x.StartedAt).ToListAsync();

    public async Task DeleteFastSessionAsync(int sessionId)
    {
        var session = await _ctx.FastSessions.FindAsync(sessionId);
        if (session == null) return;
        _ctx.FastSessions.Remove(session);
        await _ctx.SaveChangesAsync();
    }

    public async Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync()
    {
        var now = DateTime.UtcNow;
        return await _ctx.FastSessions
            .Where(x => x.EndedAt != null
                && !x.Pinned
                && x.RetentionDays.HasValue
                && x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now)
            .OrderBy(x => x.EndedAt)
            .ToListAsync();
    }

    public async Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to)
    {
        var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId);
        if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value);
        if (to.HasValue)   query = query.Where(x => x.RecordedAt <= to.Value);

        var records = await query.OrderBy(x => x.RecordedAt).ToListAsync();
        var tagNames = records.Select(x => x.TagName).Distinct().ToArray();
        var items = records.Select(r => new FastRecord
        {
            Id = r.Id,
            SessionId = r.SessionId,
            RecordedAt = r.RecordedAt,
            TagName = r.TagName,
            Value = r.Value
        });

        return new FastQueryResult(
            SessionId: sessionId,
            From: from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
            To: to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
            TagNames: tagNames,
            Items: items,
            TotalCount: records.Count
        );
    }

    public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
    {
        await _ctx.FastRecords.AddRangeAsync(records);
        await _ctx.SaveChangesAsync();
    }

    public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to)
    {
        var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId);
        if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value);
        if (to.HasValue)   query = query.Where(x => x.RecordedAt <= to.Value);

        var records = await query.OrderBy(x => x.RecordedAt).ThenBy(x => x.TagName).ToListAsync();
        var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray();

        using var writer = new StreamWriter(stream, leaveOpen: true);
        await writer.WriteLineAsync("recorded_at," + string.Join(",", tagNames));

        foreach (var g in records.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key))
        {
            var values = g.ToDictionary(r => r.TagName, r => r.Value);
            var row = g.Key.ToString("o") + "," +
                      string.Join(",", tagNames.Select(t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
            await writer.WriteLineAsync(row);
        }
        await writer.FlushAsync();
    }

    public async Task<string?> GetNodeIdByTagNameAsync(string tagName)
    {
        return await _ctx.RealtimePoints
            .Where(x => x.TagName == tagName)
            .Select(x => x.NodeId)
            .FirstOrDefaultAsync();
    }

    // ── Digital Event History ──────────────────────────────────────────────────

    public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
    {
        var fromMetadata = await _ctx.TagMetadata
            .Where(m => m.Value == "i=7594")
            .Select(m => m.BaseTag)
            .Distinct()
            .ToListAsync();

        if (fromMetadata.Any())
            return fromMetadata;

        return await _ctx.RealtimePoints
            .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
            .Select(p => p.TagName)
            .Distinct()
            .ToListAsync();
    }

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

    public async Task<string?> GetAreaByTagNameAsync(string tagName)
    {
        var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
        var meta = await _ctx.TagMetadata
            .Where(m => m.BaseTag == baseTag && m.Attribute == "area")
            .Select(m => m.Value)
            .FirstOrDefaultAsync();

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

        var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
        return match.Success ? match.Groups[1].Value : null;
    }

    // ── Sub-Area (세부 Area) ────────────────────────────────────────────────────
    // tag_metadata attribute='sub_area' (EAV). 값 형식: 단일 "P6-1" 또는 공용 "P6-1,P6-2".
    // 공용 매칭은 어디서나 `<code> = ANY(string_to_array(value, ','))` 토큰 매칭을 사용한다.

    public async Task<string?> GetSubAreaByTagNameAsync(string tagName)
    {
        var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
        return await _ctx.TagMetadata
            .Where(m => m.BaseTag.Equals(baseTag, StringComparison.OrdinalIgnoreCase) && m.Attribute == "sub_area")
            .Select(m => m.Value)
            .FirstOrDefaultAsync();
    }

    public async Task<(List<SubAreaTagDto> tags, int total)> GetSubAreaListByAreaAsync(
        string area, int page, int pageSize)
    {
        page     = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 1, 500);
        var isSubArea = area.Contains('-');   // "P6-1" 형식이면 sub_area 코드

        // v_tag_summary는 base_tag별 area/sub_area를 이미 LEFT JOIN으로 노출한다.
        // sub_area 코드면 토큰 매칭(공용 포함), area 코드면 area 정확 매칭.
        var where = isSubArea
            ? "@area = ANY(string_to_array(sub_area, ','))"
            : "trim(split_part(area, '|', 2)) = @area";

        var listSql  = $@"SELECT base_tag, trim(split_part(area, '|', 2)) AS area, sub_area, description
                          FROM v_tag_summary
                          WHERE {where}
                          ORDER BY base_tag
                          LIMIT @limit OFFSET @offset";
        var countSql = $"SELECT COUNT(*) FROM v_tag_summary WHERE {where}";

        var conn = (NpgsqlConnection)_ctx.Database.GetDbConnection();
        var mustClose = conn.State != System.Data.ConnectionState.Open;
        if (mustClose) await conn.OpenAsync();
        try
        {
            int total;
            await using (var countCmd = new NpgsqlCommand(countSql, conn))
            {
                countCmd.Parameters.AddWithValue("area", area);
                total = Convert.ToInt32(await countCmd.ExecuteScalarAsync());
            }

            var tags = new List<SubAreaTagDto>();
            await using (var cmd = new NpgsqlCommand(listSql, conn))
            {
                cmd.Parameters.AddWithValue("area", area);
                cmd.Parameters.AddWithValue("limit", pageSize);
                cmd.Parameters.AddWithValue("offset", (page - 1) * pageSize);
                await using var reader = await cmd.ExecuteReaderAsync();
                while (await reader.ReadAsync())
                {
                    tags.Add(new SubAreaTagDto
                    {
                        BaseTag     = reader.GetString(0),
                        Area        = reader.IsDBNull(1) ? null : reader.GetString(1),
                        SubArea     = reader.IsDBNull(2) ? null : reader.GetString(2),
                        Description = reader.IsDBNull(3) ? null : reader.GetString(3),
                    });
                }
            }
            return (tags, total);
        }
        finally
        {
            if (mustClose) await conn.CloseAsync();
        }
    }

    public async Task<bool> UpdateSubAreaAsync(string baseTag, string? subArea)
    {
        if (string.IsNullOrWhiteSpace(subArea))
        {
            var deleted = await _ctx.Database.ExecuteSqlRawAsync(
                "DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag);
            return deleted > 0;
        }

        var affected = await _ctx.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, subArea.Trim());
        return affected > 0;
    }

    // area-scoped 번호 prefix 규칙 + 공용 검출을 단일 CASE로 분류.
    // 공용(여러 sub_area 공유)은 두 코드를 콤마로 부여하여 어느 sub_area 필터에서도 노출되게 한다.
    private const string SubAreaClassifySql = @"
        WITH base AS (
            SELECT DISTINCT
                base_tag,
                regexp_replace(value, '.*\|\s*(\w+)\s*\|.*', '\1') AS area,
                regexp_replace(base_tag, '[^0-9]', '', 'g')        AS digits
            FROM tag_metadata
            WHERE attribute = 'area'
              -- 가드: realtime_table에 실재하는 base_tag만 (삭제된 포인트의 고아 메타데이터 제외)
              AND EXISTS (SELECT 1 FROM realtime_table r WHERE split_part(r.tagname, '.', 1) = tag_metadata.base_tag)
        ),
        shared_role AS (
            SELECT DISTINCT LOWER(tag_no) AS base_tag
            FROM pid_equipment
            WHERE role ILIKE '%공용%' OR role ILIKE '%공통%'
        ),
        classified AS (
            SELECT b.base_tag, b.area,
                CASE
                    -- 1) 공용(여러 sub_area 공유) → 두 코드 모두 부여
                    WHEN b.base_tag IN (SELECT base_tag FROM shared_role)
                      OR (b.area = 'P6' AND b.digits LIKE '6201%')
                      OR (b.area = 'P2' AND (b.digits LIKE '2127%' OR b.digits LIKE '2128%' OR b.digits LIKE '2129%'))
                    THEN CASE b.area
                            WHEN 'P6'  THEN 'P6-1,P6-2'
                            WHEN 'P9'  THEN 'P9-1,P9-2'
                            WHEN 'P10' THEN 'P10-1,P10-2'
                            WHEN 'P1'  THEN 'P1-1,P1-2'
                            WHEN 'P2'  THEN 'P2-1,P2-2'
                         END
                    -- 2) area-scoped 번호 prefix (긴 prefix 먼저)
                    WHEN b.area = 'P10' AND b.digits LIKE '101%' THEN 'P10-1'
                    WHEN b.area = 'P10' AND b.digits LIKE '102%' THEN 'P10-2'
                    WHEN b.area = 'P2'  AND b.digits LIKE '211%' THEN 'P2-1'
                    WHEN b.area = 'P2'  AND b.digits LIKE '212%' THEN 'P2-2'
                    WHEN b.area = 'P2'  AND b.digits LIKE '213%' THEN 'P2-2'
                    WHEN b.area = 'P6'  AND b.digits LIKE '61%'  THEN 'P6-1'
                    WHEN b.area = 'P6'  AND b.digits LIKE '62%'  THEN 'P6-2'
                    WHEN b.area = 'P9'  AND b.digits LIKE '91%'  THEN 'P9-1'
                    WHEN b.area = 'P9'  AND b.digits LIKE '92%'  THEN 'P9-2'
                    WHEN b.area = 'P1'  AND b.digits LIKE '11%'  THEN 'P1-1'
                    WHEN b.area = 'P1'  AND b.digits LIKE '12%'  THEN 'P1-2'
                    WHEN b.area = 'P1'  AND b.digits LIKE '13%'  THEN 'P1-2'
                    ELSE NULL
                END AS sub_area
            FROM base b
            WHERE b.area IN ('P6','P9','P10','P1','P2')
        )
        SELECT base_tag, area, sub_area FROM classified ORDER BY area, base_tag";

    public async Task<SubAreaSeedResultDto> SeedSubAreaAsync(bool dryRun)
    {
        var result = new SubAreaSeedResultDto { DryRun = dryRun };

        var conn = (NpgsqlConnection)_ctx.Database.GetDbConnection();
        var mustClose = conn.State != System.Data.ConnectionState.Open;
        if (mustClose) await conn.OpenAsync();
        try
        {
            await using (var cmd = new NpgsqlCommand(SubAreaClassifySql, conn))
            await using (var reader = await cmd.ExecuteReaderAsync())
            {
                while (await reader.ReadAsync())
                {
                    var baseTag = reader.GetString(0);
                    var area    = reader.GetString(1);
                    var subArea = reader.IsDBNull(2) ? null : reader.GetString(2);

                    string action;
                    if (subArea == null)               { action = "unmatched"; result.Unmatched++; }
                    else if (subArea.Contains(','))     { action = "shared";    result.Shared++; }
                    else                                { action = "assign";    result.Assigned++; }

                    if (subArea != null)
                        result.BySubArea[subArea] = result.BySubArea.GetValueOrDefault(subArea) + 1;

                    result.Details.Add(new SubAreaSeedDetailDto
                    {
                        BaseTag = baseTag, Area = area, Action = action, SubArea = subArea,
                        Reason  = action == "shared" ? "여러 sub_area 공용" : null,
                    });
                }
            }

            if (!dryRun)
            {
                await _ctx.Database.ExecuteSqlRawAsync(@"
                    WITH base AS (
                        SELECT DISTINCT
                            base_tag,
                            regexp_replace(value, '.*\|\s*(\w+)\s*\|.*', '\1') AS area,
                            regexp_replace(base_tag, '[^0-9]', '', 'g')        AS digits
                        FROM tag_metadata
                        WHERE attribute = 'area'
                          -- 가드: realtime_table에 실재하는 base_tag만 (삭제된 포인트의 고아 메타데이터 제외)
                          AND EXISTS (SELECT 1 FROM realtime_table r WHERE split_part(r.tagname, '.', 1) = tag_metadata.base_tag)
                    ),
                    shared_role AS (
                        SELECT DISTINCT LOWER(tag_no) AS base_tag
                        FROM pid_equipment
                        WHERE role ILIKE '%공용%' OR role ILIKE '%공통%'
                    ),
                    classified AS (
                        SELECT b.base_tag,
                            CASE
                                WHEN b.base_tag IN (SELECT base_tag FROM shared_role)
                                  OR (b.area = 'P6' AND b.digits LIKE '6201%')
                                  OR (b.area = 'P2' AND (b.digits LIKE '2127%' OR b.digits LIKE '2128%' OR b.digits LIKE '2129%'))
                                THEN CASE b.area
                                        WHEN 'P6'  THEN 'P6-1,P6-2' WHEN 'P9'  THEN 'P9-1,P9-2'
                                        WHEN 'P10' THEN 'P10-1,P10-2' WHEN 'P1' THEN 'P1-1,P1-2'
                                        WHEN 'P2'  THEN 'P2-1,P2-2' END
                                WHEN b.area = 'P10' AND b.digits LIKE '101%' THEN 'P10-1'
                                WHEN b.area = 'P10' AND b.digits LIKE '102%' THEN 'P10-2'
                                WHEN b.area = 'P2'  AND b.digits LIKE '211%' THEN 'P2-1'
                                WHEN b.area = 'P2'  AND b.digits LIKE '212%' THEN 'P2-2'
                                WHEN b.area = 'P2'  AND b.digits LIKE '213%' THEN 'P2-2'
                                WHEN b.area = 'P6'  AND b.digits LIKE '61%'  THEN 'P6-1'
                                WHEN b.area = 'P6'  AND b.digits LIKE '62%'  THEN 'P6-2'
                                WHEN b.area = 'P9'  AND b.digits LIKE '91%'  THEN 'P9-1'
                                WHEN b.area = 'P9'  AND b.digits LIKE '92%'  THEN 'P9-2'
                                WHEN b.area = 'P1'  AND b.digits LIKE '11%'  THEN 'P1-1'
                                WHEN b.area = 'P1'  AND b.digits LIKE '12%'  THEN 'P1-2'
                                WHEN b.area = 'P1'  AND b.digits LIKE '13%'  THEN 'P1-2'
                                ELSE NULL
                            END AS sub_area
                        FROM base b
                        WHERE b.area IN ('P6','P9','P10','P1','P2')
                    )
                    INSERT INTO tag_metadata (base_tag, attribute, value)
                    SELECT base_tag, 'sub_area', sub_area FROM classified WHERE sub_area IS NOT NULL
                    ON CONFLICT (base_tag, attribute) DO NOTHING");
            }
            return result;
        }
        finally
        {
            if (mustClose) await conn.CloseAsync();
        }
    }

    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,
            SubArea         = record.SubArea,
            Metadata        = record.Metadata
        };

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

    public async Task<int> BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord> records)
    {
        var rows = records.Select(r => new EventHistoryRecord
        {
            ControllerId    = r.ControllerId,
            TagName         = r.TagName,
            NodeId          = r.NodeId,
            PrevValue       = r.PrevValue,
            CurrValue       = r.CurrValue,
            EventType       = r.EventType,
            EventTime       = r.EventTime,
            DurationSeconds = r.DurationSeconds,
            Area            = r.Area,
            SubArea         = r.SubArea,
            Metadata        = r.Metadata
        }).ToList();

        await _ctx.EventHistoryRecords.AddRangeAsync(rows);
        return await _ctx.SaveChangesAsync();
    }

    public async Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
        string? tagName, string? area, string? subArea,
        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(subArea))
            query = query.Where(r => r.SubArea == subArea
                || r.SubArea!.StartsWith(subArea + ",")
                || r.SubArea.EndsWith("," + subArea)
                || r.SubArea.Contains("," + subArea + ","));
        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,
            SubArea = r.SubArea,
            DurationSeconds = r.DurationSeconds,
            Metadata = r.Metadata
        });
    }

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

        Task refreshTask;
        lock (_digitalCacheLock)
        {
            if (_cacheRefreshTask == null)
                _cacheRefreshTask = RefreshDigitalTagCacheAsync();
            refreshTask = _cacheRefreshTask;
        }

        await refreshTask;

        lock (_digitalCacheLock)
        {
            _cacheRefreshTask = null;
        }

        return _digitalTagCache;
    }

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

    /// <summary>
    /// 하이퍼테이블 상태 조회합니다.
    /// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.
    /// </summary>
    public async Task<HypertableStatusInfo> GetHypertableStatusAsync()
    {
        try
        {
            await _ctx.Database.GetDbConnection().OpenAsync();
            
            // 하이퍼테이블 존재 여부 확인
            await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
            hypertableCheckCmd.CommandText = @"
                SELECT EXISTS (
                    SELECT 1 FROM timescaledb_information.hypertables
                    WHERE hypertable_name = 'history_table'
                )";

            var isHypertableResult = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync());

            // 레코드 수 조회
            var recordCount = 0;
            if (isHypertableResult)
            {
                await using var countCmd = _ctx.Database.GetDbConnection().CreateCommand();
                countCmd.CommandText = "SELECT COUNT(*) FROM history_table";
                recordCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync());
            }

            // 보존 정책 확인 (pg_extension으로 TimeScaleDB 활성화 여부만 확인)
            var hasRetentionPolicy = false;
            try
            {
                await using var retentionCmd = _ctx.Database.GetDbConnection().CreateCommand();
                retentionCmd.CommandText = @"
                    SELECT EXISTS (
                        SELECT 1 FROM timescaledb_information.policies
                        WHERE policy_type = 'data_retention'
                        AND hypertable_name = 'history_table'
                    )";
                hasRetentionPolicy = Convert.ToBoolean(await retentionCmd.ExecuteScalarAsync());
            }
            catch
            {
                // policies 뷰가 없는 경우 false 유지
                hasRetentionPolicy = false;
            }

            // 압축 확인
            var hasCompression = false;
            try
            {
                await using var compressionCmd = _ctx.Database.GetDbConnection().CreateCommand();
                compressionCmd.CommandText = @"
                    SELECT is_compressed = 't'
                    FROM timescaledb_information.hypertables
                    WHERE hypertable_name = 'history_table'";
                var compressionResult = await compressionCmd.ExecuteScalarAsync();
                if (compressionResult != null)
                {
                    hasCompression = compressionResult.ToString() == "t";
                }
            }
            catch
            {
                // 압축 정보 조회 실패 시 false 유지
                hasCompression = false;
            }

            // 연속 집계 확인
            var hasContinuousAggregate = false;
            try
            {
                await using var aggregateCmd = _ctx.Database.GetDbConnection().CreateCommand();
                aggregateCmd.CommandText = @"
                    SELECT EXISTS (
                        SELECT 1 FROM timescaledb_information.continuous_aggregates
                        WHERE hypertable_name = 'history_table'
                    )";
                hasContinuousAggregate = Convert.ToBoolean(await aggregateCmd.ExecuteScalarAsync());
            }
            catch
            {
                // continuous_aggregates 뷰가 없는 경우 false 유지
                hasContinuousAggregate = false;
            }

            return new HypertableStatusInfo
            {
                IsHypertable = isHypertableResult,
                TableName = "history_table",
                StatusMessage = isHypertableResult
                    ? "하이퍼테이블이 활성화되어 있습니다."
                    : "일반 테이블입니다. CreateHypertableAsync()를 사용하여 하이퍼테이블로 변환할 수 있습니다.",
                RecordCount = recordCount,
                HasRetentionPolicy = hasRetentionPolicy,
                HasCompression = hasCompression,
                HasContinuousAggregate = hasContinuousAggregate
            };
        }
        catch (Exception ex)
        {
            return new HypertableStatusInfo
            {
                IsHypertable = false,
                TableName = "history_table",
                StatusMessage = $"상태 확인 중 오류 발생: {ex.Message}",
                RecordCount = 0,
                HasRetentionPolicy = false,
                HasCompression = false,
                HasContinuousAggregate = false
            };
        }
    }

    /// <summary>
    /// 수동으로 하이퍼테이블을 생성합니다.
    /// 테이블이 이미 존재하거나 하이퍼테이블로 변환된 경우 예외를 throw합니다.
    /// </summary>
    public async Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request)
    {
        // 식별자 검증 - SQL injection 방지
        if (!IsValidSqlIdentifier(request.TableName))
        {
            return HypertableCreateResult.Failed($"테이블 이름 '{request.TableName}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
        }

        if (!IsValidSqlIdentifier(request.TimeColumn))
        {
            return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
        }

        var timeInterval = request.TimeInterval ?? "";
        if (!IsValidSqlIdentifier(timeInterval.Replace(" ", "")))
        {
            return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval ?? "(null)"}'은 유효하지 않습니다.");
        }

        bool connectionOpenedHere = false;
        try
        {
            if (_ctx.Database.GetDbConnection().State != System.Data.ConnectionState.Open)
            {
                await _ctx.Database.OpenConnectionAsync();
                connectionOpenedHere = true;
            }

            // 1. 테이블 존재 여부 확인
            await using var tableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
            tableCheckCmd.CommandText = @"
                SELECT EXISTS (
                    SELECT 1 FROM information_schema.tables
                    WHERE table_schema = 'public'
                    AND table_name = @tableName)";
            tableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName));

            var tableExists = Convert.ToBoolean(await tableCheckCmd.ExecuteScalarAsync());
            if (!tableExists)
            {
                return HypertableCreateResult.Failed($"테이블 '{request.TableName}'이 존재하지 않습니다.");
            }

            // 2. 이미 하이퍼테이블인지 확인
            await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
            hypertableCheckCmd.CommandText = @"
                SELECT EXISTS (
                    SELECT 1 FROM timescaledb_information.hypertables
                    WHERE hypertable_name = @tableName)";
            hypertableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName));

            var isHypertable = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync());
            if (isHypertable)
            {
                return HypertableCreateResult.AlreadyExists($"테이블 '{request.TableName}'은 이미 하이퍼테이블입니다.");
            }

            // 3. TimescaleDB 확장 활성화
            await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");

            // 3-1. 기존 SERIAL PRIMARY KEY 제약사항 제거 (TimeScaleDB 호환성)
            var dropPrimaryKeySql = $"ALTER TABLE {request.TableName} DROP CONSTRAINT IF EXISTS {request.TableName}_pkey";
            _logger.LogDebug("[ExperionDb] 기본키 제약 제거: {Sql}", dropPrimaryKeySql);
#pragma warning disable EF1002
            await _ctx.Database.ExecuteSqlRawAsync(dropPrimaryKeySql);
#pragma warning restore EF1002

            // 4. 하이퍼테이블 생성 (기존 데이터 마이그레이션 포함)
            var createHypertableSql = $"SELECT create_hypertable('{request.TableName}'::regclass, '{request.TimeColumn}'::text, if_not_exists => TRUE, migrate_data => TRUE)";
            _logger.LogDebug("[ExperionDb] 하이퍼테이블 생성: {Sql}", createHypertableSql);
#pragma warning disable EF1002
            await _ctx.Database.ExecuteSqlRawAsync(createHypertableSql);
#pragma warning restore EF1002

            // 4-1. TimeScaleDB 하이퍼테이블에 적합한 새로운 기본키 생성
            var addPrimaryKeySql = $"ALTER TABLE {request.TableName} ADD CONSTRAINT {request.TableName}_pkey PRIMARY KEY ({request.TimeColumn}, id)";
            _logger.LogDebug("[ExperionDb] 새 기본키 생성: {Sql}", addPrimaryKeySql);
#pragma warning disable EF1002
            await _ctx.Database.ExecuteSqlRawAsync(addPrimaryKeySql);
#pragma warning restore EF1002

            // 6. 보존 정책 설정 (요청된 경우)
            if (request.SetRetentionPolicy && !string.IsNullOrEmpty(request.RetentionPeriod))
            {
                if (!IsValidSqlInterval(request.RetentionPeriod))
                {
                    return HypertableCreateResult.Failed($"보존 기간 '{request.RetentionPeriod}'은 유효하지 않습니다. (예: '30 days', '12 hours')");
                }
                var retentionSql = $"SELECT add_retention_policy('{request.TableName}'::regclass, INTERVAL '{request.RetentionPeriod}')";
                _logger.LogDebug("[ExperionDb] 보존 정책 적용: {Sql}", retentionSql);
#pragma warning disable EF1002
                await _ctx.Database.ExecuteSqlRawAsync(retentionSql);
#pragma warning restore EF1002
            }

            // 7. 압축 정책 설정 (요청된 경우)
            if (request.EnableCompression && !string.IsNullOrEmpty(request.CompressionPeriod))
            {
                if (!IsValidSqlInterval(request.CompressionPeriod))
                {
                    return HypertableCreateResult.Failed($"압축 기간 '{request.CompressionPeriod}'은 유효하지 않습니다. (예: '7 days', '24 hours')");
                }
                var compressionSql = $"SELECT add_compression_policy('{request.TableName}'::regclass, INTERVAL '{request.CompressionPeriod}')";
                _logger.LogDebug("[ExperionDb] 압축 정책 적용: {Sql}", compressionSql);
#pragma warning disable EF1002
                await _ctx.Database.ExecuteSqlRawAsync(compressionSql);
#pragma warning restore EF1002
            }

            // 7-1. history_table 컬럼명 검증 (tag_name vs tagname)
            if (request.TableName == "history_table")
            {
                await using var columnCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
                columnCheckCmd.CommandText = @"
                    SELECT column_name FROM information_schema.columns
                    WHERE table_schema = 'public'
                    AND table_name = 'history_table'
                    AND column_name IN ('tag_name', 'tagname')
                    ORDER BY column_name;";

                var columns = new List<string>();
                await using var reader = await columnCheckCmd.ExecuteReaderAsync();
                while (await reader.ReadAsync())
                {
                    columns.Add(reader.GetString(0));
                }

                _logger.LogDebug("[ExperionDb] history_table 컬럼명 검증: {Columns}", string.Join(", ", columns));
            }

            // 8. 연속 집계 생성 (요청된 경우)
            if (request.CreateContinuousAggregate)
            {
                // 8-1. 기존 MATERIALIZED VIEW 삭제 (별도 실행)
                var dropViewSql = "DROP MATERIALIZED VIEW IF EXISTS history_5min_agg";
                _logger.LogDebug("[ExperionDb] 연속 집계 DROP VIEW: {Sql}", dropViewSql);
#pragma warning disable EF1002
                await _ctx.Database.ExecuteSqlRawAsync(dropViewSql);
#pragma warning restore EF1002

                // 8-2. 연속 집계 MATERIALIZED VIEW 생성 (별도 실행)
                var createViewSql = $@"
                    CREATE MATERIALIZED VIEW history_5min_agg
                    WITH (timescaledb.continuous) AS
                    SELECT
                        time_bucket(INTERVAL '5 minutes', {request.TimeColumn}) AS time_bucket,
                        tagname,
                        AVG(value) AS avg_value,
                        first(value, {request.TimeColumn}) AS min_value,
                        last(value, {request.TimeColumn}) AS max_value
                    FROM {request.TableName}
                    GROUP BY time_bucket, tagname;";

                _logger.LogDebug("[ExperionDb] 연속 집계 CREATE VIEW: {Sql}", createViewSql);

#pragma warning disable EF1002
                await _ctx.Database.ExecuteSqlRawAsync(createViewSql);
#pragma warning restore EF1002
                _logger.LogDebug("[ExperionDb] 연속 집계 CREATE VIEW 성공");

                // 8-3. 연속 집계 정책 추가
                var aggregatePolicySql = $"SELECT add_continuous_aggregate_policy('history_5min_agg', '10 minutes', '1 minute', '5 minutes')";
                _logger.LogDebug("[ExperionDb] 연속 집계 정책: {Sql}", aggregatePolicySql);

#pragma warning disable EF1002
                await _ctx.Database.ExecuteSqlRawAsync(aggregatePolicySql);
#pragma warning restore EF1002
            }

            return HypertableCreateResult.Ok();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[ExperionDb] 하이퍼테이블 생성 실패 - 테이블: {Table}", request.TableName);
            return HypertableCreateResult.Failed($"하이퍼테이블 생성 실패: {ex.Message}");
        }
        finally
        {
            if (connectionOpenedHere)
            {
                try { await _ctx.Database.CloseConnectionAsync(); }
                catch (Exception ex) { _logger.LogWarning(ex, "[ExperionDb] 커넥션 종료 중 예외 (무시)"); }
            }
        }
    }

    /// <summary>
    /// SQL 식별자로 안전한지 검증합니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용합니다.
    /// EF1002 SQL injection 방지를 위해 DDL 문에서 식별자를 사용할 때 이 메서드로 검증을 필수로 합니다.
    /// </summary>
    private static bool IsValidSqlIdentifier(string identifier)
    {
        if (string.IsNullOrEmpty(identifier))
            return false;

        if (identifier.Length > 63) // PostgreSQL 식별자 최대 길이
            return false;

        // 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용
        return System.Text.RegularExpressions.Regex.IsMatch(
            identifier,
            @"^[a-zA-Z0-9_\-\.]+$");
    }

    // INTERVAL 문자열 검증 — TimescaleDB add_retention_policy/add_compression_policy에 사용.
    // 허용 패턴: "30 days", "12 hours 30 minutes", "1 year", "1 day 12 hours" 등.
    // 숫자 + 단위(공백 1칸 또는 없음)의 반복만 허용 → 따옴표/세미콜론/주석 등 모든 메타문자 차단.
    private static readonly System.Text.RegularExpressions.Regex _intervalRegex =
        new(@"^\s*(\d+\s*(second|seconds|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)\s*)+$",
            System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);

    private static bool IsValidSqlInterval(string? interval)
    {
        if (string.IsNullOrWhiteSpace(interval)) return false;
        if (interval.Length > 100) return false;
        return _intervalRegex.IsMatch(interval);
    }
}

/// <summary>
/// 하이퍼테이블 상태 정보 결과 클래스
/// </summary>
public record HypertableStatusInfo
{
    public bool IsHypertable { get; init; }
    public string? TableName { get; init; }
    public string? StatusMessage { get; init; }
    public int RecordCount { get; init; }
    public bool HasRetentionPolicy { get; init; }
    public bool HasCompression { get; init; }
    public bool HasContinuousAggregate { get; init; }
}

/// <summary>
/// 하이퍼테이블 생성 요청 클래스
/// </summary>
public record HypertableCreateRequest
{
    public string TableName { get; init; } = "history_table";
    public string TimeColumn { get; init; } = "recorded_at";
    public string TimeInterval { get; init; } = "1 day";
    public bool MigrateData { get; init; } = true;
    public bool SetRetentionPolicy { get; init; } = true;
    public string RetentionPeriod { get; init; } = "90 days";
    public bool EnableCompression { get; init; } = true;
    public string CompressionPeriod { get; init; } = "1 day";
    public bool CreateContinuousAggregate { get; init; } = true;
}

/// <summary>
/// 하이퍼테이블 생성 결과 클래스
/// </summary>
public record HypertableCreateResult
{
    public bool Success { get; init; }
    public string Message { get; init; } = string.Empty;
    public string? TableName { get; init; }

    public static HypertableCreateResult Ok(string? tableName = null, string? message = null)
        => new() { Success = true, TableName = tableName, Message = message ?? "하이퍼테이블이 성공적으로 생성되었습니다." };

    public static HypertableCreateResult Failed(string message)
        => new() { Success = false, Message = message };

    public static HypertableCreateResult AlreadyExists(string message)
        => new() { Success = false, Message = message };
}
