using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using AutoIngest.Core;

namespace AutoIngest.Engine
{
    /// <summary>
    /// The device-agnostic import pipeline, extracted from the old SDCardMonitor. Given any
    /// connected device (mass storage or MTP phone) it:
    ///   1. Enumerates photos via the device's <see cref="IPhotoProvider"/>.
    ///   2. Moves JPGs straight to ~/Pictures/&lt;today&gt;/, converts everything else via
    ///      Magick.NET, then removes the source via the provider.
    ///   3. Applies the same-day dupe rules (size then SHA-256) scoped to today's folder.
    ///
    /// The pipeline never touches the device directly — all enumeration and source removal go
    /// through <see cref="IPhotoProvider"/>, so mass storage (File.Move/File.Delete) and MTP
    /// (shell copy-to-temp / shell delete-object) share this exact code path.
    /// </summary>
    public class DeviceImporter
    {
        const int MAX_COLLISION_SUFFIX = 9999;
        const int MAX_COPY_COLLISION_RETRIES = 12; // re-resolve cap for CopyNoClobber races

        static readonly HashSet<string> SKIP_FOLDERS = new(StringComparer.OrdinalIgnoreCase) {
            "System Volume Information", "$RECYCLE.BIN",
            "Windows", "Program Files", "Program Files (x86)",
            "ProgramData", "AppData", "Recovery"
        };

        static readonly HashSet<string> SKIP_PREFIXES = new() { ".", "$", "~" };

        static readonly string[] DCIM_PROBES = { "DCIM", "Pictures", "photos", "PICTURES" };

        public static readonly HashSet<string> IMAGE_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) {
            ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp",
            ".raw", ".cr2", ".cr3", ".nef", ".arw", ".dng", ".orf", ".rw2",
            ".gpr", ".pef", ".srw", ".srf", ".sr2", ".raf",
            ".heic", ".heif",
            ".ico", ".ppm", ".pgm", ".pbm", ".svg"
        };

        public event Action<string, string>? DriveFound;          // (deviceId, label)
        public event Action<int, int, string>? ImportProgress;    // (current, total, filename)
        public event Action<string>? ImportError;                 // (message)
        public event Action<string, string>? LogMessage;          // (message, hexColor)

        readonly ImageConverter _converter;
        // Config is read live per Import() call so the tray Options toggles
        // (auto-orient, strip EXIF, JPG quality) take effect on the next import without a restart.
        readonly AppConfig _config;

        public DeviceImporter(ImageConverter converter, AppConfig config)
        {
            _converter = converter;
            _config = config;
        }

        /// <summary>
        /// Imports all photos from <paramref name="device"/> into ~/Pictures/&lt;today&gt;/.
        /// Returns counts; raises events as it goes. Safe to call on any device whose
        /// <see cref="IPhotoProvider"/> can enumerate.
        /// </summary>
        public (int moved, int converted, int total, string sizeStr, string destFolder)
            Import(DeviceIdentity device)
        {
            var photosBase = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);

            var photos = device.Photos.EnumeratePhotos();
            if (photos.Count == 0)
                return (0, 0, 0, "", "");

            string today = DateTime.Now.ToString("yyyy-MM-dd");
            string destFolder = Path.Combine(photosBase, today);
            Directory.CreateDirectory(destFolder);

            DriveFound?.Invoke(device.Id, $"{photos.Count} photos found on {device.DisplayName}");

            int moved = 0, converted = 0, skipped = 0;
            long totalBytes = 0;

            foreach (var (src, idx) in photos.Select((f, i) => (f, i)))
            {
                string filename = Path.GetFileName(src);
                ImportProgress?.Invoke(idx + 1, photos.Count, filename);

                try
                {
                    string ext = Path.GetExtension(src).ToLowerInvariant();

                    if (ext is ".jpg" or ".jpeg")
                    {
                        // Resolve the destination, comparing against a same-named file in
                        // TODAY'S folder only: same size + same hash => genuine dupe (skip);
                        // otherwise rename with a suffix and move.
                        string destPath = ResolveDestPath(destFolder, filename, src);
                        if (destPath == "")
                        {
                            skipped++;
                            Log($"Skipped (duplicate of file already imported today): {filename}", "#8b949e");
                            // Still reclaim any staged temp copy (MTP) so it doesn't leak.
                            device.Photos.RemoveSource(src);
                            continue;
                        }

                        long srcLen = new FileInfo(src).Length;
                        // The provider removes the source; for mass storage that's a move, for
                        // MTP it's copy-then-delete. Either way we copy first then ask the
                        // provider to reclaim the source so the verify-before-delete guarantee
                        // holds identically for both device kinds.
                        //
                        // Copy without overwrite: ResolveDestPath already guaranteed the path was
                        // free, so a collision here means another process (or a race) created it
                        // in the gap — re-resolve with a suffix rather than silently clobbering.
                        CopyNoClobber(src, destFolder, ref destPath);

                        if (!File.Exists(destPath) || new FileInfo(destPath).Length != srcLen)
                            throw new IOException("move verification failed (size mismatch)");

                        device.Photos.RemoveSource(src);

                        moved++;
                        totalBytes += srcLen;
                        Log(string.Equals(destPath, Path.Combine(destFolder, filename), StringComparison.OrdinalIgnoreCase)
                            ? $"Moved: {filename}"
                            : $"Moved: {filename} -> {Path.GetFileName(destPath)} (name in use today)",
                            "#3fb950");
                    }
                    else
                    {
                        // Convert to JPG, then delete the original from the device. The output
                        // name is resolved against today's folder (two RAWs may share a stem;
                        // only an identical converted output is a real dupe).
                        string destName = Path.GetFileNameWithoutExtension(src) + ".jpg";
                        string destPath = ResolveDestPath(destFolder, destName, src);
                        if (destPath == "")
                        {
                            skipped++;
                            Log($"Skipped (duplicate output already imported today): {filename}", "#8b949e");
                            device.Photos.RemoveSource(src);
                            continue;
                        }

                        long srcLen = new FileInfo(src).Length;
                        try
                        {
                            _converter.ConvertToJpg(src, destPath,
                                _config.JpgQuality, _config.AutoOrient, _config.StripExif,
                                _config.Branding);
                        }
                        catch
                        {
                            // Conversion failed mid-write: delete any partial output so it can't be
                            // mistaken for a successful prior import later, then rethrow.
                            TryDelete(destPath);
                            throw;
                        }

                        // Verify destination exists with non-zero size before removing the source.
                        long size = new FileInfo(destPath).Length;
                        if (size <= 0)
                        {
                            TryDelete(destPath);
                            throw new IOException("conversion produced an empty file");
                        }

                        // Stronger verify: re-decode the output. A partial/corrupt JPG that happens
                        // to be non-zero would otherwise pass the size check and — for phones with
                        // delete-from-phone on — lead to the original being removed with no good
                        // local copy. CanRead re-opens with Magick and confirms it's a valid image.
                        if (!_converter.CanRead(destPath))
                        {
                            TryDelete(destPath);
                            throw new IOException("conversion output failed to decode (corrupt result)");
                        }

                        bool removed = device.Photos.RemoveSource(src);
                        converted++;
                        totalBytes += size;
                        Log(removed
                            ? $"Converted: {filename} -> JPG ({size / 1024.0:F1} KB) [src {srcLen / 1024.0:F0} KB removed]"
                            : $"Converted: {filename} -> JPG ({size / 1024.0:F1} KB) [src kept: {srcLen / 1024.0:F0} KB]",
                            removed ? "#58a6ff" : "#d29922");
                    }
                }
                catch (Exception ex)
                {
                    Log($"{filename}: {ex.Message}", "#f85149");
                    ImportError?.Invoke($"{filename}: {ex.Message}");
                }
            }

            if (skipped > 0)
                Log($"Skipped {skipped} duplicate file(s) already imported today.", "#8b949e");

            string sizeStr = FileSize.Format(totalBytes);
            return (moved, converted, moved + converted, sizeStr, destFolder);
        }

        // ---- Mass-storage photo discovery (used by the SD/USB source) -----------------

        /// <summary>
        /// Probes a drive-letter root for photos: DCIM/Pictures first, then root files, then
        /// a bounded recursive scan. Skips system folders and hidden/prefixed names. Shared
        /// here so any future mass-storage-like source reuses it.
        /// </summary>
        public static List<string> FindPhotosOnRoot(string root)
        {
            var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            var dcimResults = DCIM_PROBES
                .Select(p => Path.Combine(root, p))
                .Where(Directory.Exists)
                .SelectMany(p => ScanFolder(p, 10))
                .Where(fp => seen.Add(fp))
                .ToList();
            if (dcimResults.Count > 0) return dcimResults;

            List<string> rootResults = new();
            try
            {
                rootResults = Directory.EnumerateFiles(root)
                    .Where(f => seen.Add(f) && IsImageFile(f))
                    .ToList();
            }
            catch (UnauthorizedAccessException) { }
            catch (IOException) { }
            if (rootResults.Count > 0) return rootResults;

            return ScanFolder(root, 3).Where(fp => seen.Add(fp)).ToList();
        }

        static List<string> ScanFolder(string folder, int maxDepth)
        {
            var results = new List<string>();
            try { ScanRecursive(folder, maxDepth, 0, results); }
            catch (UnauthorizedAccessException) { }
            catch (IOException) { }
            return results;
        }

        static void ScanRecursive(string dir, int maxDepth, int depth, List<string> results)
        {
            if (depth > maxDepth) return;

            string[] dirs; string[] files;
            try { dirs = Directory.GetDirectories(dir); files = Directory.GetFiles(dir); }
            catch (UnauthorizedAccessException) { return; }
            catch (IOException) { return; }

            dirs = dirs.Where(d =>
            {
                string name = Path.GetFileName(d);
                return !SKIP_FOLDERS.Contains(name) &&
                       !SKIP_PREFIXES.Any(p => name.StartsWith(p));
            }).ToArray();

            results.AddRange(files.Where(f =>
            {
                string name = Path.GetFileName(f);
                return !SKIP_PREFIXES.Any(p => name.StartsWith(p)) && IsImageFile(f);
            }));

            foreach (var d in dirs) ScanRecursive(d, maxDepth, depth + 1, results);
        }

        // ---- Destination resolution / dedupe (scoped to today's folder) --------------

        /// <summary>
        /// Resolves a destination path inside <paramref name="destFolder"/> for a source
        /// file that wants to be named <paramref name="desiredName"/>.
        ///
        /// Dupe rules (scoped to TODAY'S folder only — we are not a cross-day dedupe tool):
        ///   - No existing file with the desired name  -> use the desired name.
        ///   - Existing file, different size           -> not a dupe; rename with a suffix.
        ///   - Existing file, same size, same hash     -> genuine duplicate; return "" (skip).
        ///   - Existing file, same size, different hash-> not a dupe; rename with a suffix.
        ///
        /// Returns the resolved destination path, or "" if the source is a byte-for-byte
        /// duplicate of what's already there and should be skipped.
        /// </summary>
        static string ResolveDestPath(string destFolder, string desiredName, string sourceFile)
        {
            string desired = Path.Combine(destFolder, desiredName);
            if (!File.Exists(desired)) return desired;

            long srcLen = new FileInfo(sourceFile).Length;
            long dstLen = new FileInfo(desired).Length;

            if (srcLen != dstLen) return MakeUniquePath(destFolder, desiredName);
            if (FilesEqual(sourceFile, desired)) return "";
            return MakeUniquePath(destFolder, desiredName);
        }

        /// <summary>True if the two files are byte-for-byte identical (SHA256 match).</summary>
        static bool FilesEqual(string a, string b)
        {
            try
            {
                using var ha = SHA256.Create();
                using var hb = SHA256.Create();
                using var fa = File.OpenRead(a);
                using var fb = File.OpenRead(b);
                return ha.ComputeHash(fa).SequenceEqual(hb.ComputeHash(fb));
            }
            catch (UnauthorizedAccessException) { return false; }
            catch (IOException) { return false; }
        }

        /// <summary>
        /// Returns a non-colliding path in <paramref name="folder"/> by appending
        /// _1, _2, ... to the stem until a free name is found.
        /// </summary>
        static string MakeUniquePath(string folder, string desiredName)
        {
            string path = Path.Combine(folder, desiredName);
            if (!File.Exists(path)) return path;

            string stem = Path.GetFileNameWithoutExtension(desiredName);
            string ext = Path.GetExtension(desiredName);

            foreach (int n in Enumerable.Range(1, MAX_COLLISION_SUFFIX))
            {
                path = Path.Combine(folder, $"{stem}_{n}{ext}");
                if (!File.Exists(path)) return path;
            }
            return Path.Combine(folder, $"{stem}_{Guid.NewGuid():N}{ext}");
        }

        public static bool IsImageFile(string path) => IMAGE_EXTENSIONS.Contains(Path.GetExtension(path));

        /// <summary>
        /// Copies <paramref name="src"/> into <paramref name="destFolder"/> at
        /// <paramref name="destPath"/> WITHOUT overwriting. If the target was created by another
        /// process in the TOCTOU window between ResolveDestPath and now, re-resolves to a fresh
        /// unique name and updates <paramref name="destPath"/>. Never silently clobbers an
        /// existing file. Throws on non-collision IOExceptions (e.g. disk full).
        /// </summary>
        static void CopyNoClobber(string src, string destFolder, ref string destPath)
        {
            for (int attempt = 0; ; attempt++)
            {
                try
                {
                    File.Copy(src, destPath, overwrite: false);
                    return;
                }
                catch (IOException) when (attempt < MAX_COPY_COLLISION_RETRIES)
                {
                    // Collision (file exists) — re-resolve. Other IOExceptions also land here but
                    // the re-resolve is harmless and the loop bound prevents infinite spin; a real
                    // disk error will exhaust attempts and propagate.
                    destPath = MakeUniquePath(destFolder, Path.GetFileName(destPath));
                    if (destPath == "")
                        throw; // shouldn't happen, but bail if MakeUniquePath returns ""
                }
            }
        }

        /// <summary>Best-effort delete; swallows the usual filesystem failures. Used to clean up
        /// partial convert outputs without masking the real error.</summary>
        static void TryDelete(string path)
        {
            try { if (File.Exists(path)) File.Delete(path); }
            catch (UnauthorizedAccessException) { }
            catch (IOException) { }
        }

        void Log(string msg, string color) => LogMessage?.Invoke(msg, color);
    }
}
