// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.IO;
using System.Linq;
using NUnit.Framework;
using Duplicati.Library.Main;
using Microsoft.Data.Sqlite;
using Assert = NUnit.Framework.Legacy.ClassicAssert;

namespace Duplicati.UnitTest
{
    /// <summary>
    /// Test for Issue 6688: Recreate makes bad DB referencing Temporary Remotevolume ID if dindex lacks dblock
    /// 
    /// When recreating a database from remote files, if a dindex file references a dblock that is missing,
    /// the recreate operation may complete with apparent success but the resulting database contains
    /// invalid references - DeletedBlock entries pointing to Temporary RemoteVolume IDs.
    /// </summary>
    public class Issue6688 : BasicSetupHelper
    {
        [Test]
        [Category("Issue6688")]
        [Category("Recreate")]
        public void RecreateDbShouldNotReferenceTemporaryVolumesInDeletedBlock()
        {
            // Arrange
            var testopts = TestOptions;
            testopts["backup-test-samples"] = "0";
            testopts["no-encryption"] = "true";
            testopts["keep-versions"] = "0";
            testopts["blocksize"] = "1024"; // Small block size for testing

            var sourceFolder = Path.Combine(DATAFOLDER, "source");
            Directory.CreateDirectory(sourceFolder);

            // Step 1: Create a file with enough content to create blocks and perform initial backup
            var testContent = string.Join("\n", Enumerable.Range(0, 100).Select(i => $"Line {i}: Test content for issue 6688 - this is test data to ensure we have enough content to create blocks."));
            File.WriteAllText(Path.Combine(sourceFolder, "test.txt"), testContent);
            
            using (var c = new Controller("file://" + TARGETFOLDER, testopts, null))
            {
                var res = c.Backup(new[] { sourceFolder });
                Assert.AreEqual(0, res.Errors.Count(), "Initial backup should succeed");
            }

            // Get the dblock files (there may be multiple with small blocksize)
            var dblockFiles = Directory.GetFiles(TARGETFOLDER, "*.dblock.*");
            Assert.IsTrue(dblockFiles.Length > 0, "Should have at least one dblock file");
            var dblockPath = dblockFiles[0];
            
            // Get the dindex files
            var dindexFiles = Directory.GetFiles(TARGETFOLDER, "*.dindex.*");
            Assert.IsTrue(dindexFiles.Length > 0, "Should have at least one dindex file");

            // Step 2: Delete the local database to force recreate
            File.Delete(testopts["dbpath"]);

            // Step 3: Delete the dblock file (simulate corruption/missing file)
            // Keep the dindex file which now references a missing dblock
            File.Delete(dblockPath);
            
            TestContext.Progress.WriteLine($"Deleted dblock: {Path.GetFileName(dblockPath)}");
            TestContext.Progress.WriteLine($"Remaining dindex files: {string.Join(", ", dindexFiles.Select(Path.GetFileName))}");

            // Step 4: Recreate the database
            using (var c = new Controller("file://" + TARGETFOLDER, testopts, null))
            {
                try
                {
                    var res = c.Repair();
                    TestContext.Progress.WriteLine($"Repair result: {res.ParsedResult}");
                    
                    // The operation may succeed with warnings, but we need to verify DB state
                }
                catch (Exception ex)
                {
                    // It's acceptable for repair to fail with a clear error
                    TestContext.Progress.WriteLine($"Repair failed: {ex.Message}");
                }
            }

            // Step 5: Verify the database does not contain invalid references
            VerifyNoInvalidDeletedBlockReferences(testopts["dbpath"]);
        }
        
        [Test]
        [Category("Issue6688")]
        [Category("Recreate")]
        public void RecreateDbShouldNotReferenceTemporaryVolumes_WhenDindexReferencesMissingDblock()
        {
            // This test more closely reproduces the issue scenario:
            // 1. Create two backups (versions)
            // 2. Delete the first version's dblock file
            // 3. Keep the first version's dindex file (which references the deleted dblock)
            // 4. Delete the database
            // 5. Recreate - this should handle the missing dblock gracefully
            
            var testopts = TestOptions;
            testopts["backup-test-samples"] = "0";
            testopts["no-encryption"] = "true";
            testopts["keep-versions"] = "0";

            var sourceFolder = Path.Combine(DATAFOLDER, "source");
            Directory.CreateDirectory(sourceFolder);

            // Step 1: Create file A and backup (Version 1)
            File.WriteAllText(Path.Combine(sourceFolder, "A.txt"), "Content A - version 1");
            
            using (var c = new Controller("file://" + TARGETFOLDER, testopts, null))
            {
                var res = c.Backup(new[] { sourceFolder });
                Assert.AreEqual(0, res.Errors.Count(), "First backup should succeed");
            }

            // Save the dindex file from first backup
            var dindexFiles = Directory.GetFiles(TARGETFOLDER, "*.dindex.*");
            Assert.IsTrue(dindexFiles.Length > 0, "Should have dindex after first backup");
            var dindex1Path = dindexFiles[0];
            var savedDindexPath = Path.Combine(DATAFOLDER, "dindex1_backup.zip");
            File.Copy(dindex1Path, savedDindexPath, true);
            
            // Get the first backup's dblock
            var dblockFiles1 = Directory.GetFiles(TARGETFOLDER, "*.dblock.*");
            Assert.IsTrue(dblockFiles1.Length > 0, "Should have dblock after first backup");
            var firstDblock = dblockFiles1[0];

            // Step 2: Modify file and backup again (Version 2)
            File.WriteAllText(Path.Combine(sourceFolder, "A.txt"), "Content A - version 2 - modified");
            
            using (var c = new Controller("file://" + TARGETFOLDER, testopts, null))
            {
                var res = c.Backup(new[] { sourceFolder });
                Assert.AreEqual(0, res.Errors.Count(), "Second backup should succeed");
            }

            // Step 3: Delete the database and prepare for recreate
            File.Delete(testopts["dbpath"]);
            
            // Delete the first dblock file (simulating missing dblock)
            File.Delete(firstDblock);
            
            // Copy the old dindex back (it references the now-deleted dblock)
            // Rename it to ensure it's processed early
            var earlyDindexPath = Path.Combine(TARGETFOLDER, "duplicati-00000000000000000000000000000000.dindex.zip");
            File.Copy(savedDindexPath, earlyDindexPath, true);
            
            TestContext.Progress.WriteLine($"Deleted first dblock: {Path.GetFileName(firstDblock)}");
            TestContext.Progress.WriteLine($"Restored old dindex: {Path.GetFileName(savedDindexPath)}");

            // Step 4: Recreate the database
            using (var c = new Controller("file://" + TARGETFOLDER, testopts, null))
            {
                try
                {
                    var res = c.Repair();
                    TestContext.Progress.WriteLine($"Repair result: {res.ParsedResult}");
                }
                catch (Exception ex)
                {
                    TestContext.Progress.WriteLine($"Repair failed: {ex.Message}");
                }
            }

            // Step 5: Verify the database does not contain invalid references
            VerifyNoInvalidDeletedBlockReferences(testopts["dbpath"]);
        }
        
        private void VerifyNoInvalidDeletedBlockReferences(string dbPath)
        {
            using var connection = new SqliteConnection($"Data Source={dbPath};Pooling=false");
            connection.Open();
            var command = connection.CreateCommand();

            // Check for DeletedBlock entries pointing to Temporary volumes
            command.CommandText = @"
                SELECT COUNT(*)
                FROM DeletedBlock
                WHERE VolumeID IN (SELECT ID FROM RemoteVolume WHERE State = 'Temporary')
            ";
            var tempCount = (long)command.ExecuteScalar();

            // Check for DeletedBlock entries with orphaned VolumeID references
            command.CommandText = @"
                SELECT COUNT(*)
                FROM DeletedBlock
                WHERE VolumeID NOT IN (SELECT ID FROM RemoteVolume)
            ";
            var orphanedCount = (long)command.ExecuteScalar();
            
            // Log diagnostic information
            TestContext.Progress.WriteLine($"DeletedBlock entries referencing Temporary volumes: {tempCount}");
            TestContext.Progress.WriteLine($"DeletedBlock entries with orphaned VolumeID: {orphanedCount}");
            
            // List all RemoteVolumes and their states
            command.CommandText = @"
                SELECT ID, Name, State, Type FROM RemoteVolume ORDER BY Type, Name
            ";
            using (var reader = command.ExecuteReader())
            {
                TestContext.Progress.WriteLine("\nRemoteVolume entries:");
                while (reader.Read())
                {
                    var id = reader.GetInt64(0);
                    var name = reader.GetString(1);
                    var state = reader.GetString(2);
                    var type = reader.GetString(3);
                    TestContext.Progress.WriteLine($"  ID={id}, Name={name}, State={state}, Type={type}");
                }
            }
            
            // List all DeletedBlock entries
            command.CommandText = @"
                SELECT db.ID, db.Hash, db.Size, db.VolumeID, rv.State
                FROM DeletedBlock db
                LEFT JOIN RemoteVolume rv ON db.VolumeID = rv.ID
            ";
            using (var reader = command.ExecuteReader())
            {
                TestContext.Progress.WriteLine("\nDeletedBlock entries:");
                while (reader.Read())
                {
                    var id = reader.GetInt64(0);
                    var hash = reader.GetString(1);
                    var size = reader.GetInt64(2);
                    var volId = reader.GetInt64(3);
                    var state = reader.IsDBNull(4) ? "NULL (orphaned)" : reader.GetString(4);
                    TestContext.Progress.WriteLine($"  ID={id}, Hash={hash[..Math.Min(16, hash.Length)]}..., Size={size}, VolumeID={volId}, VolumeState={state}");
                }
            }

            // These assertions will fail if the bug exists
            Assert.AreEqual(0, tempCount, 
                "DeletedBlock table should not contain entries referencing Temporary volumes. " +
                $"Found {tempCount} invalid entries referencing Temporary volumes.");

            Assert.AreEqual(0, orphanedCount,
                "DeletedBlock table should not contain entries with orphaned VolumeID references. " +
                $"Found {orphanedCount} orphaned entries.");
        }
    }
}
