using Hc900Crawler.Core.Application.Feedforward;

namespace Hc900Crawler.Infrastructure.Control;

public sealed class StreamState
{
    public DeadTimeBuffer Dead { get; } = new();
    public FirstOrderLag  Lag  { get; } = new();
    public RateLimiter    Rate { get; } = new();
    public RateLimiter    CompRate { get; } = new();   // WP5: 조성 trim rate 제한(듀티 대역폭)
    public double LastRec { get; set; } = double.NaN;
}

public sealed class ColumnState
{
    public FirstOrderLag FeedFilter  { get; } = new();
    public FirstOrderLag PressFilter { get; } = new();
    public Derivative    FeedDeriv   { get; } = new();
    public double        SettleTimerSec { get; set; }
    public bool          Initialized { get; set; }
    // WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드)
    public bool          PRefSeeded { get; set; }
    public double        PRefValue  { get; set; } = double.NaN;
    // §10: 온도 프로파일 스팬 기준점(최초 정상에서 시드)
    public bool          SpanRefSeeded { get; set; }
    public double        SpanRefValue  { get; set; } = double.NaN;
    // WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
    public CrossCorrLagEstimator? ThetaEst { get; set; }
    public double        PrevFeedFiltered { get; set; } = double.NaN;
    public double        PrevRespPct      { get; set; } = double.NaN;
    public double        PrevSteamOp      { get; set; } = double.NaN;
    // WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적)
    public MovingAverage? VLossMaBlock { get; set; }
    public Dictionary<string, MovingAverage> KObsMa { get; } = new();
    public FrontPositionIndicator? FrontInd { get; set; }   // WO-5
    // WP5 1단계: 2-point front baseline (C pivot 기준 상/하 차온의 느린 baseline)
    public FirstOrderLag UpperFrontBase { get; } = new();   // ΔT(C−D) 상부
    public FirstOrderLag LowerFrontBase { get; } = new();   // ΔT(C−B) 하부
    // WO-6 전환류 상태기계
    public ColumnMode Mode { get; set; } = ColumnMode.Normal;
    public double ImbalanceTimerSec { get; set; }
    public double RecoverySettleTimerSec { get; set; }
    public double ReturnTimerSec { get; set; }
    public bool   OperatorArmed { get; set; }
    public bool   OperatorCancel { get; set; }
    public Dictionary<string, StreamState> Streams { get; } = new();

    public StreamState Stream(string key)
    {
        if (!Streams.TryGetValue(key, out var s)) { s = new StreamState(); Streams[key] = s; }
        return s;
    }
}

public sealed class FeedforwardEngine
{
    public AdvisoryResult Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now)
    {
        var ts = cfg.ScanSec;

        if (!pv.Feed.Good || !Num.IsFinite(pv.Feed.Value))
            return Hold(cfg, st, now, "FEED BAD");

        var ff = st.FeedFilter.Step(pv.Feed.Value, cfg.FeedFilterTauSec, ts);

        if (!st.Initialized) { SeedAll(cfg, pv, st, ff); st.Initialized = true; }

        var  dF       = st.FeedDeriv.Update(ff, ts);
        bool moving   = cfg.FeedMoveThresholdPerMin > 0
                        && Math.Abs(dF) * 60.0 > cfg.FeedMoveThresholdPerMin;
        bool pUnstable = false;
        if (pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value))
        {
            var pf = st.PressFilter.Step(pp.Value, cfg.PressFilterTauSec, ts);
            pUnstable = Math.Abs(pp.Value - pf) > cfg.PressureBand;
        }
        if (moving || pUnstable) st.SettleTimerSec = cfg.SettleSec;
        else                     st.SettleTimerSec = Math.Max(0.0, st.SettleTimerSec - ts);
        bool   transient = moving || pUnstable || st.SettleTimerSec > 0.0;
        string treason   = moving ? "FEED 이동"
                         : pUnstable ? "압력 불안정"
                         : st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : "";

        // ── Pre-compute mbState for WO-1 downgrade ──
        double? vloss = null, yield = null;
        string  mbState;
        if (transient)
            mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)";
        else if (TryStreamPv(pv, "D", out var dVal) && TryStreamPv(pv, "P", out var pVal)
              && TryStreamPv(pv, "B", out var bVal) && ff > 1e-6)
        {
            vloss   = ff - (dVal + pVal + bVal);
            yield   = 100.0 * pVal / ff;
            mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)"
                    : vloss.Value < 0                   ? "음의 손실(스팬 오류 의심)"
                    : "정상";
        }
        else mbState = "입력 부족";

        // ── Stream 1-pass: non-reflux ──
        var outs = new List<StreamAdvisory>(cfg.Streams.Count);
        double? prodRec = null;
        foreach (var s in cfg.Streams)
        {
            if (s.RefluxFromProduct) continue;
            var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key));
            if (s.Key == cfg.ProductKey) prodRec = rec;
            outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key), mbState));
        }

        // ── Stream 2-pass: reflux ──
        foreach (var s in cfg.Streams)
        {
            if (!s.RefluxFromProduct) continue;
            var stt = st.Stream(s.Key);
            double? rec = null;
            if (prodRec is double p)
            {
                var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax);
                rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
            }
            outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt, mbState));
        }

        // ── pUnstable column-level downgrade (WO-1) ──
        if (pUnstable)
        {
            outs = outs.Select(a =>
            {
                var (g, why) = Downgrade(a.Grade, (true, "압력 불안정"));
                string? combined = a.GradeReason is null ? why : a.GradeReason + "; " + why;
                return a with { Grade = g, GradeReason = combined };
            }).ToList();
        }

        var temps = BuildTemps(cfg, pv, st);   // WO-2 PCT 모니터
        ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs);   // WO-3 θ 제안(advisory)
        ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa);   // WO-4 느린 바이어스
        var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient);   // WO-5 프론트 위치
        var tp = JudgeTempProfile(temps, st, transient);                           // §10 역전 판정
        var f2 = ApplyFront2Point(st, ts, temps, transient);                       // WP5 1단계 2-point front
        ApplyCompositionTrim(cfg, ff, ts, st, f2, transient, tp.State, ref outs);   // WP5 2·3단계 조성base+trim
        // 역전/붕괴 시 front 트림 보류(방향 신뢰 불가)
        if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null;
        var (mode, modeReason, feedRecSp) = ApplyRecovery(
            cfg, pv, st, ts, ff, vLossMa, frontState, tp.State, transient, ref outs);        // WO-6 전환류 복귀
        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
            transient, treason, ff, outs, vloss, yield, mbState)
            { Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim,
              Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp,
              TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef,
              UpperFrontState = f2.upState, LowerFrontState = f2.loState,
              UpperFrontMetric = f2.upMetric, LowerFrontMetric = f2.loMetric };
    }

    // ── WO-2 P-2 + WP6: 압력보정온도(PCT) 모니터 — 국소 압력 프로파일 적용 ──
    private static IReadOnlyList<TempPoint>? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st)
    {
        if (pv.Temps is null || pv.Temps.Count == 0) return null;

        bool   havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value);
        double pTop      = havePress ? pv.Pressure!.Value : double.NaN;

        // WP6: 탑저압(합성 또는 직접 측정) — 국소 압력 보간용
        bool   haveBottom = pv.BottomPressure is { Good: true } bp && Num.IsFinite(bp.Value);
        double pBottom    = haveBottom ? pv.BottomPressure!.Value : double.NaN;

        double pRef = cfg.PRef;
        if (double.IsNaN(pRef))
        {
            if (!st.PRefSeeded && havePress) { st.PRefValue = pTop; st.PRefSeeded = true; }
            pRef = st.PRefSeeded ? st.PRefValue : double.NaN;
        }

        // WP6: 국소 압력 보간 비율(하단→상단). 탑저=1.0, 탑정=0.0. C-6111 레이아웃 기준.
        // 향후 config 확장 가능. temp_tags 순서 = 하단→상단 = tica-6111a,ti-6111b,ti-6111c,ti-6111d
        var list = new List<TempPoint>(pv.Temps.Count);
        int n = pv.Temps.Count;
        for (int i = 0; i < n; i++)
        {
            var t = pv.Temps[i];
            bool   good = t.Good && Num.IsFinite(t.Value);
            double raw  = good ? t.Value : double.NaN;

            // WP6: 국소 압력 = 선형 보간 bottom→top (i=0→bottom, i=n-1→top)
            double pLocal = double.NaN, pRefLocal = double.NaN;
            if (havePress && haveBottom)
            {
                double w = n > 1 ? i / (double)(n - 1) : 0.0;
                pLocal = pBottom * (1.0 - w) + pTop * w;
                pRefLocal = pRef;
            }
            else if (havePress)
            {
                pLocal = pTop;
                pRefLocal = pRef;
            }

            double pct = raw;
            if (good && cfg.DTdP != 0.0 && Num.IsFinite(pLocal) && Num.IsFinite(pRefLocal))
                pct = TempCorrection.PressureCompensated(raw, pLocal, pRefLocal, cfg.DTdP);
            list.Add(new TempPoint(t.Tag, raw, pct, good));
        }
        return list;
    }

    // ── WO-3 P-1: passive θ 식별 → commanded 스트림에 "제안"만(config θ 무변경) ──────
    private static void ApplyThetaSuggestion(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts,
        IReadOnlyList<TempPoint>? temps, ref List<StreamAdvisory> outs)
    {
        if (!cfg.ThetaAutoTune) return;                 // 옵트인(기본 off)
        if (temps is null || temps.Count == 0) return;

        // 응답 신호 = 민감트레이 PCT(없으면 첫 온도 PCT)
        double respPct = double.NaN;
        if (cfg.SensitiveTrayTag is not null)
        {
            var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
            foreach (var tp in temps) if (tp.Tag == key && tp.Good) { respPct = tp.Pct; break; }
        }
        if (double.IsNaN(respPct) && temps[0].Good) respPct = temps[0].Pct;
        if (double.IsNaN(respPct)) return;

        double feedNow  = st.FeedFilter.Value;
        double steamNow = pv.SteamOp is { Good: true } so && Num.IsFinite(so.Value) ? so.Value : 0.0;

        // 1차차분(Δ=사전백색화). 최초 호출은 prev가 NaN이라 Δ=0(시드)
        double dF = Num.IsFinite(st.PrevFeedFiltered) ? feedNow  - st.PrevFeedFiltered : 0.0;
        double dR = Num.IsFinite(st.PrevRespPct)      ? respPct  - st.PrevRespPct      : 0.0;
        double dS = Num.IsFinite(st.PrevSteamOp)      ? steamNow - st.PrevSteamOp      : 0.0;
        st.PrevFeedFiltered = feedNow; st.PrevRespPct = respPct; st.PrevSteamOp = steamNow;

        st.ThetaEst ??= new CrossCorrLagEstimator(
            maxLagSamples:  Math.Max(1, (int)Math.Round(1200.0 / Math.Max(1e-6, ts))),  // ~20분 지연 탐색
            historySamples: Math.Max(1, (int)Math.Round(3600.0 / Math.Max(1e-6, ts))),  // ~1시간 이력
            minSignalStd:   1e-9);

        var est = st.ThetaEst.Push(dF, dR, dS, ts);
        if (est is null) return;
        var (tu, td, conf) = est.Value;

        outs = outs.Select(a => a.Role == StreamRole.Commanded
            ? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
            : a).ToList();
    }

    // ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ──────
    private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss,
        bool transient, ref List<StreamAdvisory> outs, out double? vLossMa)
    {
        int window = Math.Max(1, (int)Math.Round(cfg.BiasMaWindowSec / Math.Max(1e-6, cfg.ScanSec)));
        vLossMa = null;

        // V_loss 장기 MA (정상상태 + vloss 산출된 경우에만 누적)
        if (!transient && vloss.HasValue && Num.IsFinite(vloss.Value))
        {
            st.VLossMaBlock ??= new MovingAverage(window);
            vLossMa = st.VLossMaBlock.Push(vloss.Value);
        }
        else if (st.VLossMaBlock is not null)
        {
            vLossMa = st.VLossMaBlock.Value;   // 과도 중엔 갱신 없이 직전 MA 유지(표시 연속성)
        }

        // commanded 스트림별 K_obs = PV/FF 의 MA → 제안
        if (transient || ff <= 1e-6) return;
        outs = outs.Select(a =>
        {
            if (a.Role != StreamRole.Commanded) return a;
            if (!(pv.Streams.TryGetValue(a.Key, out var smp) && smp.Good && Num.IsFinite(smp.Value))) return a;
            if (!st.KObsMa.TryGetValue(a.Key, out var ma)) { ma = new MovingAverage(window); st.KObsMa[a.Key] = ma; }
            double kObs = ma.Push(smp.Value / ff);
            return a with { KObsSuggest = kObs };
        }).ToList();
    }

    // ── WO-5 P-3: 프론트 위치(sweet-spot) 지표 + 트림 권장(advisory) ──────────────
    private static (string? state, string? trim) ApplyFront(ColumnConfig cfg, ColumnState st, double ts,
        IReadOnlyList<TempPoint>? temps, bool transient)
    {
        if (temps is null || temps.Count == 0) return (null, null);
        if (transient) return ("정착 대기(프론트 판정 보류)", null);

        double metric = double.NaN;
        bool   strong = false;
        if (cfg.SensitiveTrayTag is not null)
        {
            var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
            foreach (var tp in temps) if (tp.Tag == key && tp.Good) { metric = tp.Pct; break; }
        }
        if (double.IsNaN(metric) && temps.Count >= 2 && temps[0].Good && temps[^1].Good)
        {
            // temp_tags는 하단→상단 순서(예: tica-6111a..ti-6111d, 정상 A>B>C>D 단조감소).
            // front metric = "상단−하단"(top−bottom): 프론트 상승(heavies 상승→상단 가열) 시 증가 →
            // FrontPositionIndicator의 dev>0→"상승"→환류↑ 규약과 정합.
            // [버그픽스] 이전 Delta(temps[0],temps[^1])=하단−상단은 부호 반대라 트림권고가 반전됐음(브레인스토밍 §10.2-A).
            // ⚠ §10.2-B: temps[^1](ti-6111d)는 환류 서브쿨링 오염 가능 — 센서 선정/보정 재검토는 후속.
            metric = DiffTemp.Delta(temps[^1].Pct, temps[0].Pct);
            strong = true;
        }
        if (double.IsNaN(metric)) return (null, null);

        st.FrontInd ??= new FrontPositionIndicator(bandwidth: 0.3);
        var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
        return (state, trim);
    }

    // ── WP5 1단계: 2-point front (C=제품 pivot=temps[n-2]). 상부=ΔT(C−D), 하부=ΔT(C−B). 느린 baseline 편차로 판정. ──
    // 상태는 중립(정상/상승/하강) — 섹션별 액션(B↑/환류↑) 매핑은 2단계(trim). D는 유효온도라 ΔT(C−D) 정상 사용.
    private static (string? upState, double? upMetric, string? loState, double? loMetric)
        ApplyFront2Point(ColumnState st, double ts, IReadOnlyList<TempPoint>? temps, bool transient)
    {
        if (temps is null || temps.Count < 3) return (null, null, null, null);
        if (transient) return ("정착대기", null, "정착대기", null);

        const double bw = 0.3, refTau = 1800.0;
        int n = temps.Count;
        var C = temps[n - 2];  // 제품 pivot
        var D = temps[n - 1];  // 상단
        var B = temps[n - 3];  // 하부

        static string Classify(double dev, double band)
            => Math.Abs(dev) <= band ? "정상" : dev > 0 ? "상승" : "하강";

        string? upState = null; double? upMetric = null;
        if (C.Good && D.Good && Num.IsFinite(C.Pct) && Num.IsFinite(D.Pct))
        {
            double m = C.Pct - D.Pct;                                  // 상부 ΔT(C−D)
            double bl = st.UpperFrontBase.Step(m, refTau, ts);
            upState = Classify(m - bl, bw); upMetric = m;
        }
        string? loState = null; double? loMetric = null;
        if (C.Good && B.Good && Num.IsFinite(C.Pct) && Num.IsFinite(B.Pct))
        {
            double m = C.Pct - B.Pct;                                  // 하부 ΔT(C−B)
            double bl = st.LowerFrontBase.Step(m, refTau, ts);
            loState = Classify(m - bl, bw); loMetric = m;
        }
        return (upState, upMetric, loState, loMetric);
    }

    // ── WP5 2·3단계: 조성목표 base(분율×feed) + 유계 trim(±5% clamp). LevelDriven 드로우 advisory(쓰기 없음). ──
    // 분율 = sc.TargetCoeff(수동입력 시 supervisor가 치환). trim 게인·부호는 현장 calibrate 필요(데모 검증 불가).
    private static void ApplyCompositionTrim(ColumnConfig cfg, double ff, double ts, ColumnState st,
        (string? upState, double? upMetric, string? loState, double? loMetric) f2, bool transient,
        string? tempProfileState, ref List<StreamAdvisory> outs)
    {
        const double clampFrac = 0.05;       // ±5%(보수, 결정)
        const double gainPerDeg = 0.01;      // 1%/°C placeholder — 현장 calibrate
        const double trimRatePerMin = 1.0;   // trim rate 제한[/min] placeholder — 듀티 대역폭 현장 calibrate
        bool block = transient || tempProfileState is "온도역전" or "프로파일붕괴";  // 과도/역전 시 trim→0
        double devUp = f2.upMetric.HasValue ? f2.upMetric.Value - st.UpperFrontBase.Value : double.NaN;
        double devLo = f2.loMetric.HasValue ? f2.loMetric.Value - st.LowerFrontBase.Value : double.NaN;

        outs = outs.Select(a =>
        {
            if (a.Role != StreamRole.LevelDriven) return a;
            var sc = cfg.Streams.FirstOrDefault(x => x.Key == a.Key);
            if (sc is null || ff <= 1e-6) return a;
            double baseSp = sc.TargetCoeff * ff;   // 분율×feed
            var stt = st.Stream(a.Key);

            double dev; string src;
            if (a.Key.Equals("B", StringComparison.OrdinalIgnoreCase)) { dev = devLo; src = "하부front(C−B)"; }
            else if (a.Key.Equals("D", StringComparison.OrdinalIgnoreCase)) { dev = -devUp; src = "상부front(C−D)"; }
            else return a with { CompositionBase = baseSp, RecommendedSpComposition = baseSp, TrimSource = "trim 미적용" };

            // 목표 trim(clamp). 과도/역전/무신호면 0으로. 그 다음 rate 제한으로 완만히 이동.
            double rawTrim = (block || !Num.IsFinite(dev)) ? 0.0 : Num.Clamp(gainPerDeg * dev, -clampFrac, clampFrac) * baseSp;
            double trim = stt.CompRate.Step(rawTrim, trimRatePerMin, trimRatePerMin, ts);
            string note = (block || !Num.IsFinite(dev))
                ? $"{src} (과도/역전/무신호 — trim→0)"
                : $"{src} dev={dev:F2}°C (±{clampFrac:P0} clamp · rate≤{trimRatePerMin}/min · 게인·부호 현장 calibrate)";
            return a with { CompositionBase = baseSp, Trim = trim, RecommendedSpComposition = baseSp + trim, TrimSource = note };
        }).ToList();
    }

    // ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ──────────
    private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient)
    {
        double? refOut = st.SpanRefSeeded ? st.SpanRefValue : (double?)null;
        if (temps is null || temps.Count < 3) return new("입력부족", null, double.NaN, refOut);
        if (transient) return new("정착대기", null, double.NaN, refOut);

        // temp_tags 하단→상단 순서. 마지막(=탑정 D, 환류 서브쿨 오염)을 제외한 조성 트레이만.
        var trays = temps.Take(temps.Count - 1).Select(t => (t.Tag, t.Pct, t.Good)).ToList();

        // 파라미터(기본값, 운전 데이터로 튜닝 — 추후 config 편입). §10.3.
        const double tolInv = 0.5, spanWarnFrac = 0.5, spanCollapseFrac = 0.3;
        double spanRef = st.SpanRefSeeded ? st.SpanRefValue : double.NaN;
        var r = TempProfileJudge.Evaluate(trays, spanRef, tolInv, spanWarnFrac, spanCollapseFrac);

        // 최초 정상에서 spanRef 시드
        if (!st.SpanRefSeeded && r.State == "정상" && Num.IsFinite(r.Span) && r.Span > 0)
        { st.SpanRefValue = r.Span; st.SpanRefSeeded = true; }
        return r;
    }

    // ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ──
    private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery(
        ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff,
        double? vLossMa, string? frontState, string? tempProfileState, bool transient, ref List<StreamAdvisory> outs)
    {
        if (!cfg.RecoveryEnabled)
        {
            st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.OperatorArmed = false; st.OperatorCancel = false;
            return (ColumnMode.Normal, null, null);
        }
        if (st.OperatorCancel)
        {
            st.OperatorCancel = false; st.OperatorArmed = false;
            st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.RecoverySettleTimerSec = 0; st.ReturnTimerSec = 0;
            return (ColumnMode.Normal, "운전원 취소", null);
        }

        double frac = (vLossMa.HasValue && ff > 1e-6) ? Math.Abs(vLossMa.Value) / ff : 0.0;
        bool sigVloss = vLossMa.HasValue && frac > cfg.ImbalanceTriggerFrac;
        bool sigFront = frontState is not null && (frontState.Contains("상승") || frontState.Contains("하강"));
        bool sigDp    = pv.DeltaP is { Good: true } dp && Num.IsFinite(dp.Value) && dp.Value > cfg.DeltaPFloodLimit;
        // WP4: 온도 프로파일 역전/붕괴 코러보레이션 — ΔP 또는 vloss 동반 시에만 공정으로 인정
        bool sigInv     = tempProfileState == "온도역전";
        bool sigCollapse = tempProfileState == "프로파일붕괴";
        bool corroborated = sigVloss || sigDp;
        bool tempSevere   = (sigInv || sigCollapse) && corroborated;
        // 온도 HIGH LIMIT(raw, UI 설정) — 탑저(최고온) 과열 방지. 과열 시 과분해·증기압↑(메탈 carryover)로 제품/설비 손상,
        // 탑저온도는 제품단에도 전파. 운전원이 명시한 안전 한계라 단독 severe(코러보 불요). 1e9 기본=비활성.
        // (정상 A>B>C>D 프로파일에서 최고값=탑저 tica-6111a. 역전 시 타 단 최고면 그게 더 위험하므로 잡는 게 안전.)
        double maxTemp = (pv.Temps is not null && pv.Temps.Count > 0)
            ? pv.Temps.Where(t => t.Good && Num.IsFinite(t.Value)).Select(t => t.Value).DefaultIfEmpty(double.NaN).Max()
            : double.NaN;
        bool sigTHigh = Num.IsFinite(maxTemp) && Num.IsFinite(cfg.TempHighLimit) && maxTemp > cfg.TempHighLimit;
        bool severe   = sigVloss || sigFront || sigDp || tempSevere || sigTHigh;

        // 비코러보 온도역전 — 센서 점검 메시지(severe 아님)
        string? sensorCheck = (sigInv && !corroborated)
            ? "온도역전(센서 점검 권고 — ΔP/물질수지 정상)" : null;

        string SeverityText() =>
            (sigVloss ? $"물질수지({frac:P0}) " : "")
          + (sigFront ? "프론트드리프트 " : "")
          + (sigDp ? "ΔP플러딩 " : "")
          + (tempSevere ? "온도역전/붕괴 " : "")
          + (sigTHigh ? $"온도HIGH({maxTemp:F1}>{cfg.TempHighLimit:F0}) " : "");

        switch (st.Mode)
        {
            case ColumnMode.Normal:
                if (!transient && severe) st.ImbalanceTimerSec += ts; else st.ImbalanceTimerSec = 0;
                bool armed = cfg.RecoveryAutoArm || st.OperatorArmed;
                if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec && armed)
                {
                    st.Mode = ColumnMode.Recovering; st.OperatorArmed = false;
                    st.RecoverySettleTimerSec = 0;
                    return (ColumnMode.Recovering, $"전환류 진입: {SeverityText()}", OverrideRecovering(cfg, ref outs));
                }
                if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec)
                    return (ColumnMode.Normal, $"전환류 권장(ARM 대기): {SeverityText()}", null);
                return (ColumnMode.Normal, sensorCheck, null);

            case ColumnMode.Recovering:
            {
                var feedRec = OverrideRecovering(cfg, ref outs);
                bool recovered = !severe && frac < cfg.ImbalanceTriggerFrac * 0.5;
                if (recovered) st.RecoverySettleTimerSec += ts; else st.RecoverySettleTimerSec = 0;
                if (st.RecoverySettleTimerSec >= cfg.RecoverySettleSec)
                {
                    st.Mode = ColumnMode.Returning; st.ReturnTimerSec = 0;
                    return (ColumnMode.Returning, "평형 회복 — 복귀 램프 시작", null);
                }
                return (ColumnMode.Recovering, $"전환류 평형대기 {st.RecoverySettleTimerSec:F0}/{cfg.RecoverySettleSec:F0}s", feedRec);
            }

            case ColumnMode.Returning:
                st.ReturnTimerSec += ts;
                if (st.ReturnTimerSec >= cfg.ReturnRampSec)
                {
                    st.Mode = ColumnMode.Normal;
                    return (ColumnMode.Normal, "복귀 완료", null);
                }
                return (ColumnMode.Returning, $"복귀 램프 {st.ReturnTimerSec:F0}/{cfg.ReturnRampSec:F0}s", null);

            default:
                st.Mode = ColumnMode.Normal;
                return (ColumnMode.Normal, null, null);
        }
    }

    private static double? OverrideRecovering(ColumnConfig cfg, ref List<StreamAdvisory> outs)
    {
        outs = outs.Select(a =>
        {
            var sc = cfg.Streams.FirstOrDefault(x => x.Key == a.Key);
            bool isReflux = sc is not null && (sc.IsReflux || sc.RefluxFromProduct);
            double? ov;
            if (isReflux) ov = sc!.SpMax;
            else if (a.Role == StreamRole.Monitor) ov = a.RecommendedSp;
            else ov = (sc is not null && !double.IsNaN(sc.RecoverySp)) ? sc.RecoverySp : 0.0;
            return a with { RecommendedSp = ov, Valid = false, Note = "전환류 복귀 — 운전원 인가 필요" };
        }).ToList();
        return cfg.FeedRecoverySp;
    }

    private static (double? rec, string note) ComputeStream(
        StreamConfig s, double ff, double dF, double ts, StreamState stt)
    {
        switch (s.Role)
        {
            case StreamRole.Commanded:
                double theta = dF >= 0 ? s.ThetaUpSec : s.ThetaDnSec;
                double fd    = stt.Dead.Through(ff, theta, ts);
                fd           = stt.Lag.Step(fd, s.TauSec, ts);
                double raw   = Num.Clamp(s.TargetCoeff * fd, s.SpMin, s.SpMax);
                double rec   = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
                return (rec, "");
            case StreamRole.LevelDriven:
                return (s.TargetCoeff * ff, "레벨 제어 구동 — 기대치(deadtime 미적용)");
            default:
                return (null, "모니터(SP 없음)");
        }
    }

    private static StreamAdvisory BuildAdvisory(
        StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt, string? mbState = null)
    {
        bool pvGood = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good;
        double curPv = pvGood ? smp!.Value : double.NaN;
        int    trend = rec is double r && Num.IsFinite(stt.LastRec)
                       ? Math.Sign(r - stt.LastRec) : 0;
        if (rec is double rr) stt.LastRec = rr;
        double? gap = (rec is double g && Num.IsFinite(curPv)) ? g - curPv : null;

        // WO-1 P-5: per-stream confidence downgrade
        bool mbBad = s.Role == StreamRole.Commanded && mbState is not null && mbState.Contains("불일치");
        var (grade, reason) = Downgrade(s.Grade,
            (!pvGood, "PV 신선도 불량"),
            (transient, "과도 상태"),
            (mbBad, "물질수지 불일치"));

        return new StreamAdvisory(s.Key, s.FlowTag, s.Role, curPv, rec, gap, trend,
            !transient && s.Role != StreamRole.Monitor, grade, s.LevelTag, note)
            with { GradeReason = reason };
    }

    // ── WO-1 P-5 confidence downgrade ───────────────────────────────────
    private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit, string why)[] rules)
    {
        int lvl = (int)baseG;
        string? why = null;
        foreach (var (hit, w) in rules)
            if (hit) { lvl = Math.Min(2, lvl + 1); why = why is null ? w : why + "; " + w; }
        return ((Confidence)lvl, why);
    }

    private static bool TryStreamPv(PvSnapshot pv, string key, out double v)
    {
        v = double.NaN;
        if (pv.Streams.TryGetValue(key, out var s) && s.Good && Num.IsFinite(s.Value)) { v = s.Value; return true; }
        return false;
    }

    private static void SeedAll(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff)
    {
        st.FeedDeriv.Update(ff, cfg.ScanSec);
        foreach (var s in cfg.Streams)
        {
            var stt = st.Stream(s.Key);
            double seed = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : ff * s.TargetCoeff;
            stt.Lag.Seed(seed);
            stt.Rate.Seed(seed);
            stt.LastRec = seed;
        }
    }

    private AdvisoryResult Hold(ColumnConfig cfg, ColumnState st, DateTime now, string reason)
    {
        var outs = cfg.Streams.Select(s =>
        {
            var stt = st.Stream(s.Key);
            double? rec = Num.IsFinite(stt.LastRec) ? stt.LastRec : (double?)null;
            return new StreamAdvisory(s.Key, s.FlowTag, s.Role, double.NaN, rec, null, 0,
                false, s.Grade, s.LevelTag, $"홀드: {reason}");
        }).ToList();
        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, true, reason,
            st.FeedFilter.Value, outs, null, null, $"홀드: {reason}");
    }
}
