using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;

namespace AutoIngest.Engine
{
    /// <summary>
    /// Phone / portable-device source. Detects MTP/PTP devices (iPhones, Android phones, and
    /// modern cameras that don't expose a drive letter) via the Windows Shell namespace — the
    /// same "This PC" tree Explorer shows portable devices under. This is the approach flagged
    /// in BLOG.md as the planned replacement for the Python version's MTP support.
    ///
    /// Shell COM is happiest on an STA thread, so enumeration is marshaled onto a dedicated
    /// STA worker. Every COM touch is wrapped: a locked/untrusted phone, a dead battery, or a
    /// flaky driver must never crash the monitor — failures degrade to "no devices seen this
    /// tick" and a log line. Phones are strictly best-effort; the SD/USB path is unaffected.
    ///
    /// We use late binding (`dynamic` against `Shell.Application`) to avoid taking on an Interop
    /// assembly for a single COM object — the project's zero-extra-dependency posture is preserved.
    /// </summary>
    public class MtpDeviceSource : IDeviceSource
    {
        const int SSF_DRIVES = 0x11; // ssfDRIVES — the "This PC" / "My Computer" namespace

        // Bounded operations (MISRA spirit: no magic numbers). Timeouts are generous because MTP
        // drivers are slow; the recursion/poll bounds keep a pathological tree from running away.
        const int MAX_MTP_DEPTH = 3;             // how deep into the DCIM tree to walk for photos
        const int STAGE_POLL_ATTEMPTS = 60;      // polls for a CopyHere-landed file to appear
        const int STAGE_POLL_DELAY_MS = 100;     // delay between those polls (6s total budget)
        const int COPYHERE_NO_UI_FLAG = 16;      // FOF_NOCONFIRMATION | FOF_NOERRORUI semantics

        // Live flag accessor: phones delete their originals after verified import when this is on.
        // Read per-import (not snapshotted) so the tray toggle takes effect on the next device.
        readonly Func<bool> _shouldDeleteFromPhone;

        public MtpDeviceSource(Func<bool>? shouldDeleteFromPhone = null)
        {
            _shouldDeleteFromPhone = shouldDeleteFromPhone ?? (() => false);
        }

        public string SourceName => "Phone (MTP)";

        public IEnumerable<DeviceIdentity> GetConnectedDevices()
        {
            List<DeviceIdentity> results = new();
            try
            {
                // Marshal onto an STA thread so Shell.Application behaves. Run synchronously:
                // GetConnectedDevices is itself polled on a background thread, and a 4s tick is
                // far longer than enumeration takes.
                Exception? error = null;
                var sta = new Thread(() =>
                {
                    try { results = EnumerateOnSta(); }
                    catch (Exception ex) { error = ex; }
                })
                { IsBackground = true };
                sta.SetApartmentState(ApartmentState.STA);
                sta.Start();
                sta.Join(TimeSpan.FromSeconds(8));

                if (error != null)
                {
                    // Logged by the coordinator via the normal LogMessage channel; here we just
                    // fall through with whatever (possibly partial) results we got.
                    Debug.WriteLine($"MTP enumeration failed: {error.Message}");
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine($"MTP source thread error: {ex.Message}");
            }
            return results;
        }

        /// <summary>Walks "This PC" for items that look like portable devices (phones/cameras).</summary>
        List<DeviceIdentity> EnumerateOnSta()
        {
            var results = new List<DeviceIdentity>();
            Type? shellType = Type.GetTypeFromProgID("Shell.Application");
            if (shellType == null) return results;

            dynamic shell = Activator.CreateInstance(shellType)!;
            try
            {
                dynamic thisPc = shell.NameSpace(SSF_DRIVES);
                if (thisPc == null) return results;

                // Items() on "This PC" returns drives (C:\, D:\) AND portable devices (phones).
                // Portable devices have a Path of the form "::{guid}" or a name with no drive
                // letter and are flagged as folders. Filter out anything with a colon (drive
                // letter) — those belong to the mass-storage source.
                foreach (var item in thisPc.Items())
                {
                    DeviceIdentity? identity = TryIdentifyPortableDevice(item);
                    if (identity != null) results.Add(identity);
                }
            }
            finally
            {
                // Release the COM objects. Late-bound dynamics hold RCWs; Marshal.ReleaseComObject
                // ensures we don't keep the shell alive between polls.
                try { if (shell != null) System.Runtime.InteropServices.Marshal.ReleaseComObject(shell); }
                catch { }
            }
            return results;
        }

        DeviceIdentity? TryIdentifyPortableDevice(dynamic item)
        {
            try
            {
                // Portable-device entries are folders whose path is a "::"-prefixed PIDL string
                // (not a drive letter like "C:\"). Skip anything that looks like a drive.
                string? path = SafeString(() => item.Path);
                string? name = SafeString(() => item.Name);

                if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name)) return null;
                if (path.Length >= 2 && path[1] == ':') return null;           // "X:\" — a drive
                if (!SafeBool(() => item.IsFolder)) return null;

                // "Internal Storage", "Card" etc. are sub-items; we only want the device root,
                // which carries the friendly name (e.g. "Pixel 7", "iPhone").
                if (IsLikelyStorageSubfolder(name)) return null;

                // Open the device namespace to read its serial and to use as the photo provider.
                dynamic? deviceFolder;
                try { deviceFolder = item.GetFolder; }
                catch { return null; }
                if (deviceFolder == null) return null;

                string serial = ReadDeviceSerial(deviceFolder, name);

                // Build a stable id. Prefer the PTP/MTP serial; fall back to the device name if
                // the driver doesn't expose one (rare; flagged so the UI can warn).
                string id = !string.IsNullOrEmpty(serial)
                    ? $"MTP:{serial}:{name.ToUpperInvariant()}"
                    : $"WEAK:MTP:{name.ToUpperInvariant()}:{TryGetFreeSpace(deviceFolder)}";

                string kind = !string.IsNullOrEmpty(serial) ? "Phone" : "Phone-weak";

                return new DeviceIdentity
                {
                    Id = id,
                    DisplayName = name,
                    Kind = kind,
                    Photos = new MtpPhotoProvider(deviceFolder, name, _shouldDeleteFromPhone)
                };
            }
            catch (Exception ex)
            {
                Debug.WriteLine($"MTP device identify failed: {ex.Message}");
                return null;
            }
        }

        /// <summary>
        /// Reads a serial number / unique id for the device. MTP devices expose properties via
        /// ExtendedProperty; the canonical key for the device serial is the PTP DeviceSerialNumber,
        /// which on Windows surfaces under the property index. We try a small set of known keys
        /// and accept the first non-empty value.
        /// </summary>
        static string ReadDeviceSerial(dynamic deviceFolder, string deviceName)
        {
            // ExtendedProperty keys vary by driver. Try the common ones for device identity.
            string[] keys =
            {
                "System.Devices.Aep.DeviceAddress",
                "System.Devices.DeviceInstanceId",
                "System.ItemNameDisplay"
            };
            foreach (var key in keys)
            {
                try
                {
                    object? value = deviceFolder.ExtendedProperty(key);
                    string? s = value?.ToString();
                    if (!string.IsNullOrWhiteSpace(s) && !string.Equals(s, deviceName, StringComparison.OrdinalIgnoreCase))
                        return s!;
                }
                catch { /* next key */ }
            }

            // No property worked — synthesize a pseudo-serial from the device's self-path, which
            // contains the device interface GUID and is stable for the lifetime of the pairing.
            try
            {
                string? self = SafeString(() => deviceFolder.Self?.Path);
                if (!string.IsNullOrEmpty(self) && self.Length > 2 && self[1] != ':')
                    return self!;
            }
            catch { }
            return "";
        }

        static bool IsLikelyStorageSubfolder(string? name)
        {
            if (string.IsNullOrEmpty(name)) return false;
            // Sub-storage entries phones expose: "Internal Storage", "Phone", "Card", "SD card".
            string n = name.ToUpperInvariant();
            return n.Contains("STORAGE") || n == "PHONE" || n == "CARD" || n.Contains("SD CARD");
        }

        static long TryGetFreeSpace(dynamic deviceFolder)
        {
            try
            {
                // Size is unreliable over MTP and is only used as a weak-id fallback ingredient.
                var items = deviceFolder.Items();
                long total = 0;
                foreach (var it in items)
                {
                    try { total += SafeLong(() => it.Size); } catch { }
                }
                return total;
            }
            catch { return 0; }
        }

        static string? SafeString(Func<string?> f) { try { return f(); } catch { return null; } }
        static bool SafeBool(Func<bool> f) { try { return f(); } catch { return false; } }
        static long SafeLong(Func<long> f) { try { return f(); } catch { return 0; } }

        /// <summary>
        /// Photo provider for an MTP device. Enumerates image-bearing folders (DCIM and the
        /// like) via the shell namespace, stages each file to a temp path on disk (MTP objects
        /// aren't openable via File.OpenRead), and on removal invokes the shell "delete" verb to
        /// remove the source object from the phone.
        /// </summary>
        sealed class MtpPhotoProvider : IPhotoProvider, IDisposable
        {
            readonly object _deviceFolder;     // held as RCW; dynamic-dispatched on the STA thread
            readonly string _deviceName;
            readonly string _tempDir;
            readonly Func<bool> _shouldDeleteFromPhone;
            // Maps staged temp path -> phone-side shell path, so RemoveSource can resolve the
            // original to delete when the user has opted into delete-from-phone. Populated during
            // EnumeratePhotos and read during RemoveSource.
            readonly Dictionary<string, string> _stagedToSource = new(StringComparer.OrdinalIgnoreCase);
            int _stagedCounter; // disambiguates same-named originals into unique staged filenames

            public MtpPhotoProvider(dynamic deviceFolder, string deviceName, Func<bool> shouldDeleteFromPhone)
            {
                _deviceFolder = deviceFolder;
                _deviceName = deviceName;
                _shouldDeleteFromPhone = shouldDeleteFromPhone;
                _tempDir = Path.Combine(Path.GetTempPath(), "AutoIngest_Mtp", Guid.NewGuid().ToString("N"));
            }

            public List<string> EnumeratePhotos()
            {
                var staged = new List<string>();

                // Shell enumeration must happen on STA. Build and run the work synchronously.
                Exception? error = null;
                var sta = new Thread(() =>
                {
                    try { staged = EnumerateAndStageOnSta(); }
                    catch (Exception ex) { error = ex; }
                }) { IsBackground = true };
                sta.SetApartmentState(ApartmentState.STA);
                sta.Start();
                sta.Join(TimeSpan.FromMinutes(2));

                if (error != null)
                    Debug.WriteLine($"MTP enumerate failed for {_deviceName}: {error.Message}");

                return staged;
            }

            List<string> EnumerateAndStageOnSta()
            {
                var results = new List<string>();
                dynamic folder = _deviceFolder;
                Directory.CreateDirectory(_tempDir);

                // Photo folders phones expose. DCIM is the PTP standard; iOS also uses DCIM.
                string[] candidates = { "DCIM", "Pictures", "Internal Storage", "Card" };

                foreach (var sub in EnumerateItems(folder))
                {
                    string? name = SafeStringDyn(() => sub.Name);
                    if (string.IsNullOrEmpty(name)) continue;

                    bool photoFolder = candidates.Any(c =>
                        string.Equals(c, name, StringComparison.OrdinalIgnoreCase));
                    if (!photoFolder)
                    {
                        // Some phones nest a brand folder under DCIM and store photos directly in
                        // a sub-tree. Also accept any folder that itself contains images.
                        photoFolder = FolderContainsImages(sub);
                    }
                    if (!photoFolder) continue;

                    foreach (var photoPath in EnumerateImageFiles(sub, depth: 0))
                    {
                        string staged = StageFile(photoPath);
                        if (staged.Length > 0)
                        {
                            _stagedToSource[staged] = photoPath;
                            results.Add(staged);
                        }
                    }
                }
                return results;
            }

            // Enumerate image files up to 3 levels deep inside a shell folder, returning the
            // folder+file dynamic pairs as opaque paths the caller stages. We return the shell
            // "file" object path strings the caller can re-resolve.
            IEnumerable<string> EnumerateImageFiles(dynamic folder, int depth)
            {
                if (depth > MAX_MTP_DEPTH) yield break;
                System.Collections.IEnumerable items;
                try { items = folder.Items(); }
                catch { yield break; }

                foreach (var raw in items)
                {
                    dynamic item = raw; // late-bind so .IsFolder/.Name/.Path resolve via the COM RCW
                    bool isFolder = SafeBoolDyn(() => item.IsFolder);
                    string? name = SafeStringDyn(() => item.Name);
                    if (string.IsNullOrEmpty(name)) continue;

                    if (isFolder)
                    {
                        // Recurse (skip system/junk folder names).
                        string upper = name.ToUpperInvariant();
                        if (upper is "THM" or "MISC") continue;
                        dynamic? sub;
                        try { sub = item.GetFolder; } catch { continue; }
                        if (sub != null)
                            foreach (var p in EnumerateImageFiles(sub, depth + 1)) yield return p;
                    }
                    else
                    {
                        string ext = Path.GetExtension(name);
                        if (DeviceImporter.IsImageFile(ext))
                            yield return ((string?)SafeStringDyn(() => item.Path)) ?? name;
                    }
                }
            }

            bool FolderContainsImages(dynamic folder)
            {
                try
                {
                    foreach (var raw in folder.Items())
                    {
                        dynamic item = raw;
                        if (!SafeBoolDyn(() => item.IsFolder))
                        {
                            string? name = SafeStringDyn(() => item.Name);
                            if (!string.IsNullOrEmpty(name) &&
                                DeviceImporter.IsImageFile(Path.GetExtension(name)))
                                return true;
                        }
                    }
                }
                catch { }
                return false;
            }

            /// <summary>
            /// Copies a shell-referenced file to local temp and returns the staged path. MTP
            /// objects must be invoked via their verb ("copy"/"invoke") or read via the shell's
            /// NameSpace copy; the simplest reliable path is Folder.CopyHere of the item into a
            /// temp dir, which Windows handles as an MTP transfer.
            /// </summary>
            string StageFile(string shellPath)
            {
                try
                {
                    // Use a unique staged name so two same-named originals (e.g. IMG_0001.JPG in
                    // different DCIM subfolders — common on iPhones that roll over) don't collide
                    // and silently drop one. A counter prefix preserves sortability and the
                    // original extension for format detection downstream.
                    string baseName = Path.GetFileName(shellPath);
                    string stagedName = $"{_stagedCounter:D4}_{baseName}";
                    _stagedCounter++;
                    string dest = Path.Combine(_tempDir, stagedName);

                    // Re-resolve via Shell to copy. Using CopyHere from the device's parent folder
                    // is the canonical MTP download primitive.
                    Type? shellType = Type.GetTypeFromProgID("Shell.Application");
                    if (shellType == null) return "";

                    dynamic shell = Activator.CreateInstance(shellType)!;
                    try
                    {
                        // Namespace(shellPath) on a file path returns the containing folder.
                        dynamic? parent = shell.Namespace(Path.GetDirectoryName(shellPath));
                        if (parent == null) return "";
                        parent.CopyHere(shellPath, COPYHERE_NO_UI_FLAG);
                        // CopyHere lands the file under the ORIGINAL name in dest's directory; we
                        // then rename to the unique staged name. Poll for the original to arrive.
                        string landedUnderOrigName = Path.Combine(_tempDir, baseName);
                        for (int i = 0; i < STAGE_POLL_ATTEMPTS && !File.Exists(landedUnderOrigName); i++) Thread.Sleep(STAGE_POLL_DELAY_MS);
                        if (!File.Exists(landedUnderOrigName)) return "";
                        if (landedUnderOrigName != dest)
                        {
                            File.Move(landedUnderOrigName, dest);
                        }
                        return File.Exists(dest) ? dest : "";
                    }
                    finally
                    {
                        try { System.Runtime.InteropServices.Marshal.ReleaseComObject(shell); } catch { }
                    }
                }
                catch (Exception ex)
                {
                    Debug.WriteLine($"MTP stage failed for {shellPath}: {ex.Message}");
                    return "";
                }
            }

            static List<dynamic> EnumerateItems(dynamic folder)
            {
                var list = new List<dynamic>();
                try
                {
                    foreach (var item in folder.Items()) list.Add(item);
                }
                catch { }
                return list;
            }

            public bool RemoveSource(string path)
            {
                // Always clean up the staged temp copy regardless of the delete-from-phone flag.
                try { if (File.Exists(path)) File.Delete(path); }
                catch (UnauthorizedAccessException) { }
                catch (IOException) { }

                // Phone-side delete is opt-in (DeleteFromPhoneAfterImport). When off, the phone
                // original is kept by design — return false so the import log says "src kept"
                // rather than the misleading "removed".
                if (!_shouldDeleteFromPhone()) return false;
                if (!_stagedToSource.TryGetValue(path, out string? shellPath) || string.IsNullOrEmpty(shellPath))
                    return false;

                // Best-effort: shell delete across MTP drivers is inconsistent, so we verify the
                // object is actually gone afterward and report the true outcome.
                return DeletePhoneObject(shellPath);
            }

            /// <summary>
            /// Resolves the phone-side shell item for <paramref name="shellPath"/>, invokes its
            /// "delete" verb, and verifies the object is gone by re-resolving it. Returns true only
            /// if the post-delete re-parse no longer finds the item — so callers can log accurately
            /// rather than claiming success on a silent driver no-op. Runs on an STA thread.
            /// </summary>
            bool DeletePhoneObject(string shellPath)
            {
                Exception? error = null;
                bool deleted = false;
                var sta = new Thread(() =>
                {
                    Type? shellType = Type.GetTypeFromProgID("Shell.Application");
                    if (shellType == null) return;
                    dynamic shell = Activator.CreateInstance(shellType)!;
                    try
                    {
                        string? dir = Path.GetDirectoryName(shellPath);
                        string? file = Path.GetFileName(shellPath);
                        if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(file)) return;

                        dynamic? parent = shell.Namespace(dir);
                        if (parent == null) return;
                        dynamic? item = parent.ParseName(file);
                        if (item == null) return;

                        // Try the common delete verbs. Drivers/localizations differ; "delete" and
                        // "&Delete" cover most. InvokeVerb throws if the verb doesn't exist.
                        foreach (var verb in new[] { "delete", "&Delete", "Delete" })
                        {
                            try { item.InvokeVerb(verb); break; }
                            catch { /* next verb */ }
                        }

                        // Verify: re-parse the name. If it's gone, the delete worked. This is the
                        // only reliable signal across drivers — InvokeVerb's return is meaningless
                        // and a missing verb is a silent no-op.
                        try { deleted = parent.ParseName(file) == null; }
                        catch { deleted = false; }
                    }
                    catch (Exception ex) { error = ex; }
                    finally
                    {
                        try { System.Runtime.InteropServices.Marshal.ReleaseComObject(shell); } catch { }
                    }
                }) { IsBackground = true };
                sta.SetApartmentState(ApartmentState.STA);
                sta.Start();
                sta.Join(TimeSpan.FromSeconds(15));
                if (error != null)
                    Debug.WriteLine($"MTP delete failed for {shellPath} on {_deviceName}: {error.Message}");
                return deleted;
            }

            /// <summary>
            /// Recursively deletes the staging temp directory. Called by the coordinator after an
            /// import completes (via the provider's Dispose) so repeated phone imports don't leak
            /// GUID-named folders under %TEMP%\AutoIngest_Mtp\. Best-effort.
            /// </summary>
            public void Dispose()
            {
                try
                {
                    if (Directory.Exists(_tempDir))
                        Directory.Delete(_tempDir, recursive: true);
                }
                catch (UnauthorizedAccessException) { }
                catch (IOException) { }
            }

            static string? SafeStringDyn(Func<string?> f) { try { return f(); } catch { return null; } }
            static bool SafeBoolDyn(Func<bool> f) { try { return f(); } catch { return false; } }
        }
    }
}
