using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
///
/// The creator's own video rate, per tier — the latitude the tier table deliberately does not take
/// away. A creator who knows their upload can set any rate from the tier's YouTube minimum upward;
/// nothing here invents a ceiling, because a wrong guess is what live step-down exists to catch.
///
/// Why per tier: a number that is sensible at 1080p60 is wasteful at 720p30, and the
/// creator who tuned one tier has not tuned the other. Unset means "use the shipped default", which
/// is what substitutes.
///
/// Why this matters now: with CBR holding the target (FfmpegArgs.AddRateControl)
/// the number the creator types is the number that goes out, so it is a real quality choice rather
/// than an average to undershoot.
///
public partial class MainViewModel
{
private readonly Dictionary _ratedMbpsByTier = new(StringComparer.Ordinal);
/// The tier with the creator's rate substituted for the shipped default. Every consumer
/// that builds encoder options or a step-down policy must use this, never the raw tier.
public QualityOption EffectiveQuality(QualityOption tier)
=> RatedFor(tier) is var rated && Math.Abs(rated - tier.Bitrate) > 0.001
? tier with { Bitrate = rated }
: tier;
private double RatedFor(QualityOption tier)
{
if (_ratedMbpsByTier.TryGetValue(tier.Display, out var cached)) return cached;
// The constructor resolves the default tier before the layout store exists, so the first read
// must not cache: a cached default would shadow the creator's stored rate for that run.
var stored = _layoutStore is null ? null : _layoutStore.LoadRatedMbps(tier.Display);
var value = Math.Max(stored ?? tier.Bitrate, RateLadder.For(tier).Minimum);
if (_layoutStore is not null) _ratedMbpsByTier[tier.Display] = value;
return value;
}
/// The rate the selected tier will stream at, in Mbps.
public double RatedMbps
{
get => RatedFor(SelectedQuality);
set
{
var tier = SelectedQuality;
var band = RateLadder.For(tier);
// Below the tier minimum YouTube will not hold the resolution; above ~100 is not a stream.
var clamped = Math.Clamp(Math.Round(value, 1), band.Minimum, 100.0);
if (Math.Abs(clamped - RatedFor(tier)) < 0.05)
{
OnPropertyChanged(nameof(RatedMbps));
return;
}
_ratedMbpsByTier[tier.Display] = clamped;
_layoutStore.SaveRatedMbps(tier.Display, clamped);
ApplyStreamQuality(tier);
OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint));
}
}
/// What YouTube expects for this tier, shown next to the field.
public string RateHint
{
get
{
var band = RateLadder.For(SelectedQuality);
return $"YouTube recommends {band.Recommended:0.#} Mbps for {SelectedQuality.Display}; " +
$"it will not take less than {band.Minimum:0.#}. Your connection must carry this " +
"plus ~20% headroom.";
}
}
/// Restores the shipped default for the selected tier.
public void ResetRatedMbps()
{
var tier = SelectedQuality;
if (!_ratedMbpsByTier.ContainsKey(tier.Display)) return;
_ratedMbpsByTier.Remove(tier.Display);
_layoutStore.SaveRatedMbps(tier.Display, null);
_ratedMbpsByTier[tier.Display] = RatedFor(tier);
ApplyStreamQuality(tier);
OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint));
}
// ─── Between-session suggestion (Models/RateHistory) ───
//
// Never applied on its own. The creator is asked once per materially different answer and may say
// "keep mine"; that refusal is remembered against the proposed value, so the same question is not
// asked again until the evidence actually moves.
private RateHistory.Proposal? _rateProposal;
public bool HasRateProposal => _rateProposal is not null;
public string? RateProposalText
{
get
{
if (_rateProposal is not { } proposal) return null;
var tier = SelectedQuality.Display;
return proposal.Raise
? $"Your last {RateHistory.Window} sessions on {tier} held {proposal.Mbps:0.#} Mbps. " +
$"Start the next stream there instead of {RatedMbps:0.#}?"
: $"Your last {RateHistory.Window} sessions on {tier} had to drop quality. " +
$"Start the next stream at {proposal.Mbps:0.#} Mbps instead of {RatedMbps:0.#}?";
}
}
public ICommand AcceptRateProposalCommand { get; private set; } = null!;
public ICommand DismissRateProposalCommand { get; private set; } = null!;
private void InitializeRateProposalCommands()
{
AcceptRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is not { } proposal) return;
RatedMbps = proposal.Mbps; // explicit consent is the only path that changes it
ClearRateProposal();
});
DismissRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is { } proposal)
_layoutStore.SaveDismissedRateProposal(SelectedQuality.Display, proposal.Mbps);
ClearRateProposal();
});
}
/// Recomputes the suggestion for the selected tier. Called at startup and whenever the tier
/// or the rate changes; silent unless the evidence warrants a change and the creator has not already
/// refused this exact number.
internal void RefreshRateProposal()
{
_rateProposal = null;
if (_layoutStore is not null)
{
var tier = SelectedQuality;
var history = _layoutStore.LoadRateHistory();
// Before this run's first stream the encoder is unknown; the last session on this tier is
// the honest guess, and evidence about a different encoder stays out either way.
var lastForTier = history.LastOrDefault(entry => entry.Tier == tier.Display);
// The pump is built later in the constructor than this runs, so before the first session
// there is no encoder to ask; the last session on this tier is the honest guess, and
// evidence about a different encoder stays out either way.
var encoder = (_framePump is { } pump ? pump.EncoderName : null)
?? (string.IsNullOrEmpty(lastForTier.Encoder) ? null : lastForTier.Encoder);
var suggestion = RateHistory.Suggest(history, tier.Display, encoder, RatedMbps);
if (suggestion is { } candidate
&& _layoutStore.LoadDismissedRateProposal(tier.Display) != candidate.Mbps)
{
_rateProposal = candidate;
}
}
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
private void ClearRateProposal()
{
_rateProposal = null;
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
}