using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Arrays;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Utility;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

namespace HSUI.Helpers
{
    public static class DutyListScenarioHelper
    {
        private const uint DefaultQuestIconId = 61801;

        public record DutyListEntry(ushort QuestId, byte Sequence, string QuestName, string ObjectiveText, uint IconId);
        public record ScenarioGuideEntry(string Label, string QuestName, uint IconId, ushort Level, uint QuestRowId);
        /// <summary>Duty Finder queue status when InDutyQueue.</summary>
        public record DutyFinderQueueInfo(
            string DutyName,
            string StatusText,
            byte TanksFound, byte TanksNeeded,
            byte HealersFound, byte HealersNeeded,
            byte DPSFound, byte DPSNeeded,
            TimeSpan TimeElapsed,
            string? AverageWaitText);

        /// <summary>In-duty information when inside a dungeon/trial/raid (BoundByDuty, not in queue).</summary>
        public record DutyInfo(
            string DutyName,
            string TimerText,
            IReadOnlyList<DutyObjectiveEntry> Objectives);

        /// <summary>Single duty objective line, e.g. "Bear witness to the first doom: 0/1".</summary>
        public record DutyObjectiveEntry(string Text, string Progress);

        /// <summary>Resolves the quest icon ID for duty list/journal display, preferring EventIconType over Quest.Icon.</summary>
        private static uint GetQuestIconId(Quest questData)
        {
            try
            {
                if (questData.EventIconType.RowId != 0)
                {
                    var eventIcon = questData.EventIconType.Value;
                    if (eventIcon.MapIconAvailable != 0)
                    {
                        uint offset = questData.IsRepeatable ? 2u : 1u;
                        return eventIcon.MapIconAvailable + offset;
                    }
                }
                    if (questData.Icon != 0)
                    return (uint)questData.Icon;
            }
            catch { /* ignore */ }
            return DefaultQuestIconId;
        }

        /// <summary>Gets quest level (LevelMax or ClassJobLevel) for "Lv. X ???" display.</summary>
        private static ushort GetQuestLevel(Quest questData)
        {
            if (questData.LevelMax != 0) return questData.LevelMax;
            return questData.ClassJobLevel.FirstOrDefault();
        }

        /// <summary>Returns active duty list entries (quests with objectives).</summary>
        public static unsafe List<DutyListEntry> GetDutyListEntries()
        {
            var result = new List<DutyListEntry>();
            try
            {
                var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
                if (questSheet == null) return result;

                var span = QuestManager.Instance()->NormalQuests;
                for (int i = 0; i < span.Length; i++)
                {
                    ref var q = ref span[i];
                    if (q.QuestId == 0) continue;

                    uint rowId = q.QuestId + 65536u;
                    if (!questSheet.HasRow(rowId)) continue;

                    var questData = questSheet.GetRow(rowId);

                    string questName = questData.Name.ExtractText();
                    if (string.IsNullOrWhiteSpace(questName)) questName = "???";
                    uint iconId = GetQuestIconId(questData);

                    // Current objective text from Sequence
                    string objectiveText = GetObjectiveText(questData, q.Sequence);

                    result.Add(new DutyListEntry(q.QuestId, q.Sequence, questName, objectiveText, iconId));
                }

                // If Lumina didn't give objective text, try the game's ToDoList string array (same source as the default Duty List UI).
                TryFillObjectivesFromToDoListStringArray(result);
                // Do not read from _ToDoList addon text nodes: addon memory can be invalid when Duty Finder is open or when queuing, causing AccessViolationException in Utf8String.ToString().

                // Levequests
                var leveSpan = QuestManager.Instance()->LeveQuests;
                var leveSheet = Plugin.DataManager.GetExcelSheet<Leve>();
                if (leveSheet != null)
                {
                    for (int i = 0; i < leveSpan.Length; i++)
                    {
                        ref var lq = ref leveSpan[i];
                        if (lq.LeveId == 0) continue;

                        var leveData = leveSheet.GetRow(lq.LeveId);

                        string leveName = leveData.Name.ExtractText();
                        if (string.IsNullOrWhiteSpace(leveName)) leveName = "Levequest";
                        uint leveIcon = 61801; // default leve/quest icon

                        result.Add(new DutyListEntry(0, 0, leveName, "", leveIcon));
                    }
                }
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] GetDutyListEntries: {ex.Message}");
            }
            return result;
        }

        /// <summary>Gets the current objective description for a quest step from Lumina TodoParams (field name may vary by version).</summary>
        private static string GetObjectiveText(Quest questData, byte sequence)
        {
            try
            {
                foreach (var todo in questData.TodoParams)
                {
                    if (todo.ToDoCompleteSeq != sequence) continue;
                    string? text = TryGetTodoTextViaReflection(todo);
                    if (!string.IsNullOrWhiteSpace(text)) return text;
                    break;
                }
            }
            catch { /* ignore */ }
            return "";
        }

