Size: 42.5 KB Modified: 7/07/2026 2:41 AM
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace KaiChat.Services;

public interface IKaiTimerService
{
    Task<KaiTimerState> GetStateAsync(CancellationToken ct = default);
    Task<KaiTimerMilestonePromptContext> GetMilestonePromptContextAsync(CancellationToken ct = default);
    Task<KaiTimerOperationResult> StartAsync(CancellationToken ct = default);
    Task<KaiTimerOperationResult> PauseAsync(CancellationToken ct = default);
    Task<KaiTimerOperationResult> ResetAsync(CancellationToken ct = default);
    Task<KaiTimerOperationResult> CreateWheelSegmentAsync(KaiWheelSegmentCreateRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> UpdateWheelSegmentAsync(KaiWheelSegmentUpdateRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> DeleteWheelSegmentAsync(KaiWheelSegmentDeleteRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> SpinWheelAsync(CancellationToken ct = default);
    Task<KaiTimerOperationResult> CreateCounterAsync(KaiCounterCreateRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> UpdateCounterAsync(KaiCounterUpdateRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> IncrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> DecrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> DeleteCounterAsync(KaiCounterDeleteRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> CreateMilestoneAsync(KaiMilestoneCreateRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> UpdateMilestoneAsync(KaiMilestoneUpdateRequest request, CancellationToken ct = default);
    Task<KaiTimerOperationResult> DeleteMilestoneAsync(KaiMilestoneDeleteRequest request, CancellationToken ct = default);
    string FormatStateForTool(KaiTimerState state);
    string FormatResultForTool(KaiTimerOperationResult result);
    string FormatMilestonePromptForSystem(KaiTimerMilestonePromptContext context);
}

public sealed class KaiTimerService : IKaiTimerService
{
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
    {
        WriteIndented = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly string _path;
    private const int MaxRetries = 3;
    private static readonly TimeSpan[] RetryDelays = [
        TimeSpan.FromMilliseconds(50),
        TimeSpan.FromMilliseconds(150),
        TimeSpan.FromMilliseconds(300)
    ];

    public KaiTimerService(IConfiguration configuration)
    {
        var baseFolder = configuration.GetValue<string>("KaiChat:BaseFolder") ?? ".";
        _path = Path.Combine(baseFolder, ".kaichat-timer.json");
    }

    public async Task<KaiTimerState> GetStateAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            PopulateComputedState(state, DateTime.UtcNow);
            return state;
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerMilestonePromptContext> GetMilestonePromptContextAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var due = state.Milestones
                .Where(m => m.Enabled && m.ThresholdSeconds <= state.ElapsedSeconds && !m.LastPromptedAt.HasValue)
                .OrderBy(m => m.ThresholdSeconds)
                .ToList();

            if (due.Count == 0)
                return new KaiTimerMilestonePromptContext(false, state, []);

            foreach (var milestone in due)
            {
                milestone.TriggeredAt ??= now;
                milestone.LastPromptedAt = now;
                milestone.UpdatedAt = now;
                milestone.IsDue = false;
                milestone.WasPrompted = true;
                AddEvent(state, "milestone_output_due", $"Milestone output queued: {milestone.Name}", now);
            }

            state.UpdatedAt = now;
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return new KaiTimerMilestonePromptContext(true, state, due);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> StartAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            if (state.IsRunning)
                return KaiTimerOperationResult.Ok("Timer is already running.", state);

            state.IsRunning = true;
            state.StartedAt = now;
            state.LastStartedAt = now;
            state.UpdatedAt = now;
            AddEvent(state, "started", "Timer started.", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok("Started timer.", state);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> PauseAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            if (!state.IsRunning)
                return KaiTimerOperationResult.Ok("Timer is already paused.", state);

            state.AccumulatedSeconds = state.ElapsedSeconds;
            state.IsRunning = false;
            state.StartedAt = null;
            state.LastPausedAt = now;
            state.UpdatedAt = now;
            AddEvent(state, "paused", "Timer paused.", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok("Paused timer.", state);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> ResetAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;

            state.AccumulatedSeconds = 0;
            state.ElapsedSeconds = 0;
            state.DisplayElapsed = FormatDuration(0);
            state.IsRunning = false;
            state.StartedAt = null;
            state.LastResetAt = now;
            state.UpdatedAt = now;

            foreach (var milestone in state.Milestones)
            {
                milestone.TriggeredAt = null;
                milestone.LastPromptedAt = null;
                milestone.UpdatedAt = now;
            }

            AddEvent(state, "reset", "Timer reset.", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok("Reset timer.", state);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> CreateWheelSegmentAsync(KaiWheelSegmentCreateRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var text = (request.Text ?? "").Trim();
            if (string.IsNullOrWhiteSpace(text))
                return KaiTimerOperationResult.Fail("Wheel segment text is required.", state);

            var segment = new KaiWheelSegment
            {
                Id = CreateId(),
                Text = text,
                Weight = ClampWeight(request.Weight),
                Enabled = request.Enabled,
                CreatedAt = now,
                UpdatedAt = now
            };

            state.WheelSegments.Add(segment);
            state.UpdatedAt = now;
            AddEvent(state, "wheel_segment_created", $"Wheel segment added: {segment.Text}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Added wheel segment '{segment.Text}'.", state, segment);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> UpdateWheelSegmentAsync(KaiWheelSegmentUpdateRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var segment = state.WheelSegments.FirstOrDefault(s => SameId(s.Id, request.Id));
            if (segment == null)
                return KaiTimerOperationResult.Fail($"Wheel segment '{request.Id}' was not found.", state);

            if (request.Text != null)
            {
                var text = request.Text.Trim();
                if (string.IsNullOrWhiteSpace(text))
                    return KaiTimerOperationResult.Fail("Wheel segment text cannot be empty.", state);
                segment.Text = text;
            }

            if (request.Weight.HasValue)
                segment.Weight = ClampWeight(request.Weight.Value);
            if (request.Enabled.HasValue)
                segment.Enabled = request.Enabled.Value;

            segment.UpdatedAt = now;
            state.UpdatedAt = now;
            AddEvent(state, "wheel_segment_updated", $"Wheel segment updated: {segment.Text}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Updated wheel segment '{segment.Text}'.", state, segment);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> DeleteWheelSegmentAsync(KaiWheelSegmentDeleteRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var segment = state.WheelSegments.FirstOrDefault(s => SameId(s.Id, request.Id));
            if (segment == null)
                return KaiTimerOperationResult.Fail($"Wheel segment '{request.Id}' was not found.", state);

            state.WheelSegments.Remove(segment);
            state.UpdatedAt = now;
            AddEvent(state, "wheel_segment_deleted", $"Wheel segment removed: {segment.Text}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Removed wheel segment '{segment.Text}'.", state, segment);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> SpinWheelAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var segments = state.WheelSegments
                .Where(s => s.Enabled)
                .ToList();

            if (segments.Count == 0)
                return KaiTimerOperationResult.Fail("The wheel has no enabled segments to spin.", state);

            var totalWeight = segments.Sum(s => Math.Max(1, s.Weight));
            var draw = RandomNumberGenerator.GetInt32(totalWeight);
            var cursor = 0;
            var selected = segments[^1];
            foreach (var segment in segments)
            {
                cursor += Math.Max(1, segment.Weight);
                if (draw < cursor)
                {
                    selected = segment;
                    break;
                }
            }

            selected.LastSelectedAt = now;
            selected.SelectionCount++;
            selected.UpdatedAt = now;
            state.LastWheelSpinAt = now;
            state.UpdatedAt = now;
            AddEvent(state, "wheel_spun", $"Wheel spun: {selected.Text}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);

            return KaiTimerOperationResult.Ok($"Wheel result for Raymond: {selected.Text}", state, selected);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> CreateCounterAsync(KaiCounterCreateRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var name = (request.Name ?? "").Trim();
            if (string.IsNullOrWhiteSpace(name))
                return KaiTimerOperationResult.Fail("Counter name is required.", state);

            var mode = NormalizeCounterMode(request.Mode);
            var automaticKind = NormalizeAutomaticKind(request.AutomaticKind);
            if (mode == KaiCounterModes.Automatic && string.IsNullOrWhiteSpace(automaticKind))
                return KaiTimerOperationResult.Fail("Automatic counters require an automaticKind.", state);
            if (mode == KaiCounterModes.Automatic && !IsSupportedAutomaticCounterKind(automaticKind))
                return KaiTimerOperationResult.Fail($"Unsupported automatic counter kind '{automaticKind}'.", state);

            var counter = new KaiCounter
            {
                Id = CreateId(),
                Name = name,
                Mode = mode,
                AutomaticKind = mode == KaiCounterModes.Automatic ? automaticKind : null,
                Value = mode == KaiCounterModes.Manual ? request.Value : 0,
                Active = request.Active,
                CreatedAt = now,
                UpdatedAt = now,
                LastChangedAt = now
            };

            state.Counters.Add(counter);
            state.UpdatedAt = now;
            AddEvent(state, "counter_created", $"Counter created: {counter.Name}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Created counter '{counter.Name}'.", state, counter);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> UpdateCounterAsync(KaiCounterUpdateRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var counter = state.Counters.FirstOrDefault(c => SameId(c.Id, request.Id));
            if (counter == null)
                return KaiTimerOperationResult.Fail($"Counter '{request.Id}' was not found.", state);

            if (request.Name != null)
            {
                var name = request.Name.Trim();
                if (string.IsNullOrWhiteSpace(name))
                    return KaiTimerOperationResult.Fail("Counter name cannot be empty.", state);
                counter.Name = name;
            }

            var nextMode = request.Mode == null ? counter.Mode : NormalizeCounterMode(request.Mode);
            var nextAutomaticKind = request.AutomaticKind == null
                ? NormalizeAutomaticKind(counter.AutomaticKind)
                : NormalizeAutomaticKind(request.AutomaticKind);

            if (nextMode == KaiCounterModes.Automatic)
            {
                if (string.IsNullOrWhiteSpace(nextAutomaticKind))
                    return KaiTimerOperationResult.Fail("Automatic counters require an automaticKind.", state);
                if (!IsSupportedAutomaticCounterKind(nextAutomaticKind))
                    return KaiTimerOperationResult.Fail($"Unsupported automatic counter kind '{nextAutomaticKind}'.", state);
            }

            counter.Mode = nextMode;
            counter.AutomaticKind = nextMode == KaiCounterModes.Automatic ? nextAutomaticKind : null;
            if (request.Value.HasValue && counter.Mode == KaiCounterModes.Manual)
                counter.Value = request.Value.Value;
            if (request.Active.HasValue)
                counter.Active = request.Active.Value;

            counter.UpdatedAt = now;
            counter.LastChangedAt = now;
            state.UpdatedAt = now;
            AddEvent(state, "counter_updated", $"Counter updated: {counter.Name}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Updated counter '{counter.Name}'.", state, counter);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> IncrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default)
    {
        return await ChangeCounterAsync(request, Math.Abs(request.Amount == 0 ? 1 : request.Amount), "incremented", ct);
    }

    public async Task<KaiTimerOperationResult> DecrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default)
    {
        return await ChangeCounterAsync(request, -Math.Abs(request.Amount == 0 ? 1 : request.Amount), "decremented", ct);
    }

    public async Task<KaiTimerOperationResult> DeleteCounterAsync(KaiCounterDeleteRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var counter = state.Counters.FirstOrDefault(c => SameId(c.Id, request.Id));
            if (counter == null)
                return KaiTimerOperationResult.Fail($"Counter '{request.Id}' was not found.", state);

            state.Counters.Remove(counter);
            state.UpdatedAt = now;
            AddEvent(state, "counter_deleted", $"Counter removed: {counter.Name}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Removed counter '{counter.Name}'.", state, counter);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> CreateMilestoneAsync(KaiMilestoneCreateRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var validation = ValidateMilestone(request.Name, request.ThresholdSeconds, request.Prompt, state);
            if (validation != null)
                return validation;

            var milestone = new KaiMilestonePrompt
            {
                Id = CreateId(),
                Name = request.Name.Trim(),
                ThresholdSeconds = request.ThresholdSeconds,
                Prompt = request.Prompt.Trim(),
                Enabled = request.Enabled,
                CreatedAt = now,
                UpdatedAt = now
            };

            state.Milestones.Add(milestone);
            state.UpdatedAt = now;
            AddEvent(state, "milestone_created", $"Milestone created: {milestone.Name} at {FormatDuration(milestone.ThresholdSeconds)}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Created milestone '{milestone.Name}'.", state, milestone);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> UpdateMilestoneAsync(KaiMilestoneUpdateRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var milestone = state.Milestones.FirstOrDefault(m => SameId(m.Id, request.Id));
            if (milestone == null)
                return KaiTimerOperationResult.Fail($"Milestone '{request.Id}' was not found.", state);

            var nextName = request.Name == null ? milestone.Name : request.Name.Trim();
            var nextThreshold = request.ThresholdSeconds ?? milestone.ThresholdSeconds;
            var nextPrompt = request.Prompt == null ? milestone.Prompt : request.Prompt.Trim();
            var validation = ValidateMilestone(nextName, nextThreshold, nextPrompt, state);
            if (validation != null)
                return validation;

            milestone.Name = nextName;
            milestone.ThresholdSeconds = nextThreshold;
            milestone.Prompt = nextPrompt;
            if (request.Enabled.HasValue)
                milestone.Enabled = request.Enabled.Value;
            if (request.ResetPromptState)
            {
                milestone.TriggeredAt = null;
                milestone.LastPromptedAt = null;
            }

            milestone.UpdatedAt = now;
            state.UpdatedAt = now;
            AddEvent(state, "milestone_updated", $"Milestone updated: {milestone.Name}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Updated milestone '{milestone.Name}'.", state, milestone);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<KaiTimerOperationResult> DeleteMilestoneAsync(KaiMilestoneDeleteRequest request, CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var milestone = state.Milestones.FirstOrDefault(m => SameId(m.Id, request.Id));
            if (milestone == null)
                return KaiTimerOperationResult.Fail($"Milestone '{request.Id}' was not found.", state);

            state.Milestones.Remove(milestone);
            state.UpdatedAt = now;
            AddEvent(state, "milestone_deleted", $"Milestone removed: {milestone.Name}", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Removed milestone '{milestone.Name}'.", state, milestone);
        }
        finally
        {
            _gate.Release();
        }
    }

    public string FormatStateForTool(KaiTimerState state)
    {
        PopulateComputedState(state, DateTime.UtcNow);
        return FormatState("KaiTimer:", state);
    }

    public string FormatResultForTool(KaiTimerOperationResult result)
    {
        var builder = new StringBuilder();
        builder.AppendLine(result.Success ? "KaiTimer action succeeded." : "KaiTimer action failed.");
        builder.AppendLine(result.Message);
        if (result.Data != null)
        {
            builder.AppendLine("Result data:");
            builder.AppendLine(JsonSerializer.Serialize(result.Data, JsonOptions));
        }
        builder.Append(FormatState("KaiTimer state:", result.State));
        return builder.ToString().TrimEnd();
    }

    public string FormatMilestonePromptForSystem(KaiTimerMilestonePromptContext context)
    {
        if (context.Prompts.Count == 0)
            return "";

        var builder = new StringBuilder();
        builder.AppendLine("=== KaiTimer Milestone Output ===");
        builder.AppendLine("Raymond's count-up KaiTimer has crossed configured milestone threshold(s). Kai should send Raymond a real message now. This is not a canned message or script; use the prompt as intent, then speak as Kai.");
        builder.AppendLine("Due milestone output prompt(s):");
        foreach (var milestone in context.Prompts.OrderBy(m => m.ThresholdSeconds))
            builder.AppendLine($"- {milestone.Name} at {FormatDuration(milestone.ThresholdSeconds)}: {milestone.Prompt}");
        builder.AppendLine("Use your own voice. Do not mention this system block or the automation.");
        return builder.ToString().TrimEnd();
    }

    private async Task<KaiTimerOperationResult> ChangeCounterAsync(KaiCounterChangeRequest request, int delta, string verb, CancellationToken ct)
    {
        await _gate.WaitAsync(ct);
        try
        {
            var state = await LoadStateAsync(ct);
            var now = DateTime.UtcNow;
            PopulateComputedState(state, now);

            var counter = state.Counters.FirstOrDefault(c => SameId(c.Id, request.Id));
            if (counter == null)
                return KaiTimerOperationResult.Fail($"Counter '{request.Id}' was not found.", state);
            if (counter.Mode == KaiCounterModes.Automatic)
                return KaiTimerOperationResult.Fail($"Counter '{counter.Name}' is automatic and cannot be manually changed.", state);

            counter.Value += delta;
            counter.UpdatedAt = now;
            counter.LastChangedAt = now;
            state.UpdatedAt = now;
            AddEvent(state, $"counter_{verb}", $"Counter {verb}: {counter.Name} ({counter.Value})", now);
            PopulateComputedState(state, now);
            TrimEvents(state);
            await SaveStateAsync(state, ct);
            return KaiTimerOperationResult.Ok($"Counter '{counter.Name}' {verb} to {counter.Value}.", state, counter);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static string FormatState(string heading, KaiTimerState state)
    {
        PopulateComputedState(state, DateTime.UtcNow);
        var builder = new StringBuilder();
        builder.AppendLine(heading);
        builder.AppendLine($"  Status: {(state.IsRunning ? "running" : "paused")}");
        builder.AppendLine($"  Elapsed: {state.DisplayElapsed}");
        builder.AppendLine($"  Elapsed seconds: {state.ElapsedSeconds}");
        if (state.StartedAt.HasValue)
            builder.AppendLine($"  Started at: {state.StartedAt:O}");
        if (state.LastPausedAt.HasValue)
            builder.AppendLine($"  Last paused at: {state.LastPausedAt:O}");
        if (state.LastResetAt.HasValue)
            builder.AppendLine($"  Last reset at: {state.LastResetAt:O}");

        builder.AppendLine();
        builder.AppendLine("Counters:");
        var activeCounters = state.Counters.Where(c => c.Active).OrderBy(c => c.Name).ToList();
        if (activeCounters.Count == 0)
            builder.AppendLine("  None.");
        foreach (var counter in activeCounters)
        {
            var mode = counter.Mode == KaiCounterModes.Automatic
                ? $"automatic:{counter.AutomaticKind}"
                : "manual";
            builder.AppendLine($"  - [{counter.Id}] {counter.Name}: {counter.DisplayValue} ({mode})");
        }

        builder.AppendLine();
        builder.AppendLine("Wheel segments:");
        if (state.WheelSegments.Count == 0)
            builder.AppendLine("  None.");
        foreach (var segment in state.WheelSegments.OrderBy(s => s.CreatedAt))
            builder.AppendLine($"  - [{segment.Id}] {(segment.Enabled ? "enabled" : "disabled")} weight={segment.Weight}: {segment.Text}");

        builder.AppendLine();
        builder.AppendLine("Milestones:");
        if (state.Milestones.Count == 0)
            builder.AppendLine("  None.");
        foreach (var milestone in state.Milestones.OrderBy(m => m.ThresholdSeconds))
        {
            var stateText = !milestone.Enabled
                ? "disabled"
                : milestone.IsDue ? "due"
                : milestone.LastPromptedAt.HasValue ? "prompted"
                : "upcoming";
            builder.AppendLine($"  - [{milestone.Id}] {stateText} at {milestone.DisplayThreshold}: {milestone.Name} - {milestone.Prompt}");
        }

        builder.AppendLine();
        builder.AppendLine("Recent actions:");
        var events = state.Events.OrderByDescending(e => e.Timestamp).Take(10).ToList();
        if (events.Count == 0)
            builder.AppendLine("  None.");
        foreach (var evt in events)
            builder.AppendLine($"  - {evt.Timestamp:O} [{evt.Type}] {evt.Message}");

        return builder.ToString().TrimEnd();
    }

    private async Task<KaiTimerState> LoadStateAsync(CancellationToken ct)
    {
        for (var attempt = 0; attempt < MaxRetries; attempt++)
        {
            try
            {
                if (!File.Exists(_path))
                {
                    var fresh = new KaiTimerState();
                    PopulateComputedState(fresh, DateTime.UtcNow);
                    return fresh;
                }

                await using var stream = File.OpenRead(_path);
                var loaded = await JsonSerializer.DeserializeAsync<KaiTimerState>(stream, JsonOptions, ct)
                    ?? new KaiTimerState();
                PopulateComputedState(loaded, DateTime.UtcNow);
                return loaded;
            }
            catch (IOException) when (attempt < MaxRetries - 1)
            {
                await Task.Delay(RetryDelays[attempt], ct);
            }
        }

        // Last attempt — if this throws, let the caller see it
        await using var finalStream = File.OpenRead(_path);
        var finalLoaded = await JsonSerializer.DeserializeAsync<KaiTimerState>(finalStream, JsonOptions, ct)
            ?? new KaiTimerState();
        PopulateComputedState(finalLoaded, DateTime.UtcNow);
        return finalLoaded;
    }

    private async Task SaveStateAsync(KaiTimerState state, CancellationToken ct)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(_path) ?? ".");
        PopulateComputedState(state, DateTime.UtcNow);

        // Write to a temp file then atomically move into place.
        // This avoids the File.Create truncation window that collides with git or external readers.
        var tempPath = _path + "." + Guid.NewGuid().ToString("N")[..8] + ".tmp";

        for (var attempt = 0; attempt < MaxRetries; attempt++)
        {
            try
            {
                await using (var stream = File.Create(tempPath))
                    await JsonSerializer.SerializeAsync(stream, state, JsonOptions, ct);
                File.Move(tempPath, _path, overwrite: true);
                return;
            }
            catch (IOException) when (attempt < MaxRetries - 1)
            {
                await Task.Delay(RetryDelays[attempt], ct);
            }
        }

        // Last attempt
        try
        {
            await using (var stream = File.Create(tempPath))
                await JsonSerializer.SerializeAsync(stream, state, JsonOptions, ct);
            File.Move(tempPath, _path, overwrite: true);
        }
        finally
        {
            try { File.Delete(tempPath); } catch { /* best effort */ }
        }
    }

    private static void PopulateComputedState(KaiTimerState state, DateTime now)
    {
        EnsureCollections(state);

        var elapsed = Math.Max(0, state.AccumulatedSeconds);
        if (state.IsRunning && state.StartedAt.HasValue)
            elapsed += Math.Max(0, (long)Math.Floor((now - state.StartedAt.Value).TotalSeconds));

        state.ElapsedSeconds = elapsed;
        state.DisplayElapsed = FormatDuration(elapsed);

        foreach (var counter in state.Counters)
        {
            counter.Mode = NormalizeCounterMode(counter.Mode);
            counter.AutomaticKind = NormalizeAutomaticKind(counter.AutomaticKind);
            if (counter.Mode == KaiCounterModes.Automatic)
                counter.Value = ComputeAutomaticCounter(counter, state, now);
            counter.DisplayValue = counter.Value.ToString("N0");
        }

        foreach (var segment in state.WheelSegments)
        {
            if (string.IsNullOrWhiteSpace(segment.Id))
                segment.Id = CreateId();
            segment.Weight = ClampWeight(segment.Weight);
        }

        foreach (var milestone in state.Milestones)
        {
            if (string.IsNullOrWhiteSpace(milestone.Id))
                milestone.Id = CreateId();
            milestone.DisplayThreshold = FormatDuration(milestone.ThresholdSeconds);
            milestone.IsDue = milestone.Enabled && milestone.ThresholdSeconds <= elapsed && !milestone.LastPromptedAt.HasValue;
            milestone.WasPrompted = milestone.LastPromptedAt.HasValue;
            milestone.IsUpcoming = milestone.Enabled && milestone.ThresholdSeconds > elapsed;
        }
    }

    private static int ComputeAutomaticCounter(KaiCounter counter, KaiTimerState state, DateTime now)
    {
        return counter.AutomaticKind switch
        {
            KaiAutomaticCounterKinds.DaysSinceLastReset => state.LastResetAt.HasValue
                ? Math.Max(0, (int)Math.Floor((now - state.LastResetAt.Value).TotalDays))
                : 0,
            KaiAutomaticCounterKinds.ElapsedDays => Math.Max(0, (int)Math.Floor(state.ElapsedSeconds / 86400.0)),
            _ => counter.Value
        };
    }

    private static void EnsureCollections(KaiTimerState state)
    {
        state.Events ??= new();
        state.WheelSegments ??= new();
        state.Milestones ??= new();
        state.Counters ??= new();
    }

    private static string FormatDuration(long totalSeconds)
    {
        var value = TimeSpan.FromSeconds(Math.Max(0, totalSeconds));
        if (value.TotalDays >= 1)
            return $"{(int)value.TotalDays}d {value.Hours}h {value.Minutes}m {value.Seconds}s";
        if (value.TotalHours >= 1)
            return $"{(int)value.TotalHours}h {value.Minutes}m {value.Seconds}s";
        return $"{value.Minutes}m {value.Seconds}s";
    }

    private static void AddEvent(KaiTimerState state, string type, string message, DateTime timestamp)
    {
        state.Events.Add(KaiTimerEvent.Create(type, message, timestamp));
    }

    private static void TrimEvents(KaiTimerState state)
    {
        if (state.Events.Count <= 100)
            return;

        state.Events = state.Events
            .OrderByDescending(e => e.Timestamp)
            .Take(100)
            .OrderBy(e => e.Timestamp)
            .ToList();
    }

    private static KaiTimerOperationResult? ValidateMilestone(string? name, long thresholdSeconds, string? prompt, KaiTimerState state)
    {
        if (string.IsNullOrWhiteSpace(name))
            return KaiTimerOperationResult.Fail("Milestone name is required.", state);
        if (thresholdSeconds <= 0)
            return KaiTimerOperationResult.Fail("Milestone thresholdSeconds must be greater than zero.", state);
        if (string.IsNullOrWhiteSpace(prompt))
            return KaiTimerOperationResult.Fail("Milestone prompt is required.", state);
        return null;
    }

    private static int ClampWeight(int weight)
    {
        return Math.Clamp(weight <= 0 ? 1 : weight, 1, 100);
    }

    private static string NormalizeCounterMode(string? mode)
    {
        return string.Equals(mode, KaiCounterModes.Automatic, StringComparison.OrdinalIgnoreCase)
            ? KaiCounterModes.Automatic
            : KaiCounterModes.Manual;
    }

    private static string? NormalizeAutomaticKind(string? kind)
    {
        return string.IsNullOrWhiteSpace(kind)
            ? null
            : kind.Trim().Replace('-', '_').ToLowerInvariant();
    }

    private static bool IsSupportedAutomaticCounterKind(string? kind)
    {
        return kind is KaiAutomaticCounterKinds.DaysSinceLastReset or KaiAutomaticCounterKinds.ElapsedDays;
    }

    private static bool SameId(string? left, string? right)
    {
        return string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase);
    }

    private static string CreateId()
    {
        return Guid.NewGuid().ToString("N")[..10];
    }
}

public static class KaiCounterModes
{
    public const string Manual = "manual";
    public const string Automatic = "automatic";
}

public static class KaiAutomaticCounterKinds
{
    public const string DaysSinceLastReset = "days_since_last_reset";
    public const string ElapsedDays = "elapsed_days";
}

public sealed class KaiTimerState
{
    public bool IsRunning { get; set; }
    public long AccumulatedSeconds { get; set; }
    public DateTime? StartedAt { get; set; }
    public DateTime? LastStartedAt { get; set; }
    public DateTime? LastPausedAt { get; set; }
    public DateTime? LastResetAt { get; set; }
    public DateTime? LastWheelSpinAt { get; set; }
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
    public long ElapsedSeconds { get; set; }
    public string DisplayElapsed { get; set; } = "0m 0s";
    public List<KaiTimerEvent> Events { get; set; } = new();
    public List<KaiWheelSegment> WheelSegments { get; set; } = new();
    public List<KaiMilestonePrompt> Milestones { get; set; } = new();
    public List<KaiCounter> Counters { get; set; } = new();
}

public sealed class KaiWheelSegment
{
    public string Id { get; set; } = "";
    public string Text { get; set; } = "";
    public int Weight { get; set; } = 1;
    public bool Enabled { get; set; } = true;
    public int SelectionCount { get; set; }
    public DateTime? LastSelectedAt { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

public sealed class KaiMilestonePrompt
{
    public string Id { get; set; } = "";
    public string Name { get; set; } = "";
    public long ThresholdSeconds { get; set; }
    public string DisplayThreshold { get; set; } = "0m 0s";
    public string Prompt { get; set; } = "";
    public bool Enabled { get; set; } = true;
    public bool IsDue { get; set; }
    public bool IsUpcoming { get; set; }
    public bool WasPrompted { get; set; }
    public DateTime? TriggeredAt { get; set; }
    public DateTime? LastPromptedAt { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

public sealed class KaiCounter
{
    public string Id { get; set; } = "";
    public string Name { get; set; } = "";
    public int Value { get; set; }
    public string DisplayValue { get; set; } = "0";
    public string Mode { get; set; } = KaiCounterModes.Manual;
    public string? AutomaticKind { get; set; }
    public bool Active { get; set; } = true;
    public DateTime? LastChangedAt { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

public sealed class KaiTimerEvent
{
    public string Type { get; set; } = "";
    public string Message { get; set; } = "";
    public DateTime Timestamp { get; set; } = DateTime.UtcNow;

    public static KaiTimerEvent Create(string type, string message, DateTime timestamp)
    {
        return new KaiTimerEvent
        {
            Type = type,
            Message = message,
            Timestamp = timestamp
        };
    }
}

public sealed record KaiTimerOperationResult(
    bool Success,
    string Message,
    KaiTimerState State,
    object? Data = null)
{
    public static KaiTimerOperationResult Ok(string message, KaiTimerState state, object? data = null)
    {
        return new KaiTimerOperationResult(true, message, state, data);
    }

    public static KaiTimerOperationResult Fail(string message, KaiTimerState state, object? data = null)
    {
        return new KaiTimerOperationResult(false, message, state, data);
    }
}

public sealed record KaiTimerMilestonePromptContext(
    bool Changed,
    KaiTimerState State,
    IReadOnlyList<KaiMilestonePrompt> Prompts);

public sealed record KaiWheelSegmentCreateRequest(
    [property: JsonPropertyName("text")] string Text,
    [property: JsonPropertyName("weight")] int Weight = 1,
    [property: JsonPropertyName("enabled")] bool Enabled = true);

public sealed record KaiWheelSegmentUpdateRequest(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("text")] string? Text = null,
    [property: JsonPropertyName("weight")] int? Weight = null,
    [property: JsonPropertyName("enabled")] bool? Enabled = null);

public sealed record KaiWheelSegmentDeleteRequest(
    [property: JsonPropertyName("id")] string Id);

public sealed record KaiCounterCreateRequest(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("value")] int Value = 0,
    [property: JsonPropertyName("mode")] string Mode = KaiCounterModes.Manual,
    [property: JsonPropertyName("automaticKind")] string? AutomaticKind = null,
    [property: JsonPropertyName("active")] bool Active = true);

public sealed record KaiCounterUpdateRequest(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("name")] string? Name = null,
    [property: JsonPropertyName("value")] int? Value = null,
    [property: JsonPropertyName("mode")] string? Mode = null,
    [property: JsonPropertyName("automaticKind")] string? AutomaticKind = null,
    [property: JsonPropertyName("active")] bool? Active = null);

public sealed record KaiCounterChangeRequest(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("amount")] int Amount = 1);

public sealed record KaiCounterDeleteRequest(
    [property: JsonPropertyName("id")] string Id);

public sealed record KaiMilestoneCreateRequest(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("thresholdSeconds")] long ThresholdSeconds,
    [property: JsonPropertyName("prompt")] string Prompt,
    [property: JsonPropertyName("enabled")] bool Enabled = true);

public sealed record KaiMilestoneUpdateRequest(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("name")] string? Name = null,
    [property: JsonPropertyName("thresholdSeconds")] long? ThresholdSeconds = null,
    [property: JsonPropertyName("prompt")] string? Prompt = null,
    [property: JsonPropertyName("enabled")] bool? Enabled = null,
    [property: JsonPropertyName("resetPromptState")] bool ResetPromptState = false);

public sealed record KaiMilestoneDeleteRequest(
    [property: JsonPropertyName("id")] string Id);
Offline