using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Lumi.Companion.Core;
using Lumi.Companion.Protocol;

namespace Lumi.Companion.App;

public sealed class UpdateService(HttpClient http, CompanionPaths paths)
{
    public async Task<CompanionUpdate?> CheckAsync(DeviceCredential credential, string currentVersion, CancellationToken cancellationToken = default)
    {
        var host = new Uri(credential.Host);
        var endpoint = new Uri(host, "/plugins/lumi_transcription/api/companion/update");
        var localBuild = DevelopmentBuildManifest.Load();
        var deadline = DateTimeOffset.UtcNow.AddMinutes(10);
        while (true)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
            request.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
            request.Content = JsonContent.Create(new CompanionUpdateCheck(
                currentVersion,
                localBuild?.BuildChecksum,
                localBuild?.Components ?? new Dictionary<string, string>(),
                true), options: ProtocolV1.JsonOptions);
            using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
            if (response.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.MethodNotAllowed)
                return await CheckLegacyAsync(credential, currentVersion, host, cancellationToken);
            response.EnsureSuccessStatusCode();
            var update = await JsonSerializer.DeserializeAsync<CompanionUpdate>(
                await response.Content.ReadAsStreamAsync(cancellationToken),
                ProtocolV1.JsonOptions,
                cancellationToken);
            if (update is not { Ok: true, UpdateAvailable: true }) return null;
            if (update.Artifact is not null) return update;
            if (!update.Development || !update.BuildPending)
                throw new InvalidDataException("Lumi announced a Companion update without a downloadable artifact.");
            if (DateTimeOffset.UtcNow >= deadline)
                throw new TimeoutException("Lumi did not finish preparing the local Companion update within 10 minutes.");
            await Task.Delay(TimeSpan.FromMilliseconds(Math.Clamp(update.RetryAfterMilliseconds ?? 2000, 500, 10000)), cancellationToken);
        }
    }

    private async Task<CompanionUpdate?> CheckLegacyAsync(DeviceCredential credential, string currentVersion, Uri host, CancellationToken cancellationToken)
    {
        var endpoint = new Uri(host, $"/plugins/lumi_transcription/api/companion/update?current_version={Uri.EscapeDataString(currentVersion)}");
        using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
        request.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
        using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
        response.EnsureSuccessStatusCode();
        var legacy = await JsonSerializer.DeserializeAsync<LegacyCompanionUpdate>(await response.Content.ReadAsStreamAsync(cancellationToken), ProtocolV1.JsonOptions, cancellationToken);
        if (legacy is not { Ok: true, UpdateAvailable: true }) return null;
        return new CompanionUpdate(legacy.Ok, legacy.Version, legacy.UpdateAvailable, legacy.Artifact, legacy.Signed, legacy.ReleaseNotes,
            false, null, null, null, false, null);
    }

    public async Task<StagedUpdate> StageAsync(CompanionUpdate update, DeviceCredential? credential = null, CancellationToken cancellationToken = default)
    {
        var artifact = update.Artifact ?? throw new InvalidDataException("The update manifest does not contain an artifact.");
        if (artifact.Bytes is <= 0 or > 300 * 1024 * 1024 || !IsSha256(artifact.Sha256)) throw new InvalidDataException("The update manifest is invalid.");
        var pairedHost = credential is null ? null : new Uri(credential.Host);
        if (!Uri.TryCreate(artifact.Url, UriKind.Absolute, out var artifactUri) ||
            !IsAllowedArtifactUri(artifactUri, update.Development, pairedHost))
            throw new InvalidDataException("The update manifest does not use an allowed artifact URL.");
        var safeVersion = SafeVersion(update.Version);
        if (string.IsNullOrWhiteSpace(safeVersion)) throw new InvalidDataException("The update manifest has an invalid version.");
        Directory.CreateDirectory(paths.UpdatesDirectory);
        var buildSuffix = update.Development && IsSha256(update.BuildChecksum ?? string.Empty) ? $"-{update.BuildChecksum![..12]}" : string.Empty;
        var archivePath = Path.Combine(paths.UpdatesDirectory, $"companion-{safeVersion}{buildSuffix}.zip.partial");
        var stageRoot = Path.Combine(paths.UpdatesDirectory, $"{safeVersion}{buildSuffix}");
        try
        {
            using var download = new HttpRequestMessage(HttpMethod.Get, artifactUri);
            if (update.Development && credential is not null && CompanionTransportPolicy.IsSameOrigin(artifactUri, pairedHost!))
                download.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
            using var response = await http.SendAsync(download, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
            response.EnsureSuccessStatusCode();
            await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken))
            await using (var target = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
            {
                var buffer = new byte[81920];
                long total = 0;
                while (true)
                {
                    var read = await source.ReadAsync(buffer, cancellationToken);
                    if (read == 0) break;
                    total = checked(total + read);
                    if (total > artifact.Bytes) throw new InvalidDataException("The Companion update download exceeded its declared size.");
                    await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
                }
            }
            if (new FileInfo(archivePath).Length != artifact.Bytes || !HashFile(archivePath).Equals(artifact.Sha256, StringComparison.OrdinalIgnoreCase))
                throw new InvalidDataException("The downloaded Companion update did not match its trusted checksum.");
            if (Directory.Exists(stageRoot)) Directory.Delete(stageRoot, true);
            Directory.CreateDirectory(stageRoot);
            using var archive = ZipFile.OpenRead(archivePath);
            if (archive.Entries.Count is 0 or > 256) throw new InvalidDataException("The Companion update contains an invalid number of files.");
            long extractedBytes = 0;
            foreach (var entry in archive.Entries)
            {
                var relative = entry.FullName.Replace('\\', '/').TrimStart('/');
                if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) continue;
                if (relative.Split('/').Any(segment => segment is "" or "." or "..")) throw new InvalidDataException("The Companion update contains an unsafe path.");
                extractedBytes = checked(extractedBytes + entry.Length);
                if (extractedBytes > 500 * 1024 * 1024) throw new InvalidDataException("The Companion update expands beyond its safety limit.");
                var destination = Path.GetFullPath(Path.Combine(stageRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
                if (!destination.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("The Companion update contains an unsafe path.");
                Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
                entry.ExtractToFile(destination, true);
            }
            var staged = Path.GetFullPath(Path.Combine(stageRoot, artifact.Entrypoint.Replace('/', Path.DirectorySeparatorChar)));
            if (!File.Exists(staged) || !staged.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
                throw new InvalidDataException("The Companion update is missing its application entrypoint.");
            return new StagedUpdate(stageRoot, staged, HashFile(staged));
        }
        finally { try { File.Delete(archivePath); } catch { } }
    }

    public void LaunchApplier(StagedUpdate update)
    {
        if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new PlatformNotSupportedException("In-place updates require the packaged Windows application.");
        var target = Path.GetFullPath(Environment.ProcessPath);
        var helper = Path.Combine(paths.UpdatesDirectory, $"Lumi.Companion.UpdateHelper.{Environment.ProcessId}.exe");
        File.Copy(target, helper, true);
        var start = new ProcessStartInfo(helper) { UseShellExecute = false, CreateNoWindow = true };
        foreach (var argument in new[] { "--apply-update", Environment.ProcessId.ToString(), update.StageRoot, update.ExecutablePath, target, update.Sha256, helper }) start.ArgumentList.Add(argument);
        Process.Start(start)?.Dispose();
    }

    private static bool IsAllowedArtifactUri(Uri uri, bool development, Uri? pairedHost)
    {
        if (CompanionTransportPolicy.IsSecureOrigin(uri)) return true;
        return development && CompanionTransportPolicy.IsEndpointAllowed(uri, pairedHost);
    }

    internal static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
    private static bool IsSha256(string value) => value.Length == 64 && value.All(Uri.IsHexDigit);
    private static string SafeVersion(string value) => string.Concat(value.Where(character => char.IsLetterOrDigit(character) || character is '.' or '-')).Trim('.');
}

public sealed record LegacyCompanionUpdate(
    [property: JsonPropertyName("ok")] bool Ok,
    [property: JsonPropertyName("version")] string Version,
    [property: JsonPropertyName("update_available")] bool UpdateAvailable,
    [property: JsonPropertyName("artifact")] CompanionUpdateArtifact Artifact,
    [property: JsonPropertyName("signed")] bool Signed,
    [property: JsonPropertyName("release_notes")] string ReleaseNotes);

public sealed record CompanionUpdateCheck(
    [property: JsonPropertyName("current_version")] string CurrentVersion,
    [property: JsonPropertyName("build_checksum")] string? BuildChecksum,
    [property: JsonPropertyName("component_checksums")] IReadOnlyDictionary<string, string> ComponentChecksums,
    [property: JsonPropertyName("supports_pending_build")] bool SupportsPendingBuild);

public sealed record CompanionUpdate(
    [property: JsonPropertyName("ok")] bool Ok,
    [property: JsonPropertyName("version")] string Version,
    [property: JsonPropertyName("update_available")] bool UpdateAvailable,
    [property: JsonPropertyName("artifact")] CompanionUpdateArtifact? Artifact,
    [property: JsonPropertyName("signed")] bool Signed,
    [property: JsonPropertyName("release_notes")] string ReleaseNotes,
    [property: JsonPropertyName("development")] bool Development,
    [property: JsonPropertyName("build_checksum")] string? BuildChecksum,
    [property: JsonPropertyName("component_checksums")] IReadOnlyDictionary<string, string>? ComponentChecksums,
    [property: JsonPropertyName("changed_components")] IReadOnlyList<CompanionUpdateComponent>? ChangedComponents,
    [property: JsonPropertyName("build_pending")] bool BuildPending,
    [property: JsonPropertyName("retry_after_ms")] int? RetryAfterMilliseconds);
public sealed record CompanionUpdateComponent(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("kind")] string Kind,
    [property: JsonPropertyName("checksum")] string Checksum);
public sealed record CompanionUpdateArtifact(
    [property: JsonPropertyName("url")] string Url,
    [property: JsonPropertyName("sha256")] string Sha256,
    [property: JsonPropertyName("bytes")] long Bytes,
    [property: JsonPropertyName("entrypoint")] string Entrypoint);
public sealed record StagedUpdate(string StageRoot, string ExecutablePath, string Sha256);
