// <copyright file="PostgresBackupService.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>

namespace Jellyfin.Database.Providers.Postgres;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Providers.Postgres.Exceptions;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;

/// <summary>
/// Service for creating and restoring PostgreSQL database backups using pg_dump/pg_restore.
/// </summary>
public class PostgresBackupService
{
    private readonly ILogger<PostgresBackupService> _logger;
    private readonly IConfigurationManager _configurationManager;

    /// <summary>
    /// Initializes a new instance of the <see cref="PostgresBackupService"/> class.
    /// </summary>
    /// <param name="logger">The logger.</param>
    /// <param name="configurationManager">The configuration manager.</param>
    public PostgresBackupService(
        ILogger<PostgresBackupService> logger,
        IConfigurationManager configurationManager)
    {
        _logger = logger;
        _configurationManager = configurationManager;
    }

    /// <summary>
    /// Creates a backup of the PostgreSQL database.
    /// </summary>
    /// <param name="backupDirectory">The directory to save the backup file.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The path to the created backup file.</returns>
    public async Task<string> CreateBackupAsync(string backupDirectory, CancellationToken cancellationToken = default)
    {
        var dbConfig = _configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database");
        var backupOptions = dbConfig.BackupOptions ?? new DatabaseBackupOptions();

        if (dbConfig.CustomProviderOptions?.ConnectionString == null)
        {
            throw new InvalidOperationException("Database connection string is not configured");
        }

        var connectionString = dbConfig.CustomProviderOptions.ConnectionString;
        var connectionParams = ParseConnectionString(connectionString);

        var pgDumpPath = FindExecutable(backupOptions.PgDumpPath, "pg_dump");
        var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
        var extension = GetFileExtension(backupOptions.BackupFormat);
        var backupFileName = $"jellyfin_postgres_backup_{timestamp}.{extension}";
        var fullBackupPath = Path.Combine(backupDirectory, backupFileName);

        Directory.CreateDirectory(backupDirectory);

        var arguments = BuildPgDumpArguments(connectionParams, backupOptions, fullBackupPath);

        _logger.LogInformation("Starting PostgreSQL backup using external pg_dump tool to {BackupPath}", fullBackupPath);
        _logger.LogDebug("Executing external command: {PgDump} {Arguments}", pgDumpPath, string.Join(" ", arguments.Where(a => !a.Contains("PGPASSWORD", StringComparison.OrdinalIgnoreCase))));

        var processStartInfo = new ProcessStartInfo
        {
            FileName = pgDumpPath,
            Arguments = string.Join(" ", arguments),
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };

        // Set password via environment variable (more secure than command line)
        if (connectionParams.TryGetValue("Password", out var password))
        {
            processStartInfo.EnvironmentVariables["PGPASSWORD"] = password;
        }

        using var process = new Process { StartInfo = processStartInfo };

        var outputBuilder = new StringBuilder();
        var errorBuilder = new StringBuilder();

        process.OutputDataReceived += (sender, e) =>
        {
            if (e.Data != null)
            {
                outputBuilder.AppendLine(e.Data);
                if (backupOptions.VerboseOutput)
                {
                    _logger.LogDebug("pg_dump: {Output}", e.Data);
                }
            }
        };

        process.ErrorDataReceived += (sender, e) =>
        {
            if (e.Data != null)
            {
                errorBuilder.AppendLine(e.Data);
                if (backupOptions.VerboseOutput)
                {
                    _logger.LogDebug("pg_dump stderr: {Error}", e.Data);
                }
            }
        };

        process.Start();
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        // Wait with timeout
        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(backupOptions.TimeoutSeconds));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);

        try
        {
            await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            process.Kill();
            throw new TimeoutException($"Backup operation timed out after {backupOptions.TimeoutSeconds} seconds");
        }

        if (process.ExitCode != 0)
        {
            var errorMessage = errorBuilder.ToString();
            _logger.LogError("pg_dump failed with exit code {ExitCode}: {Error}", process.ExitCode, errorMessage);

            // Check for version mismatch error
            if (errorMessage.Contains("server version mismatch", StringComparison.OrdinalIgnoreCase))
            {
                var (serverVersion, clientVersion) = ParseVersionMismatchError(errorMessage);

                _logger.LogError(
                    "PostgreSQL version mismatch detected - Server: {ServerVersion}, pg_dump: {ClientVersion}. " +
                    "Please upgrade your PostgreSQL client tools to match the server version.",
                    serverVersion,
                    clientVersion);

                // Clean up failed backup file
                if (File.Exists(fullBackupPath))
                {
                    File.Delete(fullBackupPath);
                }

                throw new PostgresVersionMismatchException(serverVersion, clientVersion);
            }

            // Clean up failed backup file
            if (File.Exists(fullBackupPath))
            {
                File.Delete(fullBackupPath);
            }

            throw new Exception($"Database backup failed: {errorMessage}");
        }

        var fileInfo = new FileInfo(fullBackupPath);
        _logger.LogInformation(
            "Successfully created PostgreSQL backup at {BackupPath} ({Size:N0} bytes)",
            fullBackupPath,
            fileInfo.Length);

        return fullBackupPath;
    }

    /// <summary>
    /// Restores a PostgreSQL database from a backup file.
    /// </summary>
    /// <param name="backupFilePath">The path to the backup file.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A task representing the restore operation.</returns>
    public async Task RestoreBackupAsync(string backupFilePath, CancellationToken cancellationToken = default)
    {
        var dbConfig = _configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database");
        var backupOptions = dbConfig.BackupOptions ?? new DatabaseBackupOptions();

        if (!File.Exists(backupFilePath))
        {
            throw new FileNotFoundException($"Backup file not found: {backupFilePath}");
        }

        if (dbConfig.CustomProviderOptions?.ConnectionString == null)
        {
            throw new InvalidOperationException("Database connection string is not configured");
        }

        var connectionString = dbConfig.CustomProviderOptions.ConnectionString;
        var connectionParams = ParseConnectionString(connectionString);

        var pgRestorePath = FindExecutable(backupOptions.PgRestorePath, "pg_restore");

        var arguments = BuildPgRestoreArguments(connectionParams, backupOptions, backupFilePath);

        _logger.LogInformation("Starting PostgreSQL restore from {BackupPath}", backupFilePath);

        var processStartInfo = new ProcessStartInfo
        {
            FileName = pgRestorePath,
            Arguments = string.Join(" ", arguments),
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };

        if (connectionParams.TryGetValue("Password", out var password))
        {
            processStartInfo.EnvironmentVariables["PGPASSWORD"] = password;
        }

        using var process = new Process { StartInfo = processStartInfo };

        var outputBuilder = new StringBuilder();
        var errorBuilder = new StringBuilder();

        process.OutputDataReceived += (sender, e) =>
        {
            if (e.Data != null)
            {
                outputBuilder.AppendLine(e.Data);
                if (backupOptions.VerboseOutput)
                {
                    _logger.LogDebug("pg_restore: {Output}", e.Data);
                }
            }
        };

        process.ErrorDataReceived += (sender, e) =>
        {
            if (e.Data != null)
            {
                errorBuilder.AppendLine(e.Data);
                if (backupOptions.VerboseOutput)
                {
                    _logger.LogDebug("pg_restore stderr: {Error}", e.Data);
                }
            }
        };

        process.Start();
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(backupOptions.TimeoutSeconds));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);

        try
        {
            await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            process.Kill();
            throw new TimeoutException($"Restore operation timed out after {backupOptions.TimeoutSeconds} seconds");
        }

        if (process.ExitCode != 0)
        {
            var errorMessage = errorBuilder.ToString();
            _logger.LogError("pg_restore failed with exit code {ExitCode}: {Error}", process.ExitCode, errorMessage);
            throw new Exception($"Database restore failed: {errorMessage}");
        }

        _logger.LogInformation("Successfully restored PostgreSQL backup from {BackupPath}", backupFilePath);
    }

    private static Dictionary<string, string> ParseConnectionString(string connectionString)
    {
        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries);

        foreach (var part in parts)
        {
            var kvp = part.Split('=', 2);
            if (kvp.Length == 2)
            {
                result[kvp[0].Trim()] = kvp[1].Trim();
            }
        }

        return result;
    }

    private List<string> BuildPgDumpArguments(
        Dictionary<string, string> connectionParams,
        DatabaseBackupOptions options,
        string outputPath)
    {
        var args = new List<string>();

        // Connection parameters
        if (connectionParams.TryGetValue("Host", out var host))
        {
            args.Add($"-h {host}");
        }

        if (connectionParams.TryGetValue("Port", out var port))
        {
            args.Add($"-p {port}");
        }

        if (connectionParams.TryGetValue("Username", out var username))
        {
            args.Add($"-U {username}");
        }

        if (connectionParams.TryGetValue("Database", out var database))
        {
            args.Add($"-d {database}");
        }

        // Format
        var formatFlag = options.BackupFormat.ToLowerInvariant() switch
        {
            "plain" => "p",
            "custom" => "c",
            "directory" => "d",
            "tar" => "t",
            _ => "c"
        };
        args.Add($"-F {formatFlag}");

        // Compression level (for custom and directory formats)
        if (formatFlag is "c" or "d")
        {
            args.Add($"-Z {options.CompressionLevel}");
        }

        // Include blobs
        if (options.IncludeBlobs)
        {
            args.Add("-b");
        }

        // Verbose output
        if (options.VerboseOutput)
        {
            args.Add("-v");
        }

        // Parallel jobs (directory format only)
        if (options.ParallelJobs.HasValue && formatFlag == "d")
        {
            args.Add($"-j {options.ParallelJobs.Value}");
        }

        // Additional arguments
        if (!string.IsNullOrWhiteSpace(options.AdditionalArguments))
        {
            args.Add(options.AdditionalArguments);
        }

        // Output file
        args.Add($"-f \"{outputPath}\"");

        return args;
    }

    private List<string> BuildPgRestoreArguments(
        Dictionary<string, string> connectionParams,
        DatabaseBackupOptions options,
        string backupPath)
    {
        var args = new List<string>();

        // Connection parameters
        if (connectionParams.TryGetValue("Host", out var host))
        {
            args.Add($"-h {host}");
        }

        if (connectionParams.TryGetValue("Port", out var port))
        {
            args.Add($"-p {port}");
        }

        if (connectionParams.TryGetValue("Username", out var username))
        {
            args.Add($"-U {username}");
        }

        if (connectionParams.TryGetValue("Database", out var database))
        {
            args.Add($"-d {database}");
        }

        // Clean before restore
        args.Add("--clean");
        args.Add("--if-exists");

        // Verbose output
        if (options.VerboseOutput)
        {
            args.Add("-v");
        }

        // Parallel jobs
        if (options.ParallelJobs.HasValue)
        {
            args.Add($"-j {options.ParallelJobs.Value}");
        }

        // Input file
        args.Add($"\"{backupPath}\"");

        return args;
    }

    private string FindExecutable(string? configuredPath, string defaultExecutableName)
    {
        // If configured path is provided, use it
        if (!string.IsNullOrWhiteSpace(configuredPath))
        {
            if (File.Exists(configuredPath))
            {
                return configuredPath;
            }

            _logger.LogWarning("Configured path {Path} does not exist, searching in PATH", configuredPath);
        }

        // Try to find in PATH
        var possiblePaths = new[]
        {
            defaultExecutableName, // In PATH
            Path.Combine("/usr/bin", defaultExecutableName),
            Path.Combine("/usr/local/bin", defaultExecutableName),
            Path.Combine(@"C:\Program Files\PostgreSQL\16\bin", $"{defaultExecutableName}.exe"),
            Path.Combine(@"C:\Program Files\PostgreSQL\15\bin", $"{defaultExecutableName}.exe"),
            Path.Combine(@"C:\Program Files\PostgreSQL\14\bin", $"{defaultExecutableName}.exe"),
        };

        foreach (var path in possiblePaths)
        {
            if (File.Exists(path))
            {
                _logger.LogDebug("Found {Executable} at {Path}", defaultExecutableName, path);
                return path;
            }

            // Check if it's in PATH by trying to execute --version
            if (IsInPath(path))
            {
                _logger.LogDebug("Found {Executable} in PATH", defaultExecutableName);
                return path;
            }
        }

        throw new FileNotFoundException($"{defaultExecutableName} executable not found. Please configure the path in database.xml or ensure it's in the system PATH.");
    }

    private bool IsInPath(string fileName)
    {
        try
        {
            using var process = new Process
            {
                StartInfo = new ProcessStartInfo
                {
                    FileName = fileName,
                    Arguments = "--version",
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    CreateNoWindow = true
                }
            };
            process.Start();
            process.WaitForExit(2000);
            return process.ExitCode == 0;
        }
        catch
        {
            return false;
        }
    }

    private static string GetFileExtension(string format)
    {
        return format.ToLowerInvariant() switch
        {
            "plain" => "sql",
            "custom" => "dump",
            "directory" => "dir",
            "tar" => "tar",
            _ => "dump"
        };
    }

    /// <summary>
    /// Parses PostgreSQL version mismatch error message to extract server and client versions.
    /// </summary>
    /// <param name="errorMessage">The error message from pg_dump.</param>
    /// <returns>A tuple containing server version and client version.</returns>
    private static (string ServerVersion, string ClientVersion) ParseVersionMismatchError(string errorMessage)
    {
        var serverVersion = "unknown";
        var clientVersion = "unknown";

        // Parse server version: "server version: 18.3 (Debian 18.3-1.pgdg13+1)"
        var serverMatch = Regex.Match(errorMessage, @"server version:\s*([^\s;]+(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase);
        if (serverMatch.Success)
        {
            serverVersion = serverMatch.Groups[1].Value.Trim();
        }

        // Parse pg_dump version: "pg_dump version: 16.12 (Ubuntu 16.12-1.pgdg24.04+1)"
        var clientMatch = Regex.Match(errorMessage, @"pg_dump version:\s*([^\s;]+(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase);
        if (clientMatch.Success)
        {
            clientVersion = clientMatch.Groups[1].Value.Trim();
        }

        return (serverVersion, clientVersion);
    }
}
