#-----------------------------------------------------------------------------------------------------------------------
# Odometer: lag is whole cents only (sub-cent never shown or funded).
# Chase advances one cent at a time with roll animation; carry uses ..9.999 hold caps.
#-----------------------------------------------------------------------------------------------------------------------
import math
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple


@dataclass
class OdometerConfig:
    max_lag_pennies: int = 1000  # $10.00 — never exceed this gap behind target
    penny_max_lag_pennies: int = 500  # $5.00 — penny chase rate tops out here
    dime_continuous_lag_pennies: int = 0  # legacy layout key (ignored; dimes are intermittent only)
    dime_roll_max_lag_pennies: int = 900  # legacy layout key (ignored; all columns share penny roll ramp)
    penny_roll_max_lag_pennies: int = 0  # penny roll visual tops here; 0 = use penny_max_lag
    min_rate: float = 0.15  # pennies/sec at small lag
    max_rate: float = 0.5  # pennies/sec at penny_max_lag (tier 1 cap)
    dime_max_rate: float = 5.0  # extra pennies/sec added by tier 2 at max_lag
    idle_catchup_sec: float = 25.0  # slow catch-up after contributions stop
    contribution_idle_sec: float = 3.0  # active curve while CSV target is changing
    penny_roll_sec: float = 2.5  # roll duration at penny_max_lag (fastest penny visual)
    penny_roll_sec_min: float = 0.0  # roll duration at zero lag; 0 = penny_roll_sec * 5
    carry_roll_sec: float = 2.5  # legacy layout key (roll uses penny_roll_sec*)
    carry_roll_sec_min: float = 0.0  # legacy layout key


# Module defaults (override via configure_odometer or layout.json "odometer").
_config = OdometerConfig()
_demo_initial_pennies: int = 0  # $0.00 placeholder until layout sets initial_display

MAX_LAG_PENNIES = _config.max_lag_pennies
PENNIES_DIGIT_MIN_RATE = _config.min_rate
PENNIES_DIGIT_MAX_RATE = _config.max_rate
IDLE_CATCHUP_SEC = _config.idle_catchup_sec
CONTRIBUTION_IDLE_SEC = _config.contribution_idle_sec


def get_odometer_config() -> OdometerConfig:
    return _config


def get_demo_initial_pennies() -> int:
    """Starting target/display (cents) from layout.json odometer block."""
    return _demo_initial_pennies


def _penny_roll_ramp_lag(cfg: OdometerConfig) -> int:
    """Lag (cents) at which penny roll animation reaches full speed."""
    if cfg.penny_roll_max_lag_pennies > 0:
        return cfg.penny_roll_max_lag_pennies
    return cfg.penny_max_lag_pennies


def slowest_roll_sec(cfg: Optional[OdometerConfig] = None) -> float:
    """Slowest full-digit roll (penny_roll_sec_min); shared by all intermittent columns."""
    c = cfg or get_odometer_config()
    fast = max(c.penny_roll_sec, 1e-6)
    return c.penny_roll_sec_min if c.penny_roll_sec_min > 0 else fast * 5.0


def carry_roll_sec_for_power(
    power: int,
    cfg: Optional[OdometerConfig] = None,
    lag_pennies: int = -1,
) -> float:
    """Seconds per full 9->0 roll; same lag ramp for every column (pennies..100ks)."""
    del power
    c = cfg or get_odometer_config()
    lag = lag_pennies if lag_pennies >= 0 else _penny_roll_ramp_lag(c)
    return _penny_roll_sec_at_lag(lag, c)


def _penny_roll_sec_at_lag(delta: int, cfg: Optional[OdometerConfig] = None) -> float:
    """Seconds per full penny-column roll; linear 0 lag -> penny_roll_max_lag."""
    c = cfg or get_odometer_config()
    fast = max(c.penny_roll_sec, 1e-6)
    slow = c.penny_roll_sec_min if c.penny_roll_sec_min > 0 else fast * 5.0
    cap = max(_penny_roll_ramp_lag(c), 1)
    t = min(max(0, delta), cap) / cap
    return slow + t * (fast - slow)


def format_odometer_config_summary(cfg: Optional[OdometerConfig] = None) -> str:
    """One-line summary for console (two-tier chase, one roll ramp for all digits)."""
    c = cfg or get_odometer_config()
    roll_cap = _penny_roll_ramp_lag(c)
    roll_slow = _penny_roll_sec_at_lag(0, c)
    roll_fast = _penny_roll_sec_at_lag(roll_cap, c)
    from ethergold_reader import format_dollars

    return (
        f"start={format_dollars(get_demo_initial_pennies())}  "
        f"chase@{c.penny_max_lag_pennies}c  roll@{roll_cap}c {roll_slow:.2f}s->{roll_fast:.2f}s  "
        f"dimes=intermittent  {c.min_rate}->{c.max_rate}+{c.dime_max_rate}c/s @ {c.max_lag_pennies}c"
    )


def format_odometer_lag_map(cfg: Optional[OdometerConfig] = None) -> str:
    """Multi-line lag -> chase / roll cap / effective (what you actually see)."""
    c = cfg or get_odometer_config()
    e = OdometerEngine(0)
    lines = [
        "lag($)  lag(c)  chase  roll_s  roll_cap  EFFECTIVE",
    ]
    for dollars in (
        0.25,
        0.5,
        1.0,
        2.0,
        3.0,
        4.0,
        4.5,
        4.75,
        4.99,
        5.0,
        6.0,
        7.0,
        7.5,
        8.0,
        8.5,
        8.99,
        9.0,
        10.0,
    ):
        lag = int(round(dollars * 100))
        chase = e._pennies_per_second(lag)
        roll = _penny_roll_sec_at_lag(lag, c)
        rcap = 1.0 / roll
        eff = min(chase, rcap)
        lines.append(
            f"  ${dollars:4.2f}  {lag:5d}  {chase:5.2f}  {roll:5.2f}  {rcap:5.2f}  {eff:5.2f}"
        )
    return "\n".join(lines)


def _apply_initial_display_from_merged(merged: Dict[str, Any]) -> None:
    """Read initial_display / initial_dollars / initial_pennies from layout odometer block."""
    global _demo_initial_pennies
    from ethergold_reader import parse_display_to_pennies

    for key in ("initial_display", "initial_dollars", "initial_pennies"):
        if key in merged:
            _demo_initial_pennies = parse_display_to_pennies(
                merged[key], default=_demo_initial_pennies
            )
            return


def configure_odometer(data: Optional[Dict[str, Any]] = None, **kwargs: Any) -> OdometerConfig:
    """Apply tuning from layout.json ``odometer`` block and/or keyword args."""
    global MAX_LAG_PENNIES, PENNIES_DIGIT_MIN_RATE, PENNIES_DIGIT_MAX_RATE
    global IDLE_CATCHUP_SEC, CONTRIBUTION_IDLE_SEC
    cfg = _config
    merged: Dict[str, Any] = {}
    if data:
        merged.update(data)
    merged.update(kwargs)
    _apply_initial_display_from_merged(merged)
    key_map = {
        "max_lag_pennies": "max_lag_pennies",
        "penny_max_lag_pennies": "penny_max_lag_pennies",
        "penny_max_lag": "penny_max_lag_pennies",
        "penny_roll_max_lag_pennies": "penny_roll_max_lag_pennies",
        "penny_roll_max_lag": "penny_roll_max_lag_pennies",
        "dime_continuous_lag_pennies": "dime_continuous_lag_pennies",
        "dime_continuous_lag": "dime_continuous_lag_pennies",
        "dime_roll_max_lag_pennies": "dime_roll_max_lag_pennies",
        "dime_roll_max_lag": "dime_roll_max_lag_pennies",
        "min_rate": "min_rate",
        "max_rate": "max_rate",
        "dime_max_rate": "dime_max_rate",
        "idle_catchup_sec": "idle_catchup_sec",
        "contribution_idle_sec": "contribution_idle_sec",
        # legacy / short names
        "max_lag": "max_lag_pennies",
        "pennies_min_rate": "min_rate",
        "pennies_max_rate": "max_rate",
        "penny_roll_sec": "penny_roll_sec",
        "penny_roll_sec_min": "penny_roll_sec_min",
        "carry_roll_sec": "carry_roll_sec",
        "carry_roll_sec_min": "carry_roll_sec_min",
        "dime_roll_sec": "carry_roll_sec",
        "dime_roll_sec_min": "carry_roll_sec_min",
    }
    for src, dst in key_map.items():
        if src in merged:
            setattr(cfg, dst, merged[src])
    MAX_LAG_PENNIES = cfg.max_lag_pennies
    PENNIES_DIGIT_MIN_RATE = cfg.min_rate
    PENNIES_DIGIT_MAX_RATE = cfg.max_rate
    IDLE_CATCHUP_SEC = cfg.idle_catchup_sec
    CONTRIBUTION_IDLE_SEC = cfg.contribution_idle_sec
    return cfg

NUM_COLUMNS = 8
# Column powers (RTL draw): 0=pennies, 1=dimes, 2=dollars, 3=10s, 4=100s, 5=1Ks,
# 6=10Ks, 7=100Ks. Carry chain runs on powers 1..7 with the same rules each.
MIN_DISPLAY_POWER = 2


def _pennies_to_digits(pennies: int) -> List[int]:
    pennies = max(0, int(pennies))
    return [(pennies // (10**i)) % 10 for i in range(NUM_COLUMNS)]


def leftmost_significant_power(digits: List[int]) -> int:
    """Highest power column to show (hide leading zeros to the left)."""
    for power in range(NUM_COLUMNS - 1, -1, -1):
        if digits[power] != 0:
            return power
    return MIN_DISPLAY_POWER


class OdometerEngine:
    def __init__(self, initial_target: int = 0):
        t = max(0, int(initial_target))
        self.target_pennies: int = t
        self.display_float: float = float(t)
        self._last_target_change_time: float = time.perf_counter()
        self._carry_scroll_phase: List[float] = [0.0] * NUM_COLUMNS
        self._carry_finishing: List[bool] = [False] * NUM_COLUMNS
        self._carry_from_digit: List[Optional[int]] = [None] * NUM_COLUMNS
        self._carry_was_in: List[bool] = [False] * NUM_COLUMNS
        self._last_maintained_lag: Optional[int] = None
        self._penny_scroll_visual: float = 0.0
        self._smoothed_roll_sec: float = slowest_roll_sec()

    def set_target(self, target_pennies: Optional[int]) -> None:
        if target_pennies is None:
            return
        t = max(0, int(target_pennies))
        if t != self.target_pennies:
            self.target_pennies = t
            self._last_target_change_time = time.perf_counter()

    def bump_target(self, delta_pennies: int) -> None:
        """Adjust target by delta (pennies), clamped at zero; refreshes chase rate clock."""
        self.set_target(self.target_pennies + int(delta_pennies))

    @property
    def display_pennies(self) -> int:
        """Funded/displayed value in whole cents only (sub-cent never shown)."""
        return self._settled_cents()

    def _settled_cents(self) -> int:
        return int(self.display_float)

    def snap_if_won(self) -> bool:
        """
        Snap display to target only on a real win/reset (target far below display).
        Small overshoot (<= max_lag) is chased down — not snapped like a jackpot.
        """
        cfg = get_odometer_config()
        overshoot = int(self.display_float) - self.target_pennies
        if overshoot <= cfg.max_lag_pennies:
            return False
        self._snap_display_to_settled_cents(self.target_pennies)
        return True

    def _contributions_active(self) -> bool:
        cfg = get_odometer_config()
        if self.lag_pennies() > 0:
            return True
        return (time.perf_counter() - self._last_target_change_time) < cfg.contribution_idle_sec

    def pennies_per_second(self, delta: Optional[int] = None) -> float:
        """Chase rate from lag curve (may exceed visible roll speed)."""
        if delta is None:
            delta = self.lag_pennies()
        return self._pennies_per_second(delta)

    def effective_pennies_per_second(self, delta: Optional[int] = None) -> float:
        """Actual display advance rate after roll + intermittent ceiling."""
        if delta is None:
            delta = self.lag_pennies()
        if delta <= 0:
            return 0.0
        chase = min(self._pennies_per_second(delta), self._chase_animation_cap(delta))
        ceil = self._intermittent_display_ceiling(1 / 60.0)
        headroom = max(0.0, ceil - float(self._settled_cents())) * 60.0
        return min(chase, headroom)

    def _pennies_per_second(self, delta: int) -> float:
        cfg = get_odometer_config()
        if delta <= 0:
            return 0.0
        if not self._contributions_active():
            return max(cfg.min_rate, delta / cfg.idle_catchup_sec)
        penny_cap = max(cfg.penny_max_lag_pennies, 1)
        if delta <= penny_cap:
            t = delta / penny_cap
            return cfg.min_rate + t * (cfg.max_rate - cfg.min_rate)
        penny_rate = cfg.max_rate
        band = max(cfg.max_lag_pennies - cfg.penny_max_lag_pennies, 1)
        excess = min(delta - cfg.penny_max_lag_pennies, band)
        t = excess / band
        return penny_rate + t * cfg.dime_max_rate

    def _chase_animation_cap(self, delta: int) -> float:
        """Max display advance (¢/s); tied to lag-scaled penny roll (chase / gap)."""
        cfg = get_odometer_config()
        return 1.0 / _penny_roll_sec_at_lag(
            min(max(0, delta), _penny_roll_ramp_lag(cfg)), cfg
        )

    def _column_fraction(self, display_float: float, power: int) -> float:
        """0..1 progress through the current digit at this column."""
        unit = 10**power
        return (display_float % unit) / unit

    def _snap_display_to_settled_cents(self, cents: Optional[int] = None) -> None:
        """Whole-cent display only; clear carry hold and scroll state."""
        value = self._settled_cents() if cents is None else max(0, int(cents))
        self.display_float = float(value)
        self._penny_scroll_visual = 0.0
        self._smoothed_roll_sec = slowest_roll_sec()
        self._clear_all_carry_scroll()

    def _discard_sub_cent_slop(self) -> None:
        """Sub-cent in display_float is not funded; snap to whole cents when idle."""
        settled = self._settled_cents()
        if abs(self.display_float - settled) > 1e-9:
            self.display_float = float(settled)
        if self.lag_pennies() <= 0:
            self._penny_scroll_visual = 0.0
            self._clear_all_carry_scroll()

    def _smooth_roll_sec(self, dt: float) -> float:
        """Exponentially smooth roll duration toward the lag-derived target.

        One-sided: deceleration is immediate; acceleration eases in.
        """
        cfg = get_odometer_config()
        target = _penny_roll_sec_at_lag(
            min(max(1, self.lag_pennies()), _penny_roll_ramp_lag(cfg)), cfg
        )
        if target >= self._smoothed_roll_sec:
            self._smoothed_roll_sec = target
        else:
            tau = 0.4
            alpha = 1.0 - math.exp(-dt / tau)
            self._smoothed_roll_sec += (target - self._smoothed_roll_sec) * alpha
        return self._smoothed_roll_sec

    def _penny_roll_step(self, dt: float) -> float:
        return dt / max(self._smoothed_roll_sec, 1e-6)

    def _chase_needs_penny_carry_chain(self) -> bool:
        """+1¢ from ..x9 requires full carry animation, not a lone penny roll."""
        settled = self._settled_cents()
        gap = self.target_pennies - settled
        return gap > 0 and (settled % 10) == 9

    def _chase_cent_up(self, dt: float) -> None:
        """Advance one whole cent at a time; animation only, not funded until settled bumps."""
        settled = self._settled_cents()
        if self.target_pennies <= settled:
            return
        if self._chase_needs_penny_carry_chain():
            step = self._penny_roll_step(dt)
            self._penny_scroll_visual = min(1.0, self._penny_scroll_visual + step)
            return
        step = self._penny_roll_step(dt)
        self._penny_scroll_visual = min(1.0, self._penny_scroll_visual + step)
        if self._penny_scroll_visual >= 1.0 - 1e-9:
            self._penny_scroll_visual = 0.0
            self.display_float = float(min(settled + 1, self.target_pennies))

    def _chase_cent_down(self, dt: float) -> None:
        settled = self._settled_cents()
        if settled <= self.target_pennies:
            return
        step = self._penny_roll_step(dt)
        if self._penny_scroll_visual > 1e-6:
            self._penny_scroll_visual = max(0.0, self._penny_scroll_visual - step)
            if self._penny_scroll_visual <= 1e-9:
                self._penny_scroll_visual = 0.0
                self.display_float = float(max(settled - 1, self.target_pennies))
            return
        self.display_float = float(max(settled - 1, self.target_pennies))

    def update(self, dt: float) -> None:
        if dt <= 0:
            return
        self._smooth_roll_sec(dt)
        self.snap_if_won()
        settled = self._settled_cents()
        if self.target_pennies > settled:
            self._chase_cent_up(dt)
        elif self.target_pennies < settled:
            self._chase_cent_down(dt)
            self._sync_intermittent_phases_to_display()
        else:
            self._discard_sub_cent_slop()
        self._update_intermittent_scroll(dt)

    def _clear_carry_scroll(self, power: int) -> None:
        self._carry_scroll_phase[power] = 0.0
        self._carry_finishing[power] = False
        self._carry_from_digit[power] = None
        self._carry_was_in[power] = False

    def _clear_all_carry_scroll(self) -> None:
        for power in range(NUM_COLUMNS):
            self._clear_carry_scroll(power)

    def _sync_intermittent_phases_to_display(self) -> None:
        """After lag drops, phase must not stay ahead of display (causes dime pop)."""
        df = self.display_float
        for power in range(1, NUM_COLUMNS):
            phase = self._carry_scroll_phase[power]
            if phase <= 1e-6:
                continue
            unit = 10**power
            if self._column_cascade_active(power):
                target = self._intermittent_scroll_target(power)
            elif self._carry_finishing[power]:
                target = self._column_fraction(df, power)
            else:
                continue
            if phase > target + 1e-6:
                self._carry_scroll_phase[power] = target

    @staticmethod
    def _digit_at_power(settled_cents: int, power: int) -> int:
        return (settled_cents // (10**power)) % 10

    def _display_is_chasing(self) -> bool:
        """True while whole-cent display lags target (sub-cent hold does not count)."""
        return self.lag_pennies() > 0

    def _carry_roll_in_progress(self, power: int) -> bool:
        """True only while a column is mid-roll (not after phase has reached 1.0)."""
        phase = self._carry_scroll_phase[power]
        return (phase > 1e-6 and phase < 1.0 - 1e-9) or self._carry_finishing[power]

    def _column_cascade_active(self, power: int) -> bool:
        """
        True when this column should roll in the carry chain (powers 1..7).
        Power 1 (dimes): when pennies are in the 9 carry window.
        Powers 2..7: when this digit is 9 and lower places are all 9, OR when this
        digit is not 9 but the column to the right is actively carrying (overflow
        N->N+1, e.g. 1->2 at $199,999.99). Same rule at every power; not special-cased.
        """
        if power <= 0:
            return False
        if self._carry_roll_in_progress(power):
            return True
        if not self._display_is_chasing():
            return False
        settled = self._settled_cents()
        if power == 1:
            return self._column_in_carry(settled, 1)
        if not self._column_in_carry(settled, power):
            return False
        if self._digit_at_power(settled, power) >= 9:
            return True
        lower = power - 1
        return (
            self._carry_roll_in_progress(lower)
            or self._carry_finishing[lower]
            or self._column_cascade_active(lower)
        )

    def _intermittent_scroll_target(self, power: int) -> float:
        """Full digit roll (0..1) when carry chain is active (9->0 or N->N+1)."""
        if not self._column_cascade_active(power):
            return 0.0
        return 1.0

    @staticmethod
    def _carry_window_cap(display_float: float, power: int) -> float:
        """Top of carry window for this column (e.g. 999.999 cents before ..00 rollover)."""
        unit = 10**power
        tail = (int(display_float) // unit) * unit
        return float(tail + unit - 1) + 0.999999

    def _carry_display_hold_cap(self, display_float: float) -> Optional[float]:
        """Min display cap while any carry roll is pending or in progress."""
        caps: List[float] = []
        df = display_float
        for power in range(1, NUM_COLUMNS):
            if self._carry_roll_in_progress(power):
                caps.append(self._carry_window_cap(df, power))
            elif self._column_cascade_active(power):
                caps.append(self._carry_window_cap(df, power))
        if not caps:
            return None
        return min(caps)

    def _bump_display_after_carry(self, power: int) -> None:
        """Commit settled cents after this column's roll; wait for higher columns first."""
        for higher in range(power + 1, NUM_COLUMNS):
            if self._carry_roll_in_progress(higher) or self._carry_finishing[higher]:
                return
        settled = self._settled_cents()
        if settled >= self.target_pennies:
            return
        gap = self.target_pennies - settled
        if gap == 1 and (settled % 10) == 9:
            self._snap_display_to_settled_cents(self.target_pennies)
            return
        unit = 10**power
        if (settled % unit) < unit - 1:
            return
        bumped = min((settled // unit) * unit + unit, self.target_pennies)
        if bumped > settled:
            self.display_float = float(bumped)
            self._penny_scroll_visual = 0.0

    def _intermittent_display_ceiling(self, dt: float) -> float:
        """Hold display inside ..9 / ..99 / ..999 windows until carry scrolls finish."""
        del dt
        ceiling = float(self.target_pennies)
        cap = self._carry_display_hold_cap(self.display_float)
        if cap is not None:
            ceiling = min(ceiling, cap)
        return ceiling

    def _update_intermittent_scroll(self, dt: float) -> None:
        """
        Intermittent carry chain: dimes roll 9->0 when pennies carry; all columns
        use the same lag-scaled roll duration.
        """
        if dt <= 0:
            return
        for power in range(1, NUM_COLUMNS):
            unit = 10**power
            settled = (self._settled_cents() // unit) % 10
            phase = self._carry_scroll_phase[power]
            step = dt / max(self._smoothed_roll_sec, 1e-6)
            if phase >= 1.0 - 1e-9:
                self._clear_carry_scroll(power)
                self._bump_display_after_carry(power)
                continue
            cascading = self._column_cascade_active(power)
            was_in = self._carry_was_in[power]
            if cascading:
                if not was_in:
                    self._carry_from_digit[power] = settled
                self._carry_was_in[power] = True
                self._carry_finishing[power] = False
                target = self._intermittent_scroll_target(power)
                if target > phase:
                    self._carry_scroll_phase[power] = min(target, phase + step)
                continue
            self._carry_was_in[power] = False
            if phase > 1e-6 and self._carry_from_digit[power] is not None:
                self._carry_finishing[power] = True
                phase = min(1.0, phase + step)
                self._carry_scroll_phase[power] = phase
                if phase >= 1.0 - 1e-9:
                    self._clear_carry_scroll(power)
                    self._bump_display_after_carry(power)
                continue
            self._clear_carry_scroll(power)

    def get_display_digits(self) -> List[int]:
        return _pennies_to_digits(int(self.display_float))

    @staticmethod
    def _column_in_carry(settled_cents: int, power: int) -> bool:
        """True when lower places are all 9 (whole-cent settled value)."""
        if power <= 0:
            return False
        mod = 10**power
        return (settled_cents % mod) >= mod - 1

    def get_column_draw(self, power: int) -> Tuple[int, float]:
        """
        Digit glyph and 0..1 scroll for drawing.
        Pennies: lag-scaled smooth scroll. Other columns: intermittent 9->0 carry.
        """
        if power < 0 or power >= NUM_COLUMNS:
            return 0, 0.0
        if power == 0:
            return self._settled_cents() % 10, self._penny_scroll_visual
        unit = 10**power
        settled = (self._settled_cents() // unit) % 10
        phase = self._carry_scroll_phase[power]
        if phase <= 1e-6:
            return settled, 0.0
        from_digit = self._carry_from_digit[power]
        if from_digit is None:
            from_digit = settled
        return from_digit, min(phase, 1.0)

    def get_column_scroll(self, power: int) -> float:
        return self.get_column_draw(power)[1]

    def get_pennies_column_scroll(self) -> float:
        """Alias for hundredths column scroll."""
        return self.get_column_scroll(0)

    def get_pennies_column_from_digit(self) -> int:
        """Current hundredths digit (0-9) at settled value."""
        return self._settled_cents() % 10

    def lag_pennies(self) -> int:
        """Whole-cent gap behind target; fractional slop is not funded lag."""
        return max(0, self.target_pennies - self._settled_cents())

    def maintain_lag(self, lag_pennies: int) -> None:
        """
        Keep target = display + lag (pennies) without resetting the contribution clock.
        Lag is clamped to max_lag_pennies ($10 default).
        """
        cfg = get_odometer_config()
        lag = min(max(0, int(lag_pennies)), cfg.max_lag_pennies)
        prev_lag = self._last_maintained_lag
        if prev_lag is not None and lag < prev_lag:
            self._sync_intermittent_phases_to_display()
            self._penny_scroll_visual = 0.0
        self.target_pennies = max(0, self._settled_cents() + lag)
        self._last_maintained_lag = lag
        self._last_target_change_time = time.perf_counter()

    def reset_display_to_target(self) -> None:
        self._snap_display_to_settled_cents(self.target_pennies)
        self._last_maintained_lag = None
