📄 src/Infrastructure/TikTok/TikTokUploader.cs
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Slopper.Domain;

namespace Slopper.Infrastructure.TikTok;

internal sealed class TikTokUploader(
    ITikTokAccessTokenProvider accessTokenProvider,
    IOptions<TikTokUploaderOptions> options,
    IHttpClientFactory httpClientFactory,
    TimeProvider timeProvider
) : IUploader
{
    private static readonly Uri TikTokBase = new("https://open.tiktokapis.com");
    private readonly long defaultChunkSize = options.Value.ChunkSize;
    private readonly string privacyLevel = options.Value.PrivacyLevel;

    public async Task<Upload> Upload(Clip clip, DateTimeOffset publishAt, CancellationToken cancellationToken)
    {
        var token = accessTokenProvider.AccessToken;
        var client = httpClientFactory.CreateClient("TikTok");

        var (publishId, uploadUrl) = await Init(client, token, clip, cancellationToken);
        await UploadFile(client, uploadUrl, clip.Path, cancellationToken);
        return await PollStatus(client, token, publishId, cancellationToken);
    }

    private async Task<(string PublishId, string UploadUrl)> Init(
        HttpClient client,
        string token,
        Clip clip,
        CancellationToken cancellationToken
    )
    {
        var fileSize = new FileInfo(clip.Path).Length;
        var chunkSize = long.Min(defaultChunkSize, fileSize);
        var chunkCount = (long)double.Ceiling((double)fileSize / chunkSize);

        var body = new InitRequest(
            new PostInfo(clip.Caption ?? string.Empty, privacyLevel),
            new SourceInfo(fileSize, chunkSize, chunkCount)
        );

        using var request = new HttpRequestMessage(
            HttpMethod.Post,
            new Uri(TikTokBase, "/v2/post/publish/video/init/")
        );
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        request.Content = JsonContent.Create(body);

        using var response = await client.SendAsync(request, cancellationToken);
        response.EnsureSuccessStatusCode();

        var result =
            await response.Content.ReadFromJsonAsync<TikTokResponse<InitData>>(cancellationToken)
            ?? throw new Exception("Failed to deserialize TikTok init response");
        if (result.Error.Code is not "ok")
            throw new Exception($"TikTok init failed: {result.Error.Code}{result.Error.Message}");

        return (result.Data.PublishId, result.Data.UploadUrl);
    }

    private async Task UploadFile(HttpClient client, string uploadUrl, string path, CancellationToken cancellationToken)
    {
        var fileSize = new FileInfo(path).Length;
        var chunkSize = long.Min(defaultChunkSize, fileSize);
        var chunkCount = (long)double.Ceiling((double)fileSize / chunkSize);

        using var stream = File.OpenRead(path);

        if (chunkCount is 1)
        {
            var content = new StreamContent(stream);
            content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
            content.Headers.TryAddWithoutValidation("Content-Range", $"bytes 0-{fileSize - 1}/{fileSize}");

            using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = content };
            using var response = await client.SendAsync(request, cancellationToken);
            response.EnsureSuccessStatusCode();
            return;
        }

        var buffer = new byte[chunkSize];
        for (var i = 0; i < chunkCount; i++)
        {
            var start = i * chunkSize;
            var thisChunkSize = (int)long.Min(chunkSize, fileSize - start);
            var end = start + thisChunkSize - 1;

            await stream.ReadExactlyAsync(buffer.AsMemory(0, thisChunkSize), cancellationToken);

            var content = new ByteArrayContent(buffer, 0, thisChunkSize);
            content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
            content.Headers.TryAddWithoutValidation("Content-Range", $"bytes {start}-{end}/{fileSize}");

            using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = content };
            using var response = await client.SendAsync(request, cancellationToken);
            response.EnsureSuccessStatusCode();
        }
    }

    private async Task<Upload> PollStatus(
        HttpClient client,
        string token,
        string publishId,
        CancellationToken cancellationToken
    )
    {
        var deadline = timeProvider.GetUtcNow().AddMinutes(1);
        while (timeProvider.GetUtcNow() < deadline)
        {
            using var request = new HttpRequestMessage(
                HttpMethod.Post,
                new Uri(TikTokBase, "/v2/post/publish/status/fetch/")
            );
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
            request.Content = JsonContent.Create(new StatusRequest(publishId));

            using var response = await client.SendAsync(request, cancellationToken);
            response.EnsureSuccessStatusCode();

            var status =
                await response.Content.ReadFromJsonAsync<TikTokResponse<StatusData>>(cancellationToken)
                ?? throw new Exception("Failed to deserialize TikTok status response");

            if (status.Data.Status == "FAILED")
                throw new Exception($"TikTok upload failed for publish_id {publishId}");

            if (status.Data.Status == "PUBLISH_COMPLETE")
            {
                var videoId = status.Data.PublicalyAvailablePostId?.FirstOrDefault();
                var now = timeProvider.GetUtcNow();
                return new Upload(
                    videoId is null ? null : new Uri($"https://www.tiktok.com/video/{videoId}"),
                    now,
                    now,
                    "TikTok"
                );
            }

            await Task.Delay(TimeSpan.FromSeconds(1), timeProvider, cancellationToken);
        }

        throw new Exception($"TikTok upload timed out for publish_id {publishId}");
    }

    private sealed record InitRequest(
        [property: JsonPropertyName("post_info")] PostInfo PostInfo,
        [property: JsonPropertyName("source_info")] SourceInfo SourceInfo
    );

    private sealed record PostInfo(
        [property: JsonPropertyName("title")] string Title,
        [property: JsonPropertyName("privacy_level")] string PrivacyLevel,
        [property: JsonPropertyName("brand_content_toggle")] bool BrandContentToggle = false,
        [property: JsonPropertyName("brand_organic_toggle")] bool BrandOrganicToggle = false
    );

    private sealed record SourceInfo(
        [property: JsonPropertyName("video_size")] long VideoSize,
        [property: JsonPropertyName("chunk_size")] long ChunkSize,
        [property: JsonPropertyName("total_chunk_count")] long TotalChunkCount,
        [property: JsonPropertyName("source")] string Source = "FILE_UPLOAD"
    );

    private sealed record StatusRequest([property: JsonPropertyName("publish_id")] string PublishId);

    private sealed record TikTokResponse<T>(
        [property: JsonPropertyName("data")] T Data,
        [property: JsonPropertyName("error")] TikTokError Error
    );

    private sealed record TikTokError(
        [property: JsonPropertyName("code")] string Code,
        [property: JsonPropertyName("message")] string Message
    );

    private sealed record InitData(
        [property: JsonPropertyName("publish_id")] string PublishId,
        [property: JsonPropertyName("upload_url")] string UploadUrl
    );

    private sealed record StatusData(
        [property: JsonPropertyName("status")] string Status,
        [property: JsonPropertyName("publicaly_available_post_id")] string[]? PublicalyAvailablePostId
    );
}