using HellionChat.Themes.Builtin;
using Microsoft.Extensions.Logging;

namespace HellionChat.Themes;

public sealed class ThemeRegistry
{
    private readonly ILogger<ThemeRegistry>? _logger;

    public const string DefaultSlug = HellionArctic.Slug;

    // 1Hz throttle for the v1.4.8 B2 auto-refresh-on-active path. The
    // Plugin.Draw hook calls RefreshActiveIfStale every frame, but the
    // actual File.GetLastWriteTimeUtc disk-stat only runs once per second
    // -- 60fps would otherwise mean 3600 stats/min on the same path (more
    // on Wine). Same idiom as the StatusBar 1Hz cache.
    private const long ActiveStampPollIntervalMs = 1000;

    private readonly Dictionary<string, Theme> _builtIns;
    private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new(
        StringComparer.OrdinalIgnoreCase
    );
    private readonly string? _customThemesDir;
    private Theme _active;

    // v1.4.8 B2: source path of the currently active custom theme. Captured
    // at Switch() time so RefreshActiveIfStale does not have to reconstruct
    // a filename from the slug -- custom theme filenames are not required
    // to match the slug they declare in the JSON body. Null when the active
    // theme is built-in or no custom-themes directory is configured.
    private string? _activeCustomPath;
    private long _lastActiveStampCheckMs = -ActiveStampPollIntervalMs;
    private DateTime _lastActiveStamp = DateTime.MinValue;

    // PM-1 crossfade state. Switch() captures the previous AbgrCache as a
    // VALUE-COPY (not a Theme reference) -- the built-in singletons share
    // their RecomputeAbgrCache identity, so a reference would mutate
    // alongside the new active. _crossfadeStartTickMs == long.MinValue
    // means "no crossfade armed yet"; the field stays MinValue after
    // SwitchSilent so the plugin-load init-path does not trigger a fade.
    private ThemeAbgrCache? _previousAbgrSnapshot;
    private long _crossfadeStartTickMs = long.MinValue;
    private const int CrossfadeDurationMs = 300;

    public ThemeRegistry(string? customThemesDir = null, ILogger<ThemeRegistry>? logger = null)
    {
        _logger = logger;
        // Insertion order drives the Theme-Picker grid layout (3 columns).
        // Row 1: blue family. Row 2: purple to magenta family.
        // Row 3: green / warm / classic. Row 4: Synthwave Sunset as a
        // retro bonus on its own line.
        _builtIns = new Dictionary<string, Theme>(StringComparer.OrdinalIgnoreCase)
        {
            { HellionArctic.Slug, HellionArctic.Build() },
            { HellionSpectrum.Slug, HellionSpectrum.Build() },
            { NightBlue.Slug, NightBlue.Build() },
            { EventHorizon.Slug, EventHorizon.Build() },
            { IndigoViolet.Slug, IndigoViolet.Build() },
            { CrystalNocturne.Slug, CrystalNocturne.Build() },
            { MintGrove.Slug, MintGrove.Build() },
            { ForgeMerchantman.Slug, ForgeMerchantman.Build() },
            { Chat2Classic.Slug, Chat2Classic.Build() },
            { SynthwaveSunset.Slug, SynthwaveSunset.Build() },
        };

        // Centralised so Build() factories stay free of cache plumbing.
        foreach (var theme in _builtIns.Values)
            theme.RecomputeAbgrCache();

        _active = _builtIns[DefaultSlug];
        _customThemesDir = customThemesDir;
    }

    public Theme Active => _active;

    public Theme Get(string slug)
    {
        if (_builtIns.TryGetValue(slug, out var b))
            return b;

        // Discard the source path here; Switch is the only call-site that
        // needs to remember it for the auto-refresh hook.
        var custom = LoadCustomBySlug(slug, out _);
        if (custom != null)
            return custom;

        return _builtIns[DefaultSlug];
    }

    public IEnumerable<Theme> AllBuiltIns() => _builtIns.Values;

    public IEnumerable<Theme> AllCustom() => RefreshCustomCache();

    // Built-in-first to match Get(slug)'s lookup order. A user theme JSON
    // that declares the same slug as a built-in is ignored deliberately --
    // having Switch prefer custom and Get prefer built-in would produce
    // a state where _active and Get(_active.Slug) disagree.
    public void Switch(string slug)
    {
        // Same-slug switch is a no-op -- avoids a 300ms identity-crossfade
        // when the user re-selects the active theme in the picker.
        if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase))
            return;

        ArmCrossfade();

        if (_builtIns.TryGetValue(slug, out var builtin))
        {
            _active = builtin;
            _active.RecomputeAbgrCache();
            _activeCustomPath = null;
            return;
        }

        var customTheme = LoadCustomBySlug(slug, out var customPath);
        if (customTheme is not null)
        {
            _active = customTheme;
            // Defensive — ensures any future theme source always gets a populated cache.
            _active.RecomputeAbgrCache();
            _activeCustomPath = customPath;
            // Force a first-tick reload-check after the switch so the stamp
            // baseline is established on the next RefreshActiveIfStale call.
            _lastActiveStamp = DateTime.MinValue;
            return;
        }

        // Fallback: neither built-in nor custom matched. Drop to default
        // and clear the active custom path so RefreshActiveIfStale stays idle.
        _active = _builtIns[DefaultSlug];
        _active.RecomputeAbgrCache();
        _activeCustomPath = null;
    }

    // SwitchSilent is the plugin-load init path -- identical to Switch
    // but does NOT arm the crossfade state. Called from
    // ThemeRegistryInitHostedService.StartAsync so opening the plugin
    // does not produce a 300ms fade from the default theme to the user's
    // saved theme.
    public void SwitchSilent(string slug)
    {
        if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase))
            return;

        if (_builtIns.TryGetValue(slug, out var builtin))
        {
            _active = builtin;
            _active.RecomputeAbgrCache();
            _activeCustomPath = null;
            return;
        }

        var customTheme = LoadCustomBySlug(slug, out var customPath);
        if (customTheme is not null)
        {
            _active = customTheme;
            _active.RecomputeAbgrCache();
            _activeCustomPath = customPath;
            _lastActiveStamp = DateTime.MinValue;
            return;
        }

        _active = _builtIns[DefaultSlug];
        _active.RecomputeAbgrCache();
        _activeCustomPath = null;
    }

    // Captures the AbgrCache snapshot that PushGlobal should fade FROM.
    // If a crossfade is already mid-flight (second Switch within 300ms),
    // the current lerped state replaces the snapshot -- the next fade
    // starts from where we currently are, not from the original "from".
    private void ArmCrossfade()
    {
        var now = Environment.TickCount64;
        ThemeAbgrCache snapshot;
        if (
            _previousAbgrSnapshot.HasValue
            && _crossfadeStartTickMs != long.MinValue
            && now - _crossfadeStartTickMs < CrossfadeDurationMs
        )
        {
            var t = (float)(now - _crossfadeStartTickMs) / CrossfadeDurationMs;
            snapshot = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, t);
        }
        else
        {
            snapshot = _active.AbgrCache;
        }

        _previousAbgrSnapshot = snapshot;
        _crossfadeStartTickMs = now;
    }

    // Returns the lerped AbgrCache while the crossfade is active.
    // PushGlobal reads this once per frame; outside the 300ms window
    // it short-circuits via the TickCount64 delta so the per-frame
    // overhead is a couple of integer comparisons.
    public bool TryGetActiveCrossfade(out ThemeAbgrCache lerped)
    {
        lerped = default;
        if (_crossfadeStartTickMs == long.MinValue || !_previousAbgrSnapshot.HasValue)
            return false;

        var elapsed = Environment.TickCount64 - _crossfadeStartTickMs;
        if (elapsed >= CrossfadeDurationMs)
            return false;

        var t = (float)elapsed / CrossfadeDurationMs;
        lerped = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, t);
        return true;
    }

    // 1Hz-throttled disk-stat on the currently active custom theme file.
    // When the file's LastWriteTime moves forward (editor save), reload the
    // theme via Get() so the user sees the edit immediately without
    // re-selecting in the picker. Built-in themes short-circuit; custom
    // themes without an _activeCustomPath (e.g. Switch fell to default)
    // short-circuit too.
    public void RefreshActiveIfStale()
    {
        var now = Environment.TickCount64;
        if (now - _lastActiveStampCheckMs < ActiveStampPollIntervalMs)
            return;
        _lastActiveStampCheckMs = now;

        if (_active.IsBuiltIn)
            return;

        var path = _activeCustomPath;
        if (path is null || !File.Exists(path))
            return;

        var stamp = File.GetLastWriteTimeUtc(path);
        if (!ThemeStampDiff.IsStale(_lastActiveStamp, stamp))
            return;
        _lastActiveStamp = stamp;

        // Get() re-runs RefreshCustomCache which picks up the new content
        // (the cache keys by path + LastWriteTime, so a mtime bump invalidates).
        // RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
        var reloaded = Get(_active.Slug);
        _active = reloaded;
    }

    // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
    // Other IO failures are permanent — theme is dropped instead of retried.
    internal static bool IsRecoverableFileLock(Exception? ex)
    {
        if (ex is not IOException io)
            return false;
        var code = (uint)io.HResult;
        return code == 0x80070020u || code == 0x80070021u;
    }

    // Slug -> Theme lookup with the source path as an out-param so the
    // Switch path can remember which file backs the active custom theme.
    // Pure reverse-lookup over the existing _customCache: that cache is
    // already Path -> (Theme, Stamp), so iterating it costs nothing,
    // avoids a re-parse of every JSON, and keeps the parse logic (and
    // the recoverable-file-lock recovery) confined to RefreshCustomCache.
    // The cache must be warm before this runs; Plugin.LoadAsync triggers
    // a one-time warm-up via AllCustom() before the first Switch call.
    private Theme? LoadCustomBySlug(string slug, out string? sourcePath)
    {
        sourcePath = null;
        if (_customThemesDir is null)
            return null;
        if (!Directory.Exists(_customThemesDir))
            return null;

        foreach (var kvp in _customCache)
        {
            if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
            {
                sourcePath = kvp.Key;
                return kvp.Value.Theme;
            }
        }
        return null;
    }

    private IEnumerable<Theme> RefreshCustomCache()
    {
        if (_customThemesDir is null || !Directory.Exists(_customThemesDir))
            yield break;

        var seenSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var path in Directory.EnumerateFiles(_customThemesDir, "*.json"))
        {
            Theme? theme = null;
            var stamp = File.GetLastWriteTimeUtc(path);
            var key = path;
            if (_customCache.TryGetValue(key, out var cached) && cached.Stamp == stamp)
            {
                theme = cached.Theme;
            }
            else
            {
                try
                {
                    theme = ThemeJsonLoader.LoadFromFile(path);
                    theme.RecomputeAbgrCache();
                    _customCache[key] = (theme, stamp);
                }
                catch (Exception ex) when (IsRecoverableFileLock(ex))
                {
                    // Editor mid-save: keep last known good, retry on next refresh.
                    _logger?.LogDebug(
                        $"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
                    );
                    if (cached.Theme is not null)
                        theme = cached.Theme;
                }
                catch (Exception)
                {
                    continue;
                }
            }

            if (theme is not null && seenSlugs.Add(theme.Slug))
                yield return theme;
        }
    }
}