        /// <summary>Tries to get objective text by reflecting on TodoParamsStruct for a property with ExtractText().</summary>
        private static string? TryGetTodoTextViaReflection(Quest.TodoParamsStruct todo)
        {
            var type = todo.GetType();
            foreach (var prop in type.GetProperties())
            {
                try
                {
                    var val = prop.GetValue(todo);
                    if (val == null) continue;
                    var vType = val.GetType();
                    var extractMethod = vType.GetMethod("ExtractText", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance, null, Type.EmptyTypes, null);
                    if (extractMethod != null)
                    {
                        var result = extractMethod.Invoke(val, null) as string;
                        if (!string.IsNullOrWhiteSpace(result)) return result;
                    }
                }
                catch { /* skip this property */ }
            }
            return null;
        }

        /// <summary>Returns true when Duty Finder (ContentsFinder) is open—avoid reading _ToDoList then (addon memory can be invalid). Party Finder is left allowed so duty list can still be read.</summary>
        private static bool IsDutyOrPartyFinderOpen()
        {
            try
            {
                if (Plugin.GameGui.GetAddonByName("ContentsFinder", 0).Address != nint.Zero) return true;
                if (Plugin.GameGui.GetAddonByName("ContentsFinder", 1).Address != nint.Zero) return true;
                if (Plugin.GameGui.GetAddonByName("ContentsFinderConfirm", 0).Address != nint.Zero) return true;
            }
            catch { /* ignore */ }
            return false;
        }

        /// <summary>Returns true when we should skip reading _ToDoList (Duty Finder open, in queue, or zone transition—addon memory can be invalid).</summary>
        private static unsafe bool ShouldSkipReadingDutyListAddon()
        {
            if (IsDutyOrPartyFinderOpen()) return true;
            try
            {
                if (Conditions.Instance() != null && Conditions.Instance()->InDutyQueue) return true;
                if (Plugin.Condition[ConditionFlag.BetweenAreas]) return true;
                if (Plugin.Condition[ConditionFlag.BetweenAreas51]) return true;
            }
            catch { /* ignore */ }
            return false;
        }

        /// <summary>Read objective text from the game's ToDoList string array (same data the default Duty List UI uses). Layout: titles at 0..QuestCount-1, details at QuestCount..2*QuestCount-1.</summary>
        private static unsafe void TryFillObjectivesFromToDoListStringArray(List<DutyListEntry> result)
        {
            if (result.Count == 0) return;
            try
            {
                var stringArray = ToDoListStringArray.Instance();
                var numberArray = ToDoListNumberArray.Instance();
                if (stringArray == null || numberArray == null) return;

                int questCount = numberArray->QuestCount;
                if (questCount <= 0 || questCount > 30) return;

                // _questTexts at offset 9*8, 60 CStringPointer (8 bytes each). Titles 0..questCount-1, details questCount..2*questCount-1.
                const int questTextsOffset = 9 * 8;
                byte* baseAddr = (byte*)stringArray;

                string ReadSlot(int index)
                {
                    if (index < 0 || index >= 60) return "";
                    byte* ptr = *(byte**)(baseAddr + questTextsOffset + index * 8);
                    if (ptr == null) return "";
                    try { return Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; }
                    catch { return ""; }
                }

                for (int i = 0; i < result.Count; i++)
                {
                    var entry = result[i];
                    if (!string.IsNullOrWhiteSpace(entry.ObjectiveText)) continue;

                    string obj = "";
                    if (i < questCount)
                        obj = ReadSlot(questCount + i).Trim();
                    if (string.IsNullOrWhiteSpace(obj))
                    {
                        int titleIdx = -1;
                        for (int j = 0; j < questCount; j++)
                        {
                            if (string.Equals(ReadSlot(j).Trim(), entry.QuestName, StringComparison.OrdinalIgnoreCase))
                            { titleIdx = j; break; }
                        }
                        if (titleIdx >= 0)
                            obj = ReadSlot(questCount + titleIdx).Trim();
                    }
                    if (string.IsNullOrWhiteSpace(obj)) continue;
                    result[i] = entry with { ObjectiveText = obj };
                }
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] TryFillObjectivesFromToDoListStringArray: {ex.Message}");
            }
        }

        /// <summary>When the game's Duty List addon (_ToDoList) is visible, read its text nodes to get objective strings. Tries ObjectiveTimerTextNodes first (objective lines), then tree-walk, DutyFinderTextNodes, and flat UldManager.</summary>
        private static unsafe void TryFillObjectivesFromToDoListAddon(List<DutyListEntry> result)
        {
            if (result.Count == 0) return;
            try
            {
                // Try multiple addon instances (HUD can create different indices)
                var addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 1);
            if (addonHandle.Address == nint.Zero) addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 0);
            if (addonHandle.Address == nint.Zero) addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 2);
            if (addonHandle.Address == nint.Zero)
            {
                LogDutyListAddonDebug(result.Count, found: false, ready: false, 0, 0, 0, null);
                return;
            }
            var addon = (AddonToDoList*)addonHandle.Address;
            if (addon == null || !addon->IsReady)
            {
                LogDutyListAddonDebug(result.Count, found: true, ready: addon != null && addon->IsReady, 0, 0, 0, null);
                return;
            }

            int nodeListCount = addon->UldManager.NodeListCount;
            int objTimerCount = addon->ObjectiveTimerTextNodes.Count;
            int textNodeCount = 0;
            int textNodeNonEmptyCount = 0;

            var strings = new List<string>();

            // 0) ObjectiveTimerTextNodes: game stores one text node per objective line; order matches duty list
            try
            {
                var objVec = addon->ObjectiveTimerTextNodes;
                if (objVec.Count > 0)
                {
                    var span = objVec.AsSpan();
                    for (int i = 0; i < span.Length; i++)
                    {
                        ref readonly var data = ref span[i];
                        if (data.Node == null) continue;
                        try
                        {
                            string s = data.Node->NodeText.ToString();
                            if (!string.IsNullOrWhiteSpace(s)) strings.Add(s.Trim());
                        }
                        catch { /* ignore */ }
                    }
                    if (strings.Count >= result.Count)
                    {
                        for (int i = 0; i < result.Count && i < strings.Count; i++)
                        {
                            if (string.IsNullOrWhiteSpace(result[i].ObjectiveText))
                                result[i] = result[i] with { ObjectiveText = strings[i] };
                        }
                        LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, strings.Count, strings);
                        return;
                    }
                    strings.Clear();
                }
            }
            catch { /* ignore */ }

            // 0b) ListPanel: duty list rows; each entry's node tree has text (typically name then objective per row)
            if (strings.Count < result.Count * 2)
            {
                try
                {
                    var listPanel = addon->ListPanel;
                    var entries = listPanel.Entries;
                    if (entries.Count > 0)
                    {
                        var rowStrings = new List<List<string>>();
                        var span = entries.AsSpan();
                        for (int r = 0; r < span.Length; r++)
                        {
                            var row = new List<string>();
                            if (span[r].Node != null)
                                CollectTextFromNode(span[r].Node, row);
                            rowStrings.Add(row);
                        }
                        var flat = new List<string>();
                        foreach (var row in rowStrings)
                        {
                            foreach (var s in row)
                                if (!string.IsNullOrWhiteSpace(s)) flat.Add(s.Trim());
                        }
                        if (flat.Count >= result.Count * 2)
                        {
                            for (int i = 0; i < result.Count; i++)
                            {
                                if (!string.IsNullOrWhiteSpace(result[i].ObjectiveText)) continue;
                                if ((i * 2 + 1) < flat.Count)
                                    result[i] = result[i] with { ObjectiveText = flat[i * 2 + 1] };
                            }
                            LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, flat.Count, flat);
                            return;
                        }
                        if (flat.Count >= result.Count)
                        {
                            for (int i = 0; i < result.Count && i < flat.Count; i++)
                            {
                                if (string.IsNullOrWhiteSpace(result[i].ObjectiveText))
                                    result[i] = result[i] with { ObjectiveText = flat[i] };
                            }
                            LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, flat.Count, flat);
                            return;
                        }
                        strings = flat;
                    }
                }
                catch { /* ignore */ }
            }

            // 1) Tree-walk (display order: typically name then objective per quest)
            if (addon->RootNode != null)
                CollectTextFromNode(addon->RootNode, strings);

            // 2) Try DutyFinderTextNodes if tree-walk gave too few
            if (strings.Count < 2)
            {
                try
                {
                    var vec = addon->DutyFinderTextNodes;
                    if (vec.First != null && vec.Count > 0)
                    {
                        var ptrs = (AtkTextNode**)vec.First;
                        for (int i = 0; i < vec.Count; i++)
                        {
                            var textNode = ptrs[i];
                            if (textNode == null) continue;
                            try
                            {
                                string s = textNode->NodeText.ToString();
                                if (!string.IsNullOrWhiteSpace(s)) strings.Add(s.Trim());
                            }
                            catch { /* ignore */ }
                        }
                    }
                }
                catch { /* ignore */ }
            }

            // 3) Fallback: flat UldManager node list
            if (strings.Count < 2)
            {
                strings.Clear();
                var uld = addon->UldManager;
                if (uld.NodeListCount > 0)
                {
                    for (uint i = 0; i < uld.NodeListCount; i++)
                    {
                        AtkResNode* node = uld.NodeList[i];
                        if (node == null || node->Type != NodeType.Text) continue;
                        textNodeCount++;
                        var textNode = node->GetAsAtkTextNode();
                        if (textNode == null) continue;
                        try
                        {
                            string s = textNode->NodeText.ToString();
                            if (!string.IsNullOrWhiteSpace(s))
                            {
                                textNodeNonEmptyCount++;
                                strings.Add(s.Trim());
                            }
                        }
                        catch { /* ignore */ }
                    }
                }
                if (strings.Count < 2 && addon->RootNode != null)
                    CollectTextFromNode(addon->RootNode, strings);
            }
            // Pair by index when layout is exactly [name0, obj0, name1, obj1, ...]
            bool pairedByIndex = strings.Count >= result.Count * 2;
            for (int i = 0; i < result.Count; i++)
            {
                var entry = result[i];
                if (!string.IsNullOrWhiteSpace(entry.ObjectiveText)) continue;
                string? obj = null;
                if (pairedByIndex && (i * 2 + 1) < strings.Count)
                    obj = strings[i * 2 + 1];
                if (string.IsNullOrWhiteSpace(obj))
                {
                    int idx = strings.IndexOf(entry.QuestName);
                    if (idx < 0)
                        idx = strings.FindIndex(s => s.Contains(entry.QuestName, StringComparison.OrdinalIgnoreCase));
                    if (idx >= 0 && idx + 1 < strings.Count)
                        obj = strings[idx + 1];
                }
                if (string.IsNullOrWhiteSpace(obj)) continue;
                result[i] = entry with { ObjectiveText = obj };
            }

            LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, strings.Count, strings, textNodeCount, textNodeNonEmptyCount);
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] TryFillObjectivesFromToDoListAddon: {ex.GetType().Name}: {ex.Message}");
            }
        }

        private static DateTime _lastDutyListDebugLog = DateTime.MinValue;
        private static void LogDutyListAddonDebug(int entryCount, bool found, bool ready, int nodeListCount, int objTimerCount, int stringCount, List<string>? stringsForSample, int textNodeCount = -1, int textNodeNonEmptyCount = -1)
        {
            if (entryCount == 0) return;
            var now = DateTime.UtcNow;
            if ((now - _lastDutyListDebugLog).TotalSeconds < 15) return;
            _lastDutyListDebugLog = now;
            string sample = stringsForSample != null && stringsForSample.Count > 0
                ? string.Join(" | ", stringsForSample.Take(2).Select(s => s.Length > 40 ? s.Substring(0, 40) + "…" : s))
                : "";
            string extra = (textNodeCount >= 0 || textNodeNonEmptyCount >= 0) ? $" textNodes={textNodeCount} nonEmpty={textNodeNonEmptyCount}" : "";
            Plugin.Logger.Verbose($"[DutyList] addon found={found} ready={ready} nodeListCount={nodeListCount} objTimerCount={objTimerCount} stringCount={stringCount}{extra} sample=[{sample}]");
        }

        private static unsafe void CollectTextFromNode(AtkResNode* node, List<string> outStrings)
        {
            if (node == null) return;
            if (node->Type == NodeType.Text)
            {
                var textNode = node->GetAsAtkTextNode();
                if (textNode != null)
                {
                    try
                    {
                        string s = textNode->NodeText.ToString();
                        if (!string.IsNullOrWhiteSpace(s)) outStrings.Add(s.Trim());
                    }
                    catch { /* ignore */ }
                }
            }
            else if (node->Type == NodeType.Component)
            {
                var compNode = node->GetAsAtkComponentNode();
                if (compNode != null && compNode->Component != null)
                {
                    var comp = compNode->Component;
                    var uld = &comp->UldManager;
                    if (uld->RootNode != null)
                        CollectTextFromNode(uld->RootNode, outStrings);
                    // Also walk flat NodeList in case text nodes aren't under RootNode
                    if (uld->NodeListCount > 0 && uld->NodeList != null)
                    {
                        for (ushort i = 0; i < uld->NodeListCount; i++)
                        {
                            AtkResNode* n = uld->NodeList[i];
                            if (n != null && n->Type == NodeType.Text)
                            {
                                var tn = n->GetAsAtkTextNode();
                                if (tn != null)
                                {
                                    try
                                    {
                                        string s = tn->NodeText.ToString();
                                        if (!string.IsNullOrWhiteSpace(s)) outStrings.Add(s.Trim());
                                    }
                                    catch { /* ignore */ }
                                }
                            }
                        }
                    }
                }
            }
            for (var child = node->ChildNode; child != null; child = child->NextSiblingNode)
                CollectTextFromNode(child, outStrings);
        }

        /// <summary>Opens the Quest Journal to the given quest. Uses raw quest ID (16-bit) as expected by the game.</summary>
        public static unsafe void OpenJournalForQuest(ushort questId)
        {
            try
            {
                AgentQuestJournal.Instance()->OpenForQuest((uint)questId, 1);
            }
            catch { /* ignore */ }
        }

        /// <summary>Gets territory ID for a map (for SetFlagMapMarker). Returns 0 if not found.</summary>
        private static uint GetTerritoryIdForMap(uint mapId)
        {
            try
            {
                var mapSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.Map>();
                if (mapSheet == null || !mapSheet.HasRow(mapId)) return 0;
                var mapRow = mapSheet.GetRow(mapId);
                if (mapRow.TerritoryType.RowId == 0) return 0;
                return mapRow.TerritoryType.RowId;
            }
            catch { return 0; }
        }

        /// <summary>Opens the Area Map centered on the quest's current objective location. Sets a flag marker when coordinates are available so the map centers on the objective.</summary>
        public static unsafe void OpenMapForQuestObjective(ushort questId, byte sequence)
        {
            try
            {
                var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
                if (questSheet == null) return;
                uint rowId = questId + 65536u;
                if (!questSheet.HasRow(rowId)) return;
                var questData = questSheet.GetRow(rowId);

                var todoParam = questData.TodoParams.FirstOrDefault(p => p.ToDoCompleteSeq == sequence);
                var location = todoParam.ToDoLocation.FirstOrDefault(loc => loc is not { RowId: 0, ValueNullable: null });
                if (location.ValueNullable == null) return;

                uint mapId = location.Value.Map.RowId;
                if (mapId == 0) return;
                uint territoryId = GetTerritoryIdForMap(mapId);
                if (territoryId == 0) return;

                var agent = AgentMap.Instance();
                (float x, float y)? coords = TryGetMapCoordinates(location.Value);
                if (coords.HasValue)
                {
                    agent->SetFlagMapMarker(territoryId, mapId, coords.Value.x, coords.Value.y);
                    agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.FlagMarker);
                }
                else
                {
                    agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.Centered);
                }
            }
            catch { /* ignore */ }
        }

        /// <summary>Tries to get map X,Y from a Level/location row (Lumina column names may vary).</summary>
        private static (float x, float y)? TryGetMapCoordinates(object? locationRow)
        {
            if (locationRow == null) return null;
            try
            {
                var type = locationRow.GetType();
                var xProp = type.GetProperty("X") ?? type.GetProperty("MapX");
                var yProp = type.GetProperty("Y") ?? type.GetProperty("MapY");
                if (xProp == null || yProp == null) return null;
                var xVal = xProp.GetValue(locationRow);
                var yVal = yProp.GetValue(locationRow);
                if (xVal == null || yVal == null) return null;
                float x = Convert.ToSingle(xVal);
                float y = Convert.ToSingle(yVal);
                return (x, y);
            }
            catch { return null; }
        }

        /// <summary>Opens the Area Map to the quest's current objective (for scenario guide hints). Prefers current objective so MSQ and Job hints each open the correct map on first click; falls back to issuer location if quest is not in the duty list.</summary>
        public static unsafe void OpenMapForQuestIssuer(uint questRowId)
        {
            try
            {
                var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
                if (questSheet == null || !questSheet.HasRow(questRowId)) return;
                ushort questId = (ushort)(questRowId & 0xFFFF);

                // Prefer current objective so clicking Job hint opens Job's map (not MSQ's) on first click
                var span = QuestManager.Instance()->NormalQuests;
                for (int i = 0; i < span.Length; i++)
                {
                    if (span[i].QuestId != questId) continue;
                    OpenMapForQuestObjective(questId, span[i].Sequence);
                    return;
                }

                // Fallback: open to issuer location when quest is not in the duty list yet
                var questData = questSheet.GetRow(questRowId);
                if (questData.IssuerLocation.IsValid && questData.IssuerLocation.RowId != 0)
                {
                    var loc = questData.IssuerLocation.Value;
                    uint mapId = loc.Map.RowId;
                    if (mapId != 0)
                    {
                        uint territoryId = GetTerritoryIdForMap(mapId);
                        if (territoryId != 0)
                        {
                            var agent = AgentMap.Instance();
                            (float x, float y)? coords = TryGetMapCoordinates(loc);
                            if (coords.HasValue)
                            {
                                agent->SetFlagMapMarker(territoryId, mapId, coords.Value.x, coords.Value.y);
                                agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.FlagMarker);
                            }
                            else
                            {
                                agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.Centered);
                            }
                        }
                    }
                }
            }
            catch { /* ignore */ }
        }

        /// <summary>Returns scenario guide entries (MSQ/Job path hints). Uses AgentScenarioTree when populated; otherwise derives next MSQ/Job quest from game data.</summary>
        public static unsafe List<ScenarioGuideEntry> GetScenarioGuideEntries()
        {
            var result = new List<ScenarioGuideEntry>();
            try
            {
                var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
                if (questSheet == null) return result;

                // Try agent first
                var agent = AgentScenarioTree.Instance();
                if (agent != null && agent->Data != null)
                {
                    var data = agent->Data;
                    bool hasMsqFromAgent = false;
                    bool hasJobFromAgent = false;

                    // Main Scenario Quest - use MainScenarioQuestIds[MSQPathIndex], fallback to [0], then [3] (last completed)
                    byte msqPathIndex = data->MSQPathIndex;
                    ushort msqQuestId = msqPathIndex < 3 ? data->MainScenarioQuestIds[msqPathIndex] : data->MainScenarioQuestIds[0];
                    if (msqQuestId == 0) msqQuestId = data->MainScenarioQuestIds[0];
                    if (msqQuestId == 0) msqQuestId = data->MainScenarioQuestIds[3];
                    if (msqQuestId != 0)
                    {
                        uint rowId = msqQuestId + 65536u;
                        if (questSheet.HasRow(rowId))
                        {
                            var questData = questSheet.GetRow(rowId);
                            string questName = questData.Name.ExtractText();
                            if (string.IsNullOrWhiteSpace(questName)) questName = "???";
                            uint iconId = GetQuestIconId(questData);
                            ushort level = GetQuestLevel(questData);
                            result.Add(new ScenarioGuideEntry("Main Scenario", questName, iconId, level, rowId));
                            hasMsqFromAgent = true;
                        }
                    }

                    // Job quest(s) - use JobQuestIndex so we show the one the game considers selected (avoids wrong map when both slots exist)
                    byte jobIdx = data->JobQuestIndex;
                    if (jobIdx <= 1)
                    {
                        ushort questId = data->JobQuestIds[jobIdx];
                        if (questId != 0)
                        {
                            uint rowId = questId + 65536u;
                            if (questSheet.HasRow(rowId))
                            {
                                var questData = questSheet.GetRow(rowId);
                                string questName = questData.Name.ExtractText();
                                if (string.IsNullOrWhiteSpace(questName)) questName = "???";
                                uint iconId = GetQuestIconId(questData);
                                ushort level = GetQuestLevel(questData);
                                result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, rowId));
                                hasJobFromAgent = true;
                            }
                        }
                    }
                    if (!hasJobFromAgent)
                    {
                        for (int i = 0; i < 2; i++)
                        {
                            ushort questId = data->JobQuestIds[i];
                            if (questId == 0) continue;
                            uint rowId = questId + 65536u;
                            if (!questSheet.HasRow(rowId)) continue;
                            var questData = questSheet.GetRow(rowId);
                            string questName = questData.Name.ExtractText();
                            if (string.IsNullOrWhiteSpace(questName)) questName = "???";
                            uint iconId = GetQuestIconId(questData);
                            ushort level = GetQuestLevel(questData);
                            result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, rowId));
                            hasJobFromAgent = true;
                            break;
                        }
                    }

                    if (hasMsqFromAgent && hasJobFromAgent)
                        return result;
                }

                // Fallback: derive next MSQ and Job quest from game data when agent has no hint
                TryAddNextMsqFromScenarioTree(result, questSheet);
                TryAddNextJobQuestFromQuests(result, questSheet);
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] GetScenarioGuideEntries: {ex.Message}");
            }
            return result;
        }

        private static unsafe void TryAddNextMsqFromScenarioTree(List<ScenarioGuideEntry> result, ExcelSheet<Quest> questSheet)
        {
            if (result.Any(e => e.Label == "Main Scenario")) return;
            try
            {
                var scenarioSheet = Plugin.DataManager.GetExcelSheet<ScenarioTree>();
                if (scenarioSheet == null) return;

                var ordered = new List<(ScenarioTree St, Quest Q)>();
                foreach (var st in scenarioSheet)
                {
                    if (!questSheet.TryGetRow(st.RowId, out var quest) || !quest.Expansion.IsValid)
                        continue;
                    ordered.Add((st, quest));
                }
                ordered = ordered.OrderBy(x => x.St.Unknown2).ToList();

                foreach (var (st, quest) in ordered)
                {
                    if (QuestManager.IsQuestComplete(st.RowId)) continue;
                    string questName = quest.Name.ExtractText();
                    if (string.IsNullOrWhiteSpace(questName)) questName = "???";
                    uint iconId = GetQuestIconId(quest);
                    ushort level = GetQuestLevel(quest);
                    result.Insert(0, new ScenarioGuideEntry("Main Scenario", questName, iconId, level, quest.RowId));
                    return;
                }
            }
            catch { /* ignore */ }
        }

        private static unsafe void TryAddNextJobQuestFromQuests(List<ScenarioGuideEntry> result, ExcelSheet<Quest> questSheet)
        {
            if (result.Any(e => e.Label == "Job Quest")) return;
            var player = Plugin.ObjectTable?.LocalPlayer;
            if (player == null) return;
            uint classJobId = player.ClassJob.RowId;
            try
            {
                foreach (var quest in questSheet)
                {
                    if (quest.ClassJobCategory0.RowId == 0) continue;
                    if (quest.ClassJobCategory0.RowId != classJobId) continue;
                    if (!quest.JournalGenre.IsValid) continue;
                    if (QuestManager.IsQuestComplete(quest.RowId)) continue;
                    ushort reqLevel = quest.ClassJobLevel.FirstOrDefault();
                    if (reqLevel == 0) continue;
                    if (player.Level < reqLevel) continue;

                    string questName = quest.Name.ExtractText();
                    if (string.IsNullOrWhiteSpace(questName)) questName = "???";
                    uint iconId = GetQuestIconId(quest);
                    ushort level = GetQuestLevel(quest);
                    result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, quest.RowId));
                    return;
                }
            }
            catch { /* ignore */ }
        }

        /// <summary>Returns current Duty Finder queue info when the player is in the duty queue; otherwise null. Uses the actual queued duties (from QueueInfo) and supports multiple selected duties.</summary>
        public static unsafe DutyFinderQueueInfo? GetDutyFinderQueueInfo()
        {
            try
            {
                if (Conditions.Instance() == null || !Conditions.Instance()->InDutyQueue)
                    return null;
                var finder = ContentsFinder.Instance();
                if (finder == null) return null;
                ref var queueInfo = ref finder->QueueInfo;

                // Build duty name(s) from all queued entries (up to 5). Each entry is 8 bytes: ContentType at 0, Id at 4.
                var dutyNames = new List<string>();
                var queueInfoPtr = (byte*)Unsafe.AsPointer(ref queueInfo);
                for (int i = 0; i < 5; i++)
                {
                    byte contentType = queueInfoPtr[i * 8 + 0];
                    uint id = *(uint*)(queueInfoPtr + i * 8 + 4);
                    if (contentType == 0) continue; // None / empty slot
                    if (contentType == (byte)ContentsId.ContentsType.Regular && id != 0)
                    {
                        var cfcSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ContentFinderCondition>();
                        if (cfcSheet != null && cfcSheet.HasRow(id))
                        {
                            var row = cfcSheet.GetRow(id);
                            string name = row.Name.ExtractText();
                            if (!string.IsNullOrWhiteSpace(name)) dutyNames.Add(name);
                        }
                    }
                    else if (contentType == (byte)ContentsId.ContentsType.Roulette)
                    {
                        byte rouletteId = (byte)(id & 0xFF);
                        var rouletteSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ContentRoulette>();
                        if (rouletteSheet != null && rouletteSheet.HasRow(rouletteId))
                        {
                            var row = rouletteSheet.GetRow(rouletteId);
                            string name = row.Name.ExtractText();
                            dutyNames.Add(string.IsNullOrWhiteSpace(name) ? "Duty Roulette" : name);
                        }
                        else
                            dutyNames.Add("Duty Roulette");
                    }
                }
                string dutyName = dutyNames.Count == 0 ? "Duty Finder" : (dutyNames.Count == 1 ? dutyNames[0] : string.Join(", ", dutyNames));

                string statusText = queueInfo.QueueState switch
                {
                    ContentsFinderQueueInfo.QueueStates.Pending => "Pending",
                    ContentsFinderQueueInfo.QueueStates.Queued => "Forming Party",
                    ContentsFinderQueueInfo.QueueStates.Ready => "Duty Ready",
                    ContentsFinderQueueInfo.QueueStates.Accepted => "Accepted",
                    _ => "In Queue"
                };
                var infoState = queueInfo.InfoState;
                var elapsed = queueInfo.EnteredQueueTimestamp != 0
                    ? TimeSpan.FromSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - queueInfo.EnteredQueueTimestamp)
                    : TimeSpan.Zero;
                string? avgWait = null;
                if (infoState.AverageWaitTime)
                {
                    avgWait = TryGetAverageWaitFromQueueStatusMessages();
                    if (string.IsNullOrEmpty(avgWait)) avgWait = "—";
                }
                return new DutyFinderQueueInfo(
                    dutyName,
                    statusText,
                    infoState.TanksFound, infoState.TanksNeeded,
                    infoState.HealersFound, infoState.HealersNeeded,
                    infoState.DPSFound, infoState.DPSNeeded,
                    elapsed,
                    avgWait);
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] GetDutyFinderQueueInfo: {ex.Message}");
                return null;
            }
        }

        /// <summary>Returns current duty information when inside a dungeon/trial/raid (BoundByDuty and not in queue); otherwise null.</summary>
        public static unsafe DutyInfo? GetDutyInfo()
        {
            try
            {
                if (!Plugin.Condition[ConditionFlag.BoundByDuty]) return null;
                if (Conditions.Instance() != null && Conditions.Instance()->InDutyQueue) return null;

                var stringArray = ToDoListStringArray.Instance();
                var numberArray = ToDoListNumberArray.Instance();
                if (stringArray == null || numberArray == null) return null;

                int dutyState = numberArray->DutyState;
                // DutyState 2 = in duty, 3 = in duty with single text line (per ToDoListNumberArray).
                if (dutyState != 2 && dutyState != 3) return null;

                byte* strBase = (byte*)stringArray;
                string ReadStr(int offset)
                {
                    byte* ptr = *(byte**)(strBase + offset);
                    if (ptr == null) return "";
                    try { return Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; }
                    catch { return ""; }
                }

                string dutyName = ReadStr(159 * 8).Trim(); // ActiveDutyTitle
                string timerText = ReadStr(162 * 8).Trim(); // DutyTimer

                int objectiveCount = numberArray->DutyObjectiveCount;
                if (objectiveCount <= 0 || objectiveCount > 10) objectiveCount = 0;

                var objectives = new List<DutyObjectiveEntry>();
                const int dutyObjectivesOffset = 165 * 8;
                const int dutyTimersOffset = 175 * 8;
                byte* numBase = (byte*)numberArray;
                for (int i = 0; i < objectiveCount; i++)
                {
                    string text = ReadStr(dutyObjectivesOffset + i * 8).Trim();
                    string progress = ReadStr(dutyTimersOffset + i * 8).Trim();
                    if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(progress)) continue;
                    int value = *(int*)(numBase + (147 + i) * 4); // _dutyObjectiveValue
                    if (string.IsNullOrWhiteSpace(progress) && value >= 0)
                        progress = value.ToString();
                    else if (value < 0 && string.IsNullOrWhiteSpace(progress))
                        progress = "?";
                    string displayProgress = FormatDutyObjectiveProgress(progress);
                    objectives.Add(new DutyObjectiveEntry(text, displayProgress));
                }

                if (string.IsNullOrWhiteSpace(dutyName) && objectives.Count == 0 && string.IsNullOrWhiteSpace(timerText))
                    return null;

                return new DutyInfo(
                    string.IsNullOrWhiteSpace(dutyName) ? "Duty" : dutyName,
                    timerText,
                    objectives);
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] GetDutyInfo: {ex.Message}");
                return null;
            }
        }

        /// <summary>Read average wait time from ToDoList string array queue status messages (slots 5-8). Prefers static format (e.g. "7m") over timer format (e.g. "7:01").</summary>
        private static unsafe string? TryGetAverageWaitFromQueueStatusMessages()
        {
            try
            {
                var stringArray = ToDoListStringArray.Instance();
                if (stringArray == null) return null;
                byte* baseAddr = (byte*)stringArray;
                const int queueStatusOffset = 5 * 8; // _queueStatusMessages
                string? fallback = null; // use if we only find timer-style values
                for (int i = 0; i < 4; i++)
                {
                    byte* ptr = *(byte**)(baseAddr + queueStatusOffset + i * 8);
                    if (ptr == null) continue;
                    string s;
                    try { s = Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; }
                    catch { continue; }
                    if (string.IsNullOrWhiteSpace(s)) continue;
                    s = s.Trim();
                    // Game can show "Average Wait Time: 7:01/Average Wait Time: 7m" (timer then static) or vice versa.
                    if (s.IndexOf("Average", StringComparison.OrdinalIgnoreCase) < 0 || s.IndexOf("Wait", StringComparison.OrdinalIgnoreCase) < 0)
                    {
                        if ((s.Contains("m") || s.Contains("s")) && s.Length > 0 && (char.IsDigit(s[0]) || s.StartsWith("Less", StringComparison.OrdinalIgnoreCase)))
                            return s;
                        continue;
                    }
                    const string label = "Average Wait Time:";
                    int start = 0;
                    while (start < s.Length)
                    {
                        int idx = s.IndexOf(label, start, StringComparison.OrdinalIgnoreCase);
                        if (idx < 0) break;
                        int valueStart = idx + label.Length;
                        if (valueStart >= s.Length) break;
                        int valueEnd = s.Length;
                        int nextLabel = s.IndexOf(label, valueStart, StringComparison.OrdinalIgnoreCase);
                        if (nextLabel >= 0) valueEnd = nextLabel;
                        string value = s.Substring(valueStart, valueEnd - valueStart).Trim().TrimEnd('/', ' ');
                        if (!string.IsNullOrWhiteSpace(value))
                        {
                            if (IsStaticAverageWaitFormat(value))
                                return value;
                            fallback ??= value;
                        }
                        start = valueEnd;
                    }
                }
                return fallback;
            }
            catch (Exception ex)
            {
                Plugin.Logger.Verbose($"[DutyListScenario] TryGetAverageWaitFromQueueStatusMessages: {ex.Message}");
                return null;
            }
        }

        /// <summary>True if the value looks like a static estimate ("7m", "2m 30s", "Less than 5m") rather than a timer ("7:01").</summary>
        private static bool IsStaticAverageWaitFormat(string value)
        {
            if (string.IsNullOrWhiteSpace(value)) return false;
            // Timer format is minutes:seconds (e.g. "7:01"); static is "7m", "7m 30s", "Less than 5m".
            if (value.Contains(':')) return false;
            return value.Contains("m") || value.Contains("s") || value.StartsWith("Less", StringComparison.OrdinalIgnoreCase);
        }

        /// <summary>Format duty objective progress from game format (e.g. "0/1:0") to "X out of Y step(s)" with " ✓" when completed.</summary>
        private static string FormatDutyObjectiveProgress(string progress)
        {
            if (string.IsNullOrWhiteSpace(progress)) return "";
            string main = progress;
            bool completed = false;
            int colonIdx = progress.IndexOf(':');
            if (colonIdx >= 0)
            {
                main = progress.Substring(0, colonIdx).Trim();
                if (colonIdx + 1 < progress.Length)
                {
                    string flag = progress.Substring(colonIdx + 1).Trim();
                    completed = flag == "1";
                }
            }
            if (string.IsNullOrWhiteSpace(main)) return progress;
            int slashIdx = main.IndexOf('/');
            if (slashIdx < 0)
                return completed ? progress + " ✓" : progress;
            string currentStr = main.Substring(0, slashIdx).Trim();
            string totalStr = slashIdx + 1 < main.Length ? main.Substring(slashIdx + 1).Trim() : "";
            if (!int.TryParse(currentStr, out int current) || !int.TryParse(totalStr, out int total) || total <= 0)
                return completed ? progress + " ✓" : progress;
            string stepWord = total == 1 ? "step" : "steps";
            string result = $"{current} out of {total} {stepWord}";
            if (completed) result += " ✓";
            return result;
        }
    }
}
