using Hc900Crawler.Core.Application.Feedforward;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Core.Domain.Entities;
using Hc900Crawler.Infrastructure.Hc900;
using Hc900Crawler.Infrastructure.Kb;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace Hc900Crawler.Web.Controllers;

[ApiController]
[Route("api/ff")]
public sealed class FeedforwardController : ControllerBase
{
    private readonly IFeedforwardAdvisoryStore _store;
    private readonly IFeedforwardConfigStore   _config;
    private readonly IFeedforwardAuditService  _audit;
    private readonly IFeedforwardWriteGuard    _writeGuard;
    private readonly Hc900WriteService         _writeClient;
    private readonly IKbAuthService            _auth;
    private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig;
    private readonly Hc900Crawler.Infrastructure.Control.FeedforwardSupervisor _supervisor;
    public FeedforwardController(
        IFeedforwardAdvisoryStore store,
        IFeedforwardConfigStore   config,
        IFeedforwardAuditService  audit,
        IFeedforwardWriteGuard    writeGuard,
        Hc900WriteService         writeClient,
        IKbAuthService            auth,
        Microsoft.Extensions.Configuration.IConfiguration appConfig,
        Hc900Crawler.Infrastructure.Control.FeedforwardSupervisor supervisor,
        ISimOverrideStore sim,
        Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService ramp,
        ICompositionStore composition,
        IFeedRampJobStore rampJobs,
        IExperionDbService db,
        IFfTrackingStore tracking)
    { _store = store; _config = config; _audit = audit; _writeGuard = writeGuard;
      _writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor;
      _sim = sim; _ramp = ramp; _composition = composition; _rampJobs = rampJobs; _db = db; _tracking = tracking; }

    private readonly IFfTrackingStore _tracking;

    private readonly ISimOverrideStore _sim;
    private readonly Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService _ramp;
    private readonly ICompositionStore _composition;
    private readonly IFeedRampJobStore _rampJobs;
    private readonly IExperionDbService _db;

    // realtime_table에서 현재값(예: WSP) 1개 best-effort 조회 — 되돌리기/이전값 표시용
    private async Task<double?> TryReadCurrentAsync(string tag, CancellationToken ct)
    {
        try
        {
            var rows = await _db.GetRealtimeRecordsByTagNamesAsync(new[] { tag });
            var r = rows.FirstOrDefault();
            if (r?.LiveValue is not null &&
                double.TryParse(r.LiveValue, System.Globalization.NumberStyles.Float,
                                System.Globalization.CultureInfo.InvariantCulture, out var v))
                return v;
        }
        catch { /* best-effort */ }
        return null;
    }

