using System.Reflection;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using AlibabaCloud.OSS.V2.Credentials;
using AlibabaCloud.OSS.V2.Models;
using Microsoft.Extensions.Options;
using Tiku.Application.Storage;
using Oss = AlibabaCloud.OSS.V2;

namespace Tiku.Infrastructure.Storage;

public sealed partial class AliyunOssObjectStorageService(
    IOptions<ObjectStorageOptions> storageOptions,
    IOptions<AliyunOssOptions> aliyunOptions)
    : IObjectStorageService, IDisposable
{
    private static readonly HashSet<string> SupportedProviders =
    [
        ObjectStorageProviders.ExternalUrl,
        ObjectStorageProviders.AliyunOss,
        ObjectStorageProviders.TencentCos,
        ObjectStorageProviders.QiniuKodo,
        ObjectStorageProviders.LocalDev
    ];

    private static readonly HashSet<string> UploadableProviders =
    [
        ObjectStorageProviders.LocalDev,
        ObjectStorageProviders.AliyunOss
    ];

    private readonly object clientLock = new();
    private Oss.Client? client;
    private bool disposed;

    public void Dispose()
    {
        if (disposed) return;

        lock (clientLock)
        {
            client?.Dispose();
            client = null;
            disposed = true;
        }
    }

    public string ConfiguredDefaultProvider()
    {
        return NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev);
    }

    public string ConfiguredDefaultBucket()
    {
        return string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket)
            ? "tenant-assets"
            : storageOptions.Value.DefaultBucket.Trim();
    }

    public string NormalizeProvider(string? value, string? fallback = null)
    {
        var provider = (string.IsNullOrWhiteSpace(value) ? fallback : value)?.Trim() ?? ConfiguredDefaultProvider();
        if (!SupportedProviders.Contains(provider))
            throw new ObjectStorageException($"Unsupported storage provider: {provider}",
                "UNSUPPORTED_STORAGE_PROVIDER");

        return provider;
    }

    public string ValidateObjectKey(Guid tenantId, string objectKey)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(objectKey);

        var clean = CanonicalObjectKey(objectKey);
        if (string.IsNullOrWhiteSpace(clean) ||
            clean.Contains("..", StringComparison.Ordinal) ||
            clean.Contains('\\', StringComparison.Ordinal) ||
            clean.Contains("%2f", StringComparison.OrdinalIgnoreCase) ||
            !SafeObjectKeyRegex().IsMatch(clean))
            throw new ObjectStorageException("Invalid objectKey.", "INVALID_OBJECT_KEY");

        if (storageOptions.Value.RequireTenantPrefix && !clean.StartsWith($"{tenantId:N}/", StringComparison.Ordinal))
            throw new ObjectStorageException(
                "objectKey must be scoped by tenantId prefix.",
                "OBJECT_KEY_TENANT_PREFIX_REQUIRED");

        return clean;
    }

    public string ValidateMimeType(string mimeType)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(mimeType);

        var normalized = mimeType.Trim().ToLowerInvariant();
        if (normalized.Length > 160 ||
            normalized.Contains('\r', StringComparison.Ordinal) ||
            normalized.Contains('\n', StringComparison.Ordinal))
            throw new ObjectStorageException("Invalid mimeType.", "INVALID_MIME_TYPE");

        var exact = storageOptions.Value.AllowedMimeTypes
            .Select(item => item.Trim().ToLowerInvariant())
            .Where(item => item.Length > 0)
            .ToHashSet(StringComparer.Ordinal);
        var prefixes = storageOptions.Value.AllowedMimePrefixes
            .Select(item => item.Trim().ToLowerInvariant())
            .Where(item => item.Length > 0)
            .ToArray();

        if (!exact.Contains(normalized) &&
            !prefixes.Any(prefix => normalized.StartsWith(prefix, StringComparison.Ordinal)))
            throw new ObjectStorageException($"mimeType is not allowed: {normalized}", "MIME_TYPE_NOT_ALLOWED");

        return normalized;
    }

    public long? ValidateFileSize(long? fileSizeBytes)
    {
        if (fileSizeBytes is null) return null;

        if (fileSizeBytes < 0)
            throw new ObjectStorageException("fileSizeBytes must be non-negative.", "INVALID_FILE_SIZE");

        if (fileSizeBytes > storageOptions.Value.MaxUploadBytes)
            throw new ObjectStorageException("file exceeds configured storage max upload bytes.", "FILE_TOO_LARGE");

        return fileSizeBytes;
    }

    public void AssertUploadProvider(string provider)
    {
        var normalized = NormalizeProvider(provider);
        if (!UploadableProviders.Contains(normalized))
            throw new ObjectStorageException(
                $"{normalized} does not support managed upload signing.",
                "UPLOAD_PROVIDER_NOT_SUPPORTED");
    }

    public void AssertWritableLocation(StorageAssetLocation location)
    {
        var provider = NormalizeProvider(location.Provider);
        if (provider == ObjectStorageProviders.AliyunOss &&
            (string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey)))
            throw new ObjectStorageException(
                $"{provider} asset requires bucket and objectKey.",
                "ASSET_OBJECT_LOCATION_REQUIRED");
    }

    public Task<ObjectStorageSignedUrl> SignUploadAsync(
        ObjectStorageUploadSignRequest request,
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var provider = NormalizeProvider(request.Provider);
        AssertUploadProvider(provider);
        var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
        var mimeType = ValidateMimeType(request.MimeType);
        ValidateFileSize(request.FileSizeBytes);

        return provider == ObjectStorageProviders.LocalDev
            ? Task.FromResult(LocalSignedUrl(
                provider,
                request.Bucket,
                objectKey,
                "PUT",
                request.ExpiresIn,
                "attachment",
                new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
                {
                    ["content-type"] = mimeType
                }))
            : Task.FromResult(SignAliyunOss(
                request.Bucket,
                objectKey,
                "PUT",
                request.ExpiresIn,
                mimeType,
                null,
                null));
    }

    public Task<ObjectStorageSignedUrl> SignDownloadAsync(
        ObjectStorageDownloadSignRequest request,
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var provider = NormalizeProvider(request.Provider);

        if (!string.IsNullOrWhiteSpace(request.CdnUrl))
            return Task.FromResult(new ObjectStorageSignedUrl(
                provider,
                request.Bucket,
                request.ObjectKey,
                "GET",
                new Uri(request.CdnUrl, UriKind.Absolute),
                new Dictionary<string, string>(),
                DateTimeOffset.UtcNow.Add(request.ExpiresIn),
                request.ExpiresIn,
                "public-or-provider-managed"));

        if (string.IsNullOrWhiteSpace(request.ObjectKey))
            throw new ObjectStorageException("Asset requires objectKey or cdnUrl.", "ASSET_LOCATION_REQUIRED");

        var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
        if (provider is ObjectStorageProviders.LocalDev or ObjectStorageProviders.QiniuKodo)
            return Task.FromResult(LocalSignedUrl(
                provider,
                request.Bucket,
                objectKey,
                "GET",
                request.ExpiresIn,
                request.Disposition));

        if (provider != ObjectStorageProviders.AliyunOss)
            throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");

        if (string.IsNullOrWhiteSpace(request.Bucket))
            throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");

        return Task.FromResult(SignAliyunOss(
            request.Bucket,
            objectKey,
            "GET",
            request.ExpiresIn,
            null,
            request.FileName,
            request.Disposition));
    }

    public async Task<ObjectStorageMetadata> HeadObjectAsync(
        ObjectStorageHeadRequest request,
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var provider = NormalizeProvider(request.Provider);
        if (string.IsNullOrWhiteSpace(request.ObjectKey))
            throw new ObjectStorageException("Asset requires objectKey.", "ASSET_LOCATION_REQUIRED");

        var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
        var fileSizeBytes = ValidateFileSize(request.DeclaredFileSizeBytes);
        var mimeType = request.DeclaredMimeType is null ? null : ValidateMimeType(request.DeclaredMimeType);
        var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant();

        if (provider == ObjectStorageProviders.LocalDev)
            return new ObjectStorageMetadata(
                provider,
                request.Bucket,
                objectKey,
                true,
                fileSizeBytes,
                mimeType,
                checksumSha256,
                checksumSha256,
                DateTimeOffset.UtcNow.ToString("O"),
                new Dictionary<string, string>(),
                "local-dev-declared-metadata");

        if (provider != ObjectStorageProviders.AliyunOss)
            throw new ObjectStorageException(
                $"{provider} does not support upload confirmation.",
                "UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED");

        if (string.IsNullOrWhiteSpace(request.Bucket))
            throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.",
                "ASSET_OBJECT_LOCATION_REQUIRED");

        try
        {
            var result = await GetClient().HeadObjectAsync(new HeadObjectRequest
            {
                Bucket = request.Bucket,
                Key = objectKey
            }, cancellationToken: cancellationToken);

            return MetadataFromHeadResult(request.Bucket, objectKey, result);
        }
        catch (Exception exception) when (TryGetStatusCode(exception) == 404)
        {
            throw new ObjectStorageException(
                "Uploaded object was not found in Aliyun OSS.",
                "STORAGE_OBJECT_NOT_FOUND");
        }
    }

    public async Task<ObjectStorageWriteResult> WriteObjectAsync(
        ObjectStorageWriteRequest request,
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var provider = NormalizeProvider(request.Provider);
        AssertUploadProvider(provider);
        var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
        var mimeType = ValidateMimeType(request.MimeType);
        var fileSizeBytes = ValidateFileSize(request.FileSizeBytes);
        var checksumSha256 = NormalizeChecksum(request.ChecksumSha256);
        if (request.Content.CanSeek) request.Content.Position = 0;

        if (string.IsNullOrWhiteSpace(request.Bucket))
            throw new ObjectStorageException("Writable asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");

        if (provider == ObjectStorageProviders.LocalDev)
            return new ObjectStorageWriteResult(
                provider,
                request.Bucket,
                objectKey,
                BuildPublicUrl(request.Bucket, objectKey),
                fileSizeBytes,
                mimeType,
                checksumSha256 ?? await ComputeSha256Async(request.Content, cancellationToken),
                null,
                new Dictionary<string, string>(),
                "local-dev-write-placeholder");

        if (provider != ObjectStorageProviders.AliyunOss)
            throw new ObjectStorageException(
                $"{provider} does not support managed server-side writes.",
                "WRITE_PROVIDER_NOT_SUPPORTED");

        var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        if (request.Metadata is not null)
            foreach (var (key, value) in request.Metadata)
                if (!string.IsNullOrWhiteSpace(key) && value is not null)
                    metadata[key.Trim()] = value;

        if (!string.IsNullOrWhiteSpace(checksumSha256)) metadata["sha256"] = checksumSha256;

        var result = await GetClient().PutObjectAsync(new PutObjectRequest
        {
            Bucket = request.Bucket,
            Key = objectKey,
            Body = request.Content,
            ContentType = mimeType,
            ContentLength = fileSizeBytes,
            Metadata = metadata.Count == 0 ? null : metadata,
            ForbidOverwrite = !request.Upsert
        }, cancellationToken: cancellationToken);

        return new ObjectStorageWriteResult(
            provider,
            request.Bucket,
            objectKey,
            BuildPublicUrl(request.Bucket, objectKey),
            fileSizeBytes,
            mimeType,
            checksumSha256,
            CleanEtag(result.ETag),
            NormalizeHeaders(result.Headers),
            "aliyun-oss-put-object");
    }

    public async Task<Stream> OpenReadAsync(
        ObjectStorageReadRequest request,
        CancellationToken cancellationToken = default)
    {
        var provider = NormalizeProvider(request.Provider);
        var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
        if (provider != ObjectStorageProviders.AliyunOss)
            throw new ObjectStorageException(
                $"{provider} does not support managed server-side reads.",
                "READ_PROVIDER_NOT_SUPPORTED");
        if (string.IsNullOrWhiteSpace(request.Bucket))
            throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");

        var result = await GetClient().GetObjectAsync(
            new GetObjectRequest { Bucket = request.Bucket, Key = objectKey },
            HttpCompletionOption.ResponseHeadersRead,
            null,
            cancellationToken);
        return result.Body ?? throw new ObjectStorageException(
            "Object storage returned an empty response stream.",
            "STORAGE_OBJECT_EMPTY_RESPONSE");
    }

    private ObjectStorageSignedUrl SignAliyunOss(
        string bucket,
        string objectKey,
        string method,
        TimeSpan expiresIn,
        string? mimeType,
        string? fileName,
        string? disposition)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
        var expiresAt = ResolveExpiration(expiresIn);

        PresignResult result;
        if (method == "PUT")
            result = GetClient().Presign(new PutObjectRequest
            {
                Bucket = bucket,
                Key = objectKey,
                ContentType = mimeType
            }, expiresAt.UtcDateTime);
        else
            result = GetClient().Presign(new GetObjectRequest
            {
                Bucket = bucket,
                Key = objectKey,
                ResponseContentDisposition = BuildContentDisposition(fileName, disposition)
            }, expiresAt.UtcDateTime);

        return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn,
            "aliyun-oss-signature-url-v4");
    }

    private ObjectStorageSignedUrl LocalSignedUrl(
        string provider,
        string? bucket,
        string? objectKey,
        string method,
        TimeSpan expiresIn,
        string? disposition,
        IReadOnlyDictionary<string, string>? headers = null)
    {
        var expiresAt = ResolveExpiration(expiresIn);
        var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/');
        var path = $"{Uri.EscapeDataString(bucket ?? "default")}/{Uri.EscapeDataString(objectKey ?? "missing")}";
        var url = string.IsNullOrWhiteSpace(baseUrl)
            ? $"https://local-storage.invalid/{provider}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}"
            : $"{baseUrl}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}";

        return new ObjectStorageSignedUrl(
            provider,
            bucket,
            objectKey,
            method,
            new Uri(url),
            headers ?? new Dictionary<string, string>(),
            expiresAt,
            expiresIn,
            "local-placeholder");
    }

    private Uri? BuildPublicUrl(string bucket, string objectKey)
    {
        var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/');
        if (string.IsNullOrWhiteSpace(baseUrl)) return null;

        var path = string.Join(
            '/',
            bucket.Trim('/'),
            objectKey
                .Split('/', StringSplitOptions.RemoveEmptyEntries)
                .Select(Uri.EscapeDataString));
        return new Uri($"{baseUrl}/{path}", UriKind.Absolute);
    }

    private Oss.Client GetClient()
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        if (client is not null) return client;

        lock (clientLock)
        {
            client ??= CreateClient(aliyunOptions.Value);
            return client;
        }
    }

    private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
    {
        if (!currentOptions.IsConfigured)
            throw new ObjectStorageNotConfiguredException(
                "aliyun_oss is not configured: ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET and ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT are required.");

        var configuration = Oss.Configuration.LoadDefault();
        configuration.Region = currentOptions.Region?.Trim();
        configuration.Endpoint = NullIfWhiteSpace(currentOptions.Endpoint);
        configuration.UseInternalEndpoint = currentOptions.UseInternalEndpoint;
        configuration.UsePathStyle = currentOptions.UsePathStyle;
        configuration.UseCName = currentOptions.UseCName;
        configuration.DisableSsl = false;
        configuration.CredentialsProvider = string.IsNullOrWhiteSpace(currentOptions.SecurityToken)
            ? new StaticCredentialsProvider(
                currentOptions.AccessKeyId!.Trim(),
                currentOptions.AccessKeySecret!.Trim())
            : new StaticCredentialsProvider(
                currentOptions.AccessKeyId!.Trim(),
                currentOptions.AccessKeySecret!.Trim(),
                currentOptions.SecurityToken!.Trim());

        return new Oss.Client(configuration);
    }

    private static ObjectStorageSignedUrl ToSignedUrl(
        PresignResult result,
        string provider,
        string bucket,
        string objectKey,
        string method,
        TimeSpan expiresIn,
        string signatureMode)
    {
        if (!Uri.TryCreate(result.Url, UriKind.Absolute, out var url))
            throw new InvalidOperationException("Aliyun OSS SDK returned an invalid presigned URL.");

        return new ObjectStorageSignedUrl(
            provider,
            bucket,
            objectKey,
            string.IsNullOrWhiteSpace(result.Method) ? method : result.Method,
            url,
            result.SignedHeaders is null
                ? new Dictionary<string, string>()
                : new Dictionary<string, string>(result.SignedHeaders, StringComparer.OrdinalIgnoreCase),
            result.Expiration.HasValue
                ? new DateTimeOffset(DateTime.SpecifyKind(result.Expiration.Value, DateTimeKind.Utc))
                : DateTimeOffset.UtcNow.Add(expiresIn),
            expiresIn,
            signatureMode);
    }

    private static ObjectStorageMetadata MetadataFromHeadResult(
        string bucket,
        string objectKey,
        HeadObjectResult result)
    {
        var headers = NormalizeHeaders(result.Headers);
        foreach (var metadata in result.Metadata ?? new Dictionary<string, string>())
            headers[$"x-oss-meta-{metadata.Key}".ToLowerInvariant()] = metadata.Value;

        return new ObjectStorageMetadata(
            ObjectStorageProviders.AliyunOss,
            bucket,
            objectKey,
            true,
            result.ContentLength,
            NullIfWhiteSpace(result.ContentType),
            FirstHeader(headers, "x-oss-meta-sha256", "x-oss-meta-checksum-sha256")?.Trim().ToLowerInvariant(),
            CleanEtag(result.ETag ?? FirstHeader(headers, "etag")),
            NullIfWhiteSpace(result.LastModified) ?? FirstHeader(headers, "last-modified"),
            headers,
            "aliyun-oss-head-object");
    }

    private static Dictionary<string, string> NormalizeHeaders(IDictionary<string, string>? headers)
    {
        var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        if (headers is null) return normalized;

        foreach (var (key, value) in headers)
            if (!string.IsNullOrWhiteSpace(key) && value is not null)
                normalized[key.ToLowerInvariant()] = value;

        return normalized;
    }

    private static int? TryGetStatusCode(Exception exception)
    {
        var type = exception.GetType();
        var property = type.GetProperty("StatusCode", BindingFlags.Public | BindingFlags.Instance) ??
                       type.GetProperty("Status", BindingFlags.Public | BindingFlags.Instance);
        return property?.GetValue(exception) switch
        {
            int statusCode => statusCode,
            long statusCode => checked((int)statusCode),
            string value when int.TryParse(value, out var statusCode) => statusCode,
            _ => null
        };
    }

    private static DateTimeOffset ResolveExpiration(TimeSpan expiresIn)
    {
        if (expiresIn <= TimeSpan.Zero)
            throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn,
                "Signed URL expiration must be positive.");

        return DateTimeOffset.UtcNow.Add(expiresIn);
    }

    private static string CanonicalObjectKey(string objectKey)
    {
        return ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/");
    }

    private static string? BuildContentDisposition(string? fileName, string? disposition)
    {
        if (string.IsNullOrWhiteSpace(fileName)) return null;

        var normalizedDisposition = string.Equals(disposition, "inline", StringComparison.OrdinalIgnoreCase)
            ? "inline"
            : "attachment";
        return $"{normalizedDisposition}; filename=\"{Uri.EscapeDataString(fileName.Trim())}\"";
    }

    private static string? FirstHeader(IReadOnlyDictionary<string, string> headers, params string[] keys)
    {
        foreach (var key in keys)
            if (headers.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
                return value;

        return null;
    }

    private static string? CleanEtag(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"');
    }

    private static string? NullIfWhiteSpace(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
    }

    private static string? NormalizeChecksum(string? value)
    {
        if (string.IsNullOrWhiteSpace(value)) return null;

        var normalized = value.Trim().ToLowerInvariant();
        return normalized.Length == 64 ? normalized : null;
    }

    private static async Task<string> ComputeSha256Async(Stream stream, CancellationToken cancellationToken)
    {
        if (stream.CanSeek) stream.Position = 0;

        var hash = await SHA256.HashDataAsync(stream, cancellationToken);
        if (stream.CanSeek) stream.Position = 0;

        return Convert.ToHexString(hash).ToLowerInvariant();
    }

    [GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")]
    private static partial Regex SafeObjectKeyRegex();

    [GeneratedRegex("/{2,}")]
    private static partial Regex ConsecutiveSlashRegex();
}