using System.Globalization;
using ClawdDotNet.Core.Scheduling;

namespace ClawdDotNet.Core.Tasks;

/// <summary>
/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
///
/// Das ist die Antwort auf B6 und B7: Statt eines langen <c>Task.Delay</c> bis zum
/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
///
/// Höchstens <b>ein</b> Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
/// eine Welle aus.
/// </summary>
public static class TaskSchedule
{
    /// <summary>
    /// Der Occurrence-Key des fälligen Termins, oder <c>null</c>, wenn nichts ansteht.
    /// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
    /// (<see cref="TaskItem.LastOccurrence"/>) liegt.
    /// </summary>
    public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
    {
        nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);

        if (task.When is null)
        {
            // Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
            if (task.LastOccurrence is not null) return null;
            var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
            return Iso(basis);
        }

        return task.When.Kind switch
        {
            TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
            TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
            TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
            _ => null
        };
    }

    /// <summary>
    /// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
    /// <c>null</c>.
    ///
    /// Ein solcher Termin feuert nie (siehe <c>ResolveTimeZone</c>). Damit das nicht
    /// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
    /// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
    /// (<c>W. Europe Standard Time</c>) auf einem System ohne ICU-Daten.
    /// </summary>
    public static string? UnresolvableTimeZone(TaskItem task)
        => task.When is { } when
           && !string.IsNullOrWhiteSpace(when.TimeZone)
           && !TimeZones.IsKnown(when.TimeZone)
            ? when.TimeZone
            : null;

    private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
    {
        if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
        if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
        if (nowUtc < targetUtc) return null;
        return Iso(targetUtc);
    }

    private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
    {
        if (!TryParseInterval(task.When!.Value, out var interval)) return null;

        var anchor = marker is not null
            ? ParseIso(marker)
            : (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());

        return nowUtc < anchor + interval ? null : Iso(nowUtc);
    }

    private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
    {
        var when = task.When!;
        CronExpression cron;
        try { cron = CronExpression.Parse(when.Value); }
        catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen

        var tz = ResolveTimeZone(when.TimeZone);
        if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit

        var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));

        // Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
        // ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
        // nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
        var lowerUtc = marker is not null ? ParseIso(marker)
            : task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
            : nowUtc;
        var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));

        var limit = lowerLocal;
        DateTime? matchLocal = null;
        for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
        {
            if (cron.Matches(candidate)) { matchLocal = candidate; break; }
        }

        if (matchLocal is null) return null;

        // Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen.
        var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified);
        if (tz.IsInvalidTime(unspecified)) return null;

        var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
        var occ = Iso(occUtc);

        // Nur fällig, wenn der Termin echt über dem Marker liegt.
        return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
    }

    // ─── Hilfsfunktionen ───

    /// <summary>Deutet einen <c>at</c>-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
    /// angegebenen Zeitzone.</summary>
    private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
    {
        utc = default;
        var v = value.Trim();
        if (v.Length == 0) return false;

        if (v.EndsWith('Z') || v.EndsWith('z'))
        {
            if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
                    DateTimeStyles.AssumeUniversal, out var dto))
            {
                utc = dto.UtcDateTime;
                return true;
            }
            return false;
        }

        if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
        {
            var tz = ResolveTimeZone(timeZone);
            if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar

            var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
            if (tz.IsInvalidTime(unspecified)) return false;
            utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
            return true;
        }

        return false;
    }

    /// <summary>Ein Intervall wie <c>30s</c>, <c>15m</c>, <c>2h</c>, <c>1d</c>.</summary>
    private static bool TryParseInterval(string value, out TimeSpan interval)
    {
        interval = default;
        var v = value.Trim().ToLowerInvariant();
        if (v.Length < 2) return false;

        var unit = v[^1];
        if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
            return false;

        interval = unit switch
        {
            's' => TimeSpan.FromSeconds(n),
            'm' => TimeSpan.FromMinutes(n),
            'h' => TimeSpan.FromHours(n),
            'd' => TimeSpan.FromDays(n),
            _ => TimeSpan.Zero
        };
        return interval > TimeSpan.Zero;
    }

    /// <summary>
    /// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
    ///
    /// Eine <b>unbekannte</b> Zone gibt <c>null</c> zurück — und der Aufrufer behandelt
    /// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
    /// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
    /// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
    ///
    /// Sichtbar wird er beim Import — <see cref="TaskboardService"/> weist eine Aufgabe
    /// mit unbekannter Zone mit Meldung ab.
    /// </summary>
    private static TimeZoneInfo? ResolveTimeZone(string id)
    {
        if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
        return TimeZones.TryResolve(id);
    }

    private static DateTime Truncate(DateTime value)
        => new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);

    private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");

    private static DateTime ParseIso(string value)
        => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
            ? dt.ToUniversalTime()
            : DateTime.MinValue;
}