    private async Task<bool> AuthAsync(CancellationToken ct)
        => await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);

    // ── 설정 CRUD ──
    [HttpGet("config")]
    public async Task<IActionResult> GetConfig(CancellationToken ct)
    {
        var cols = await _config.LoadAllAsync(ct);
        return Ok(new { columns = cols.Select(MapConfig) });
    }

    [HttpPost("config")]
    public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
    {
        if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
        var id = await _config.SaveColumnAsync(body, ct);
        return Ok(new { success = true, id });
    }

    [HttpDelete("config/{id:int}")]
    public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
    {
        if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
        await _config.DeleteColumnAsync(id, ct);
        return Ok(new { success = true });
    }

    // ── WO-6 전환류 ARM/취소 ──
    [HttpPost("recovery/{id:int}/arm")]
    public IActionResult ArmRecovery(int id) => Ok(new { success = _supervisor.Arm(id) });

    [HttpPost("recovery/{id:int}/cancel")]
    public IActionResult CancelRecovery(int id) => Ok(new { success = _supervisor.Cancel(id) });

    // ── Phase II: 수동 SP 쓰기 ──
    [HttpPost("write/{columnId:int}/{streamKey}")]
    public async Task<IActionResult> WriteSp(int columnId, string streamKey, [FromBody] WriteSpBody body, CancellationToken ct)
    {
        if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
        var advisory = _store.Get(columnId);
        if (advisory is null) return NotFound(new { error = "advisory 없음" });
        var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
        if (cfg is null) return NotFound(new { error = "config 없음" });
        var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
        if (sc is null) return NotFound(new { error = "stream 없음" });
        // SP 쓰기 대상 = flow 태그에서 WSP(.SP, offset 0x04 RW) 자동 파생. SpNodeId는 선택적 override.
        var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
        if (string.IsNullOrWhiteSpace(spTag))
            return BadRequest(new { error = "flow 태그가 없어 SP 대상 산출 불가" });
        var adv = advisory.Streams.FirstOrDefault(a => a.Key == streamKey);
        if (adv is null) return NotFound(new { error = "stream advisory 없음" });

        double spVal = body.value ?? (adv.RecommendedSp ?? double.NaN);
        if (double.IsNaN(spVal)) return BadRequest(new { error = "SP 값 없음" });

        // 범위 클램프(§3.3) 후 WriteGuard 검증 — manualOverride=true(AdvisoryOnly만 우회, 나머지 가드 유지)
        spVal = Math.Clamp(spVal, sc.SpMin, sc.SpMax);
        var check = _writeGuard.Check(cfg, adv, sc, advisory, manualOverride: true);
        if (!check.Allowed)
            return BadRequest(new { error = $"WriteGuard 차단: {check.Reason}" });

        // 되돌리기용: 쓰기 전 현재 WSP 값을 캡처(realtime_table)
        double? prevSp = await TryReadCurrentAsync(spTag, ct);

        // 컨트롤러는 태그→controller_id 매핑에서 해석(DB). 컬럼 ControllerId에 의존하지 않음.
        var ctrlId = await _db.GetControllerIdForTagAsync(spTag);
        if (string.IsNullOrWhiteSpace(ctrlId))
            return BadRequest(new { error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" });

        // HC900 gRPC 쓰기 (WSP 태그, 해석된 컨트롤러로 라우팅)
        var (success, error) = await _writeClient.WriteTagAsync(ctrlId, spTag, spVal);

        // 감사 로그(이전값 포함 — 되돌리기 근거)
        await _audit.LogAsync(new FfActionLogEntry(columnId, "sp_write",
            StreamKey: streamKey, SpValue: spVal, NodeId: spTag,
            Result: success ? $"success (prev={prevSp?.ToString("F2") ?? "n/a"})" : $"error: {error}",
            OperatorName: "manual"), ct);

        if (!success)
            return StatusCode(502, new { error = $"HC900 쓰기 실패: {error}" });

        return Ok(new { success = true, streamKey, nodeId = spTag, value = spVal, previousSp = prevSp });
    }

    // ── 측류 추종 ON/OFF/원복 ───────────────────────────────────────
    // ON: baseline(현재 WSP) 캡처 후 추종 시작 → Supervisor가 매 주기 권장 WSP를 연속 씀
    [HttpPost("track/{columnId:int}/{streamKey}/on")]
    public async Task<IActionResult> TrackOn(int columnId, string streamKey, CancellationToken ct)
    {
        var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
        if (cfg is null) return NotFound(new { error = "config 없음" });
        var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
        if (sc is null) return NotFound(new { error = "stream 없음" });
        if (sc.Role == StreamRole.Monitor) return BadRequest(new { error = "Monitor 스트림은 추종 대상 아님" });
        var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
        if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { error = "flow 태그 없음 — SP 대상 산출 불가" });

        _tracking.Set(new FfTrackingState
        {
            ColumnId = columnId, StreamKey = streamKey, Enabled = true,
            StartedAt = DateTime.UtcNow, Operator = "manual"
        });
        await _audit.LogAsync(new FfActionLogEntry(columnId, "track_on",
            StreamKey: streamKey, NodeId: spTag, Result: "started", OperatorName: "manual"), ct);
        return Ok(new { success = true, streamKey });
    }

    // OFF(=취소): 추종 중지. 컨트롤러는 마지막 값 유지.
    [HttpPost("track/{columnId:int}/{streamKey}/off")]
    public async Task<IActionResult> TrackOff(int columnId, string streamKey, CancellationToken ct)
    {
        var cur = _tracking.Get(columnId, streamKey);
        _tracking.Set(new FfTrackingState
        {
            ColumnId = columnId, StreamKey = streamKey, Enabled = false,
            StartedAt = cur?.StartedAt, Operator = "manual"
        });
        await _audit.LogAsync(new FfActionLogEntry(columnId, "track_off",
            StreamKey: streamKey, Result: "stopped", OperatorName: "manual"), ct);
        return Ok(new { success = true });
    }

    // ── Phase II: 감사 로그 조회 ──
    [HttpGet("audit")]
    public async Task<IActionResult> GetAudit([FromQuery] int? columnId, [FromQuery] int limit = 50, CancellationToken ct = default)
    {
        if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
        var rows = await _audit.QueryAsync(columnId, limit, ct);
        return Ok(new { rows });
    }

    private object MapConfig(ColumnConfig c) => new
    {
        id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly,
        controllerId = c.ControllerId,
        feedSpNodeId = c.FeedSpNodeId, feedSpMin = c.FeedSpMin,
        feedSpMax = double.IsInfinity(c.FeedSpMax) || c.FeedSpMax >= double.MaxValue ? 1e9 : c.FeedSpMax,
        feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
        scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
        feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
        pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey,
        tempTags = c.TempTags, sensitiveTrayTag = c.SensitiveTrayTag,
        dtdp = c.DTdP, pRef = double.IsNaN(c.PRef) ? (double?)null : c.PRef,
        steamOpTag = c.SteamOpTag, thetaAutoTune = c.ThetaAutoTune,
        biasMaWindowSec = c.BiasMaWindowSec,
        recoveryEnabled = c.RecoveryEnabled, recoveryAutoArm = c.RecoveryAutoArm,
        imbalanceTriggerFrac = c.ImbalanceTriggerFrac, imbalanceTriggerSec = c.ImbalanceTriggerSec,
        recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec,
        feedRecoverySp = c.FeedRecoverySp,
        deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit,
        tempHighLimit = c.TempHighLimit,
        tempLowLimit = c.TempLowLimit,
        tcReturnRebTarget = double.IsNaN(c.TcReturnRebTarget) ? (double?)null : c.TcReturnRebTarget,
        tcReturnRebBand = c.TcReturnRebBand,
        tcReturnDeltaAdRef = double.IsNaN(c.TcReturnDeltaAdRef) ? (double?)null : c.TcReturnDeltaAdRef,
        tcReturnDeltaAdBand = c.TcReturnDeltaAdBand,
        tcReturnTcTarget = double.IsNaN(c.TcReturnTcTarget) ? (double?)null : c.TcReturnTcTarget,
        tcReturnTcBand = c.TcReturnTcBand,
        streams = c.Streams.Select(s => new
        {
            key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff,
            thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
            spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin,
            refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString(),
            isReflux = s.IsReflux, recoverySp = double.IsNaN(s.RecoverySp) ? (double?)null : s.RecoverySp,
            spNodeId = s.SpNodeId
        })
    };

    // ── WP3: Feed Ramp Advisor (read-only) ────────────────────────
    [HttpGet("ramp-advisor")]
    public async Task<IActionResult> RampAdvisor(
        [FromQuery] int columnId, [FromQuery] double targetFeed,
        [FromQuery] double deltaIAllow = 50, [FromQuery] double sensibleGain = double.NaN,
        [FromQuery] double feedTempRef = double.NaN, [FromQuery] double floodLimit = double.NaN,
        [FromQuery] double n = 1.8, CancellationToken ct = default)
    {
        var a = await _ramp.ComputeAsync(columnId, targetFeed, deltaIAllow, sensibleGain, feedTempRef, floodLimit, n, ct);
        if (a is null) return NotFound(new { error = "config 없음" });
        return Ok(MapRamp(a));
    }

    // ── 작업 B: FEED 램프 실행 (시작/취소/상태) ───────────────────────
    private bool RampDryRun() => _appConfig.GetValue<bool?>("Feedforward:FeedRampDryRun") ?? true;

    [HttpPost("feed-ramp/{columnId:int}/start")]
    public async Task<IActionResult> StartFeedRamp(int columnId, [FromBody] FeedRampStartBody body, CancellationToken ct)
    {
        if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
        if (body is null || double.IsNaN(body.targetFeed) || double.IsInfinity(body.targetFeed))
            return BadRequest(new { error = "targetFeed 값 필요" });

        var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
        if (cfg is null) return NotFound(new { error = "config 없음" });
        if (string.IsNullOrWhiteSpace(FfSpTag.Resolve(cfg.FeedTag, cfg.FeedSpNodeId)))
            return BadRequest(new { error = "Feed 태그 없음 — FEED SP 대상 산출 불가" });

        // 현재 피드 확인 + 업램프만 허용
        var adv = await _ramp.ComputeAsync(columnId, body.targetFeed, 50, double.NaN, double.NaN, double.NaN, 1.8, ct);
        if (adv is null) return NotFound(new { error = "config 없음" });
        if (adv.Hold) return BadRequest(new { error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" });

        bool dryRun = RampDryRun() || _sim.Enabled;
        var job = _rampJobs.Start(columnId, body.targetFeed, "manual", dryRun);

        await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_start",
            SpValue: body.targetFeed, NodeId: cfg.FeedSpNodeId,
            Result: dryRun ? "started(dry-run)" : "started", OperatorName: "manual"), ct);

        return Ok(new { success = true, dryRun, job = MapRampJob(job) });
    }

    [HttpPost("feed-ramp/{columnId:int}/cancel")]
    public async Task<IActionResult> CancelFeedRamp(int columnId, CancellationToken ct)
    {
        if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
        bool ok = _rampJobs.Cancel(columnId);
        if (ok)
            await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_cancel",
                Result: "canceled", OperatorName: "manual"), ct);
        return Ok(new { success = ok });
    }

    [HttpGet("feed-ramp/{columnId:int}")]
    public IActionResult GetFeedRamp(int columnId)
    {
        var job = _rampJobs.Get(columnId);
        return Ok(new { dryRun = RampDryRun() || _sim.Enabled, job = job is null ? null : MapRampJob(job) });
    }

    [HttpGet("feed-ramp")]
    public IActionResult GetAllFeedRamp()
        => Ok(new { dryRun = RampDryRun() || _sim.Enabled, jobs = _rampJobs.GetAll().Select(MapRampJob) });

    private static object MapRampJob(FeedRampJob j) => new
    {
        columnId      = j.ColumnId,
        targetFeed    = Fin(j.TargetFeed),
        lastWrittenSp = double.IsNaN(j.LastWrittenSp) ? (double?)null : j.LastWrittenSp,
        state         = j.State.ToString(),
        hold          = j.Hold,
        currentFeed   = j.CurrentFeed,
        ceiling       = j.Ceiling,
        rampRate      = j.RampRate,
        startedAt     = j.StartedAt,
        lastStepAt    = j.LastStepAt,
        dryRun        = j.DryRun,
        warnings      = j.Warnings
    };

    // ── WP0: Sim Override (DEMO 게이트, 입력 치환 — 제어 쓰기 아님) ──
    private bool SimEnabled() => _appConfig.GetValue<bool>("Feedforward:SimOverrideEnabled");

    [HttpGet("sim/override")]
    public IActionResult GetSimOverride()
    {
        if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성(Feedforward:SimOverrideEnabled=false)" });
        return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
    }

    [HttpPost("sim/override")]
    public IActionResult SetSimOverride([FromBody] SimOverrideBody body)
    {
        if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
        _sim.SetMany(body.enabled, body.values ?? new Dictionary<string, double>());
        return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
    }

    [HttpDelete("sim/override")]
    public IActionResult ClearSimOverride()
    {
        if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
        _sim.Clear();
        return Ok(new { enabled = _sim.Enabled });
    }

    // ── WP5 3단계: 조성 분율 수동입력(랩) ──
    [HttpGet("composition")]
    public IActionResult GetComposition() => Ok(new { fractions = _composition.Snapshot() });

    [HttpPost("composition")]
    public IActionResult SetComposition([FromBody] CompositionBody b)
    {
        if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { error = "streamKey 필요" });
        _composition.Set(b.columnId, b.streamKey, b.fraction);
        return Ok(new { fractions = _composition.Snapshot() });
    }

    [HttpDelete("composition/{columnId:int}/{streamKey}")]
    public IActionResult ClearComposition(int columnId, string streamKey)
    { _composition.Clear(columnId, streamKey); return Ok(new { fractions = _composition.Snapshot() }); }

    private static double? Fin(double v) => (double.IsNaN(v) || double.IsInfinity(v)) ? (double?)null : v;
    private static object MapRamp(FeedRampAdvisory a) => new
    {
        columnId = a.ColumnId,
        currentFeed = Fin(a.CurrentFeed), targetFeed = Fin(a.TargetFeed), clampedTarget = Fin(a.ClampedTarget),
        ceiling = new { value = Fin(a.Ceiling.Value), binding = a.Ceiling.Binding },
        rampRate = new { value = Fin(a.RampRate.Value), binding = a.RampRate.Binding },
        rampTimeMin = Fin(a.RampTimeMin),
        steam = new { fiq6115From = a.Steam.Fiq6115From, fiq6115To = a.Steam.Fiq6115To, startOpPct = a.Steam.StartOpPct },
        simOverrideActive = a.SimOverrideActive, hold = a.Hold, warnings = a.Warnings
    };

    // ── Advisory (공개 읽기) ───────────────────────────────────────
    [HttpGet("advisory")]
    public IActionResult GetAll() => Ok(new { columns = _store.GetAll().Select(MapColumn) });

    [HttpGet("advisory/{columnId:int}")]
    public IActionResult Get(int columnId)
    {
        var r = _store.Get(columnId);
        return r is null ? NotFound() : Ok(MapColumn(r));
    }

    private object MapColumn(AdvisoryResult r)
    {
        var streams = r.Streams.Select(s =>
        {
            var (lastSp, lastErr, lastAt) = _supervisor.GetLastWrite(r.ColumnId, s.Key);
            // SP는 flow 태그에서 .SP(WSP) 자동 파생 → Commanded/LevelDriven(피드결정형 D·B)·flow태그 있으면 추종 가능
            bool writable = s.Role != StreamRole.Monitor && !string.IsNullOrWhiteSpace(s.FlowTag);
            var trk = _tracking.Get(r.ColumnId, s.Key);
            return new
            {
                tracking         = trk?.Enabled ?? false,
                key           = s.Key,
                flowTag       = s.FlowTag,
                role          = s.Role.ToString(),
                levelTag      = s.LevelTag,
                pv            = double.IsNaN(s.Pv) ? (double?)null : s.Pv,
                recommendedSp = s.RecommendedSp,
                writable      = writable,   // SP 태그 설정됨 → 측류 SP 쓰기 버튼 노출 가능
                gap           = s.Gap,
                trend         = s.Trend,
                valid         = s.Valid,
                grade         = s.Grade.ToString(),
                note          = s.Note,
                gradeReason   = s.GradeReason,
                thetaSuggestUpSec = s.ThetaSuggestUpSec,
                thetaSuggestDnSec = s.ThetaSuggestDnSec,
                thetaSuggestConf  = s.ThetaSuggestConf,
                kObsSuggest   = s.KObsSuggest,
                // WP5 2·3단계: 조성 base + trim
                compositionBase = s.CompositionBase,
                trim            = s.Trim,
                recommendedSpComposition = s.RecommendedSpComposition,
                trimSource      = s.TrimSource,
                // Phase II: auto-write 결과
                lastWriteSp    = lastSp,
                lastWriteError = lastErr,
                lastWriteAt    = lastAt
            };
        }).ToList();

        return new
        {
            columnId         = r.ColumnId,
            columnName       = r.ColumnName,
            computedAt       = r.ComputedAt,
            enabled          = r.Enabled,
            transient        = r.Transient,
            transientReason  = r.TransientReason,
            feedFiltered     = r.FeedFiltered,
            vLoss            = r.VLoss,
            yield            = r.Yield,
            massBalanceState = r.MassBalanceState,
            mode             = r.Mode.ToString(),
            modeReason       = r.ModeReason,
            feedRecommendedSp = r.FeedRecommendedSp,
            vLossMa          = r.VLossMa,
            frontPositionState = r.FrontPositionState,
            frontTrimAdvice  = r.FrontTrimAdvice,
            tempProfileState = r.TempProfileState,
            inversionPair    = r.InversionPair,
            tempSpan         = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null,
            tempSpanRef      = r.TempSpanRef,
            upperFrontState  = r.UpperFrontState,
            lowerFrontState  = r.LowerFrontState,
            upperFrontMetric = r.UpperFrontMetric,
            lowerFrontMetric = r.LowerFrontMetric,
            autoWriteActive  = r.AutoWriteActive,
            writeGuardBlockedSp = r.WriteGuardBlockedSp,
            writeGuardReason = r.WriteGuardReason,
            temps            = r.Temps?.Select(t => new
            {
                tag  = t.Tag,
                raw  = double.IsNaN(t.Raw) ? (double?)null : t.Raw,
                pct  = double.IsNaN(t.Pct) ? (double?)null : t.Pct,
                good = t.Good
            }),
            streams          = streams
        };
    }
}

public sealed record WriteSpBody { public double? value { get; init; } }

public sealed record FeedRampStartBody { public double targetFeed { get; init; } = double.NaN; }

public sealed record SimOverrideBody
{
    public bool enabled { get; init; }
    public Dictionary<string, double>? values { get; init; }
}

public sealed record CompositionBody
{
    public int columnId { get; init; }
    public string streamKey { get; init; } = "";
    public double fraction { get; init; }
}
