using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;

namespace BrassAndSigil.Server.Services;

/// <summary>
/// Auto-backup driven by config.BackupSchedule. Accepted formats:
///   - "HH:mm"                 single daily slot (e.g. "04:00")
///   - "HH:mm,HH:mm,..."       multiple daily slots
///   - "every Nh"              every N hours (>= 15 minutes)
///   - "every Nm"              every N minutes
/// Wakes once a minute and fires backups when the clock matches the spec.
/// Doesn't catch up if the server was off when a slot passed -- daily/interval
/// backups don't need replay logic.
/// </summary>
public sealed class BackupScheduler : IDisposable
{
    private readonly ServerConfig _config;
    private readonly BackupService _backup;
    private readonly Action<string> _log;
    private CancellationTokenSource? _cts;
    private Task? _loop;

    // Tracking for "fired" state. For interval: just the last fire time. For
    // daily-times: which times have fired today, reset at day rollover.
    private DateTimeOffset? _lastIntervalFire;
    private DateOnly _lastFireDay = DateOnly.MinValue;
    private readonly HashSet<TimeOnly> _firedToday = new();

    public BackupScheduler(ServerConfig config, BackupService backup, Action<string> log)
    {
        _config = config;
        _backup = backup;
        _log    = log;
    }

    public void Start()
    {
        if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return;
        if (Parse(_config.BackupSchedule) == default)
        {
            _log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled.");
            return;
        }
        _cts?.Cancel();
        _cts = new CancellationTokenSource();
        _loop = Task.Run(() => RunAsync(_cts.Token));
        _log($"[backup-scheduler] Schedule active: {Describe()}");
    }

    /// <summary>Stop the current loop and re-Start with the latest config values.</summary>
    public void Reload()
    {
        try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
        _cts?.Dispose();
        _cts = null;
        _loop = null;
        Start();
    }

    /// <summary>Compute the next future scheduled fire time. Null if no schedule.</summary>
    public DateTimeOffset? NextRun()
    {
        var (interval, times) = Parse(_config.BackupSchedule);
        if (interval.HasValue)
        {
            var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1);
            var next = baseTime + interval.Value;
            // If we've never fired and we're past the implied first slot, "next" might be
            // in the past -- clamp to "imminent" by using now + small buffer.
            if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1);
            return next.ToLocalTime();
        }
        if (times is not null)
        {
            var now = DateTime.Now;
            var nowTime = TimeOnly.FromDateTime(now);
            // Use Cast<TimeOnly?>().FirstOrDefault() so "no pending" is null rather than 00:00.
            var pendingToday = times
                .Where(t => t > nowTime && !_firedToday.Contains(t))
                .OrderBy(t => t)
                .Cast<TimeOnly?>()
                .FirstOrDefault();
            if (pendingToday.HasValue)
                return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan()));
            // None left today -- first slot tomorrow.
            var firstTomorrow = times.Min();
            return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan()));
        }
        return null;
    }

    private async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try { await Task.Delay(TimeSpan.FromMinutes(1), ct); }
            catch (OperationCanceledException) { break; }

            var (interval, times) = Parse(_config.BackupSchedule);
            if (interval is null && times is null) continue;

            var nowUtc   = DateTimeOffset.UtcNow;
            var nowLocal = DateTime.Now;
            var today    = DateOnly.FromDateTime(nowLocal);
            var nowTime  = TimeOnly.FromDateTime(nowLocal);

            bool shouldFire = false;
            if (interval.HasValue)
            {
                shouldFire = !_lastIntervalFire.HasValue
                          || (nowUtc - _lastIntervalFire.Value) >= interval.Value;
            }
            else if (times is not null)
            {
                if (today != _lastFireDay)
                {
                    _firedToday.Clear();
                    _lastFireDay = today;
                }
                foreach (var t in times)
                {
                    if (t <= nowTime && !_firedToday.Contains(t))
                    {
                        shouldFire = true;
                        _firedToday.Add(t);
                        break;
                    }
                }
            }

            if (!shouldFire) continue;

            _log("[backup-scheduler] Triggering scheduled backup.");
            try
            {
                var result = await _backup.CreateAsync("scheduled", ct: ct);
                if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB).");
                else           _log($"[backup-scheduler] Failed: {result.Error}");
                if (interval.HasValue) _lastIntervalFire = nowUtc;
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); }
        }
    }

    /// <summary>Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty.</summary>
    private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input)
    {
        if (string.IsNullOrWhiteSpace(input)) return (null, null);
        var s = input.Trim().ToLowerInvariant();

        // every Nh / every Nm
        var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$");
        if (m.Success)
        {
            var n = int.Parse(m.Groups[1].Value);
            var unit = m.Groups[2].Value;
            var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n);
            // Sanity floor -- anything below 15 min creates more save-lag than backups are worth.
            if (span < TimeSpan.FromMinutes(15)) return (null, null);
            if (span > TimeSpan.FromDays(7))    return (null, null);
            return (span, null);
        }

        // Comma-separated HH:mm
        var list = new List<TimeOnly>();
        foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries))
        {
            if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null);
            list.Add(t);
        }
        return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray());
    }

    public string Describe()
    {
        var (interval, times) = Parse(_config.BackupSchedule);
        if (interval.HasValue)
        {
            var totalMin = (int)interval.Value.TotalMinutes;
            if (totalMin >= 60 && totalMin % 60 == 0)
            {
                var h = totalMin / 60;
                return h == 1 ? "Every hour" : $"Every {h} hours";
            }
            return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes";
        }
        if (times is not null)
        {
            if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}";
            return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm")));
        }
        return "Disabled";
    }

    public void Dispose()
    {
        try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
        _cts?.Dispose();
    }
}
