using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AIRuntime.Editor;
using AIRuntime.Runtime.Providers.Fallback;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;

namespace AIRuntime.Tests.EditMode
{
    public sealed class KanbanHybridSchedulerTests
    {
        private const BindingFlags Flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
        private static readonly Type SessionType = typeof(AIRuntimeKanbanSession);
        private static readonly Type SchedulerType = SessionType.Assembly.GetType("AIRuntime.Editor.AIRuntimeKanbanScheduler");
        private AIRuntimeKanbanSession _session;
        private string _directory;
        private string _path;
        private readonly List<UnityEngine.Object> _objects = new List<UnityEngine.Object>();

        [SetUp]
        public void SetUp()
        {
            _directory = Path.Combine(Path.GetTempPath(), "AIRuntimeHybridTests", Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(_directory);
            _path = Path.Combine(_directory, "board.json");
            SessionType.GetField("BoardPathOverrideForTests", Flags).SetValue(null, _path);
            _session = ScriptableObject.CreateInstance<AIRuntimeKanbanSession>();
            _objects.Add(_session);
            Set(_session, "_autoSave", false);
            ((HashSet<string>)Get(_session, "_activeKanbanAgentIds")).Clear();
            ((HashSet<string>)Get(_session, "_activeKanbanAgentIds")).Add("Worker");
        }

        [TearDown]
        public void TearDown()
        {
            if (ReferenceEquals(SchedulerType.GetField("_session", Flags).GetValue(null), _session))
                SchedulerType.GetField("_session", Flags).SetValue(null, null);
            for (int i = _objects.Count - 1; i >= 0; i--)
                if (_objects[i] != null) UnityEngine.Object.DestroyImmediate(_objects[i]);
            _objects.Clear();
            SessionType.GetField("BoardPathOverrideForTests", Flags).SetValue(null, null);
            SessionState.SetBool("AIRuntime.Kanban.ResumeAgentsAfterReload", false);
            if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
        }

        private static object Get(object target, string field) => target.GetType().GetField(field, Flags).GetValue(target);
        private static void Set(object target, string field, object value) => target.GetType().GetField(field, Flags).SetValue(target, value);
        private static object Invoke(object target, string method, params object[] arguments) =>
            target.GetType().GetMethod(method, Flags).Invoke(target, arguments);
        private object Call(string method, params object[] arguments) => Invoke(_session, method, arguments);
        private IList Tasks => (IList)Get(Get(_session, "_board"), "Tasks");
        private JObject Saved => JObject.Parse(File.ReadAllText(_path));

        private object Card(string id, string status = "Todo", string phase = "Implementation")
        {
            object card = Activator.CreateInstance(SessionType.GetNestedType("KanbanTask", Flags));
            Set(card, "Id", id);
            Set(card, "Title", "Summarize " + id);
            Set(card, "Description", "Inspect and summarize the existing architecture.");
            Set(card, "AssignedAgent", "Worker");
            Set(card, "Status", Enum.Parse(Get(card, "Status").GetType(), status));
            Set(card, "WorkflowPhase", phase);
            Set(card, "CreatedAt", DateTimeOffset.UtcNow.ToString("O"));
            Set(card, "ExecutionMemory", "durable checkpoint");
            Tasks.Add(card);
            return card;
        }

        [TestCase(false)]
        [TestCase(true)]
        public void Hybrid_UnsupportedImagesPauseWithoutRetryOrLosingWork(bool review)
        {
            var card = Card("visual", review ? "Review" : "InProgress");
            Set(card, "AgentResult", "completed implementation");
            Set(card, "ReviewFailureCount", 2);
            Call("CompleteAgentTaskCore", "visual", "Worker", "", "Visual verification blocked: no configured provider can accept these images.", "evidence", review, (ProviderFailureKind?)ProviderFailureKind.UnsupportedInput);
            Assert.AreEqual(review ? "Review" : "Todo", Get(card, "Status").ToString());
            Assert.AreEqual("Retry Available", Get(card, "ReviewStatus"));
            Assert.AreEqual(review, Get(card, "RetryReview"));
            Assert.AreEqual("durable checkpoint", Get(card, "ExecutionMemory"));
            Assert.AreEqual("completed implementation", Get(card, "AgentResult"));
            Assert.AreEqual(2, Get(card, "ReviewFailureCount"));
            Assert.AreEqual(0, Get(card, "AutomaticRetryCount"));
            Assert.AreEqual(string.Empty, Get(card, "NextAttemptAtUtc"));
            Call("ReleaseDueProviderRetries");
            Assert.AreEqual("Retry Available", Get(card, "ReviewStatus"));
            StringAssert.Contains("image", (string)Get(card, "BlockedReason"));
        }

        [TestCase("missing-owner")]
        [TestCase("ambiguous-name")]
        [TestCase("duplicate-owner")]
        [TestCase("local-output")]
        public void Hybrid_UpstreamReviewInferenceRejectsAmbiguousOwnership(string scenario)
        {
            var owner = Card("owner-id", "Done");
            var card = Card("integration", "Review");
            const string path = "Assets/Game/Player.prefab";
            ((IList)Get(owner, "ProducedArtifactPaths")).Add(path);
            ((IList)Get(card, "ConsumedArtifactPaths")).Add(path);
            if (scenario == "ambiguous-name") ((IList)Get(card, "ConsumedArtifactPaths")).Add("Assets/Other/Player.prefab");
            if (scenario == "duplicate-owner") ((IList)Get(Card("other", "Done"), "ProducedArtifactPaths")).Add(path);
            if (scenario == "local-output") ((IList)Get(card, "ProducedArtifactPaths")).Add(path);
            object[] args = { card, "KANBAN_REVIEW: FAIL. Player.prefab needs repair by " + (scenario == "missing-owner" ? "someone" : "owner-id"), null, null };
            Assert.IsFalse((bool)Call("TryRouteUpstreamArtifactDefect", args));
            Assert.AreEqual("Done", Get(owner, "Status").ToString());
        }

        [Test]
        public void HumanVisualReview_DefersImageBytesButPreservesEvidenceAndTextTools()
        {
            var type = SessionType.Assembly.GetType("AIRuntime.Editor.KanbanHumanVisualReviewMiddleware");
            var input = "{\"Succeeded\":true,\"Artifacts\":[{\"Kind\":\"Image\",\"Path\":\"shot.png\",\"Sha256\":\"abc\",\"MediaType\":\"image/png\"},{\"Kind\":\"File\",\"Path\":\"report.txt\"}]}";
            string saved = null;
            string output = (string)type.GetMethod("DeferImages", Flags).Invoke(null, new object[] { input, (Action<string>)(value => saved = value) });
            var json = JObject.Parse(output);
            Assert.AreEqual("shot.png", (string)JArray.Parse(saved)[0]["Path"]);
            Assert.AreEqual(1, ((JArray)json["Artifacts"]).Count);
            Assert.AreEqual("File", (string)json["Artifacts"][0]["Kind"]);
            Assert.AreEqual("shot.png", (string)json["HumanReviewArtifacts"][0]["Path"]);
            StringAssert.Contains("Awaiting human approval", (string)json["VisualVerification"]);
            var conversation = new AIRuntime.Runtime.Core.Conversation();
            conversation.AddCapabilityResult("capture", "capture_game_view", output);
            Assert.IsFalse(conversation.Messages.Last().Attachments.Any(item => item.Kind == "Image"));
        }

        private string AddHumanImage(object card)
        {
            string path = "Temp/KanbanHumanVisualTests/" + Guid.NewGuid().ToString("N") + ".png";
            Directory.CreateDirectory(Path.GetDirectoryName(path));
            File.WriteAllText(path, "test screenshot bytes");
            string hash = (string)SessionType.GetMethod("VisualFileHash", Flags).Invoke(null, new object[] { path });
            Call("RecordHumanVisualEvidence", card, new JArray(new JObject { ["Path"] = path, ["Sha256"] = hash, ["Kind"] = "Image", ["MediaType"] = "image/png" }).ToString());
            return path;
        }

        [Test]
        public void HumanVisualReview_AutomatedPassWaitsAndExplicitApprovalCompletes()
        {
            var card = Card("visual-approval", "Review");
            string path = AddHumanImage(card);
            try
            {
                Call("CompleteReviewTask", card, "Reviewer", "KANBAN_REVIEW: PASS", "", "{}");
                Assert.AreEqual("Review", Get(card, "Status").ToString());
                Assert.AreEqual("Awaiting visual approval", Get(card, "ReviewStatus"));
                Assert.AreEqual("Awaiting visual approval", Call("GetTaskSchedulingBlocker", card));
                Assert.IsFalse((bool)Call("StartReviewTask", card, "Worker"));
                Assert.AreEqual(0, Get(card, "ReviewFailureCount"));
                object[] move = { card, Enum.Parse(Get(card, "Status").GetType(), "Done"), null };
                Assert.IsFalse((bool)Call("TrySetTaskStatus", move));
                Assert.IsFalse((bool)Call("SubmitHumanVisualReview", card, true, ""));
                Assert.IsTrue((bool)Call("SubmitHumanVisualReview", card, true, "Checked screenshot layout and legibility."));
                Assert.AreEqual("Done", Get(card, "Status").ToString());
                Assert.AreEqual("Passed", Get(card, "ReviewStatus"));
                Assert.IsNotEmpty((string)Get(card, "HumanVisualReviewedAtUtc"));
                Assert.IsFalse((bool)Call("SubmitHumanVisualReview", card, true, "Duplicate approval"));
            }
            finally { File.Delete(path); }
        }

        [TestCase("image")]
        [TestCase("contract")]
        [TestCase("asset")]
        public void HumanVisualReview_ChangedEvidenceCannotBeApproved(string change)
        {
            var card = Card("visual-stale", "Review");
            string asset = "Temp/human-asset-" + Guid.NewGuid().ToString("N") + ".txt";
            File.WriteAllText(asset, "version one");
            ((IList)Get(card, "ConsumedArtifactPaths")).Add(asset);
            string path = AddHumanImage(card);
            try
            {
                Call("CompleteReviewTask", card, "Reviewer", "KANBAN_REVIEW: PASS", "", "{}");
                if (change == "image") File.WriteAllText(path, "replacement image");
                if (change == "contract") Set(card, "AcceptanceCriteria", "Changed acceptance criteria");
                if (change == "asset") File.WriteAllText(asset, "version two");
                Assert.IsFalse((bool)Call("SubmitHumanVisualReview", card, true, "Looks good"));
                Assert.AreEqual("Review", Get(card, "Status").ToString());
            }
            finally { File.Delete(path); File.Delete(asset); }
        }

        [Test]
        public void HumanVisualReview_RejectsBackToReworkAndRequiresNewCapture()
        {
            var card = Card("visual-rework", "Review");
            string path = AddHumanImage(card);
            try
            {
                Call("CompleteReviewTask", card, "Reviewer", "KANBAN_REVIEW: PASS", "", "{}");
                Assert.IsTrue((bool)Call("SubmitHumanVisualReview", card, false, "Status text is clipped."));
                Assert.AreEqual("Todo", Get(card, "Status").ToString());
                StringAssert.Contains("Status text is clipped", (string)Get(card, "NextAgentMessage"));
                Assert.AreEqual("[]", Get(card, "HumanVisualArtifacts"));
                Call("CompleteReviewTask", card, "Reviewer", "KANBAN_REVIEW: PASS", "", "{}");
                Assert.IsFalse((bool)Call("SubmitHumanVisualReview", card, true, "No new capture"));
            }
            finally { File.Delete(path); }
        }

        [Test]
        public void HumanVisualReview_PendingGateSurvivesBoardReload()
        {
            var card = Card("visual-persist", "Review");
            string path = AddHumanImage(card);
            try
            {
                Call("CompleteReviewTask", card, "Reviewer", "KANBAN_REVIEW: PASS", "", "{}");
                string evidence = (string)Get(card, "HumanVisualArtifacts");
                UnityEngine.Object.DestroyImmediate(_session);
                _session = ScriptableObject.CreateInstance<AIRuntimeKanbanSession>();
                _objects.Add(_session);
                Set(_session, "_autoSave", false);
                card = Call("FindTask", "visual-persist");
                Assert.AreEqual("Awaiting visual approval", Get(card, "ReviewStatus"));
                Assert.AreEqual(evidence, Get(card, "HumanVisualArtifacts"));
                Assert.IsTrue((bool)Get(card, "HumanVisualReviewRequired"));
                Assert.IsTrue((bool)Call("SubmitHumanVisualReview", card, true, "Checked the saved screenshots after reload."));
                Assert.AreEqual("Done", Get(card, "Status").ToString());
            }
            finally { File.Delete(path); }
        }

        [Test]
        public void HumanVisualReview_CannotOverrideFailedAutomatedChecks()
        {
            var card = Card("visual-failed", "Review");
            string path = AddHumanImage(card);
            try
            {
                Call("CompleteReviewTask", card, "Reviewer", "KANBAN_REVIEW: FAIL\nMissing required component.", "", "{}");
                Assert.AreNotEqual("Awaiting visual approval", Get(card, "ReviewStatus"));
                Assert.IsFalse((bool)Call("SubmitHumanVisualReview", card, true, "Looks fine"));
                Assert.AreNotEqual("Done", Get(card, "Status").ToString());
            }
            finally { File.Delete(path); }
        }

        private void ConfigureFallback()
        {
            var options = (IList)Get(_session, "_agentOptions");
            options.Clear();
            foreach (string id in new[] { "Worker", "Backup", "WrongRole" })
            {
                object option = Activator.CreateInstance(SessionType.GetNestedType("AgentOption", Flags));
                Set(option, "Id", id);
                Set(option, "Role", id == "WrongRole" ? "Reviewer" : "Developer");
                options.Add(option);
                ((HashSet<string>)Get(_session, "_activeKanbanAgentIds")).Add(id);
            }
            object chain = Activator.CreateInstance(SessionType.GetNestedType("AgentFallbackChain", Flags));
            Set(chain, "PreferredAgentId", "Worker");
            Set(chain, "FallbackAgentIds", new List<string> { "Worker", "WrongRole", "Backup", "Backup" });
            ((IList)Get(Get(_session, "_board"), "AgentFallbackChains")).Add(chain);
        }

        private void CoolAgent(string id)
        {
            object cooldown = Activator.CreateInstance(SessionType.GetNestedType("ProviderCooldownState", Flags));
            Set(cooldown, "AgentId", id);
            Set(cooldown, "UntilUtc", DateTimeOffset.UtcNow.AddMinutes(5).ToString("O"));
            ((IList)Get(Get(_session, "_board"), "ProviderCooldowns")).Add(cooldown);
        }

        [Test]
        public void Hybrid_FallbackUsesExplicitSameRoleChainOnlyDuringCooldownAndPersistsAssignment()
        {
            ConfigureFallback();
            var card = Card("fallback");
            Assert.AreEqual("Worker", Call("ResolveWorkflowAgentId", card, false));
            CoolAgent("Worker");
            Assert.AreEqual("Backup", Call("ResolveWorkflowAgentId", card, false));
            Assert.AreEqual("Worker", Get(card, "AssignedAgent"));
            Assert.AreEqual("durable checkpoint", Get(card, "ExecutionMemory"));
            Assert.IsTrue((bool)Call("PersistSchedulerState"));
            Assert.AreEqual("Worker", (string)Saved["Tasks"][0]["AssignedAgent"]);
            Assert.AreEqual("Backup", (string)Saved["AgentFallbackChains"][0]["FallbackAgentIds"][2]);
            Set(card, "WorkflowPhase", "Planning");
            Assert.AreEqual("Worker", Call("ResolveWorkflowAgentId", card, false));
            Set(card, "WorkflowPhase", "Implementation");
            Assert.AreEqual("Worker", Call("ResolveWorkflowAgentId", card, true));
        }

        [Test]
        public void Hybrid_FallbackSkipsInactiveBusyAndCoolingAgentsWithoutLooping()
        {
            ConfigureFallback();
            var card = Card("fallback");
            CoolAgent("Worker");
            var active = (HashSet<string>)Get(_session, "_activeKanbanAgentIds");
            active.Remove("Backup");
            Assert.AreEqual("Worker", Call("ResolveWorkflowAgentId", card, false));
            active.Add("Backup");
            var busy = Card("busy", "InProgress");
            Set(busy, "AssignedAgent", "Backup");
            Assert.AreEqual("Worker", Call("ResolveWorkflowAgentId", card, false));
            Tasks.Remove(busy);
            CoolAgent("Backup");
            Assert.AreEqual("Worker", Call("ResolveWorkflowAgentId", card, false));
            Assert.AreEqual("Provider cooldown", Call("GetTaskSchedulingBlocker", card));
        }

        [Test]
        public void Hybrid_FallbackStillHonorsDependencies()
        {
            ConfigureFallback();
            CoolAgent("Worker");
            Card("root");
            var dependency = Card("dependency", "Backlog");
            var card = Card("fallback");
            Set(dependency, "ParentTaskId", "root");
            Set(card, "ParentTaskId", "root");
            ((IList)Get(card, "DependsOnTaskIds")).Add("dependency");
            Assert.AreEqual("Backup", Call("ResolveWorkflowAgentId", card, false));
            Assert.AreEqual("Unfinished dependency", Call("GetTaskSchedulingBlocker", card));
        }

        [Test]
        public void Hybrid_FallbackDispatchPersistsActualAgentAndHandoffBeforeRunning()
        {
            ConfigureFallback();
            CoolAgent("Worker");
            var card = Card("fallback");
            var completion = new TaskCompletionSource<string>();
            bool recorded = false;
            Set(_session, "RunnerOverrideForTests", new Func<string, CancellationToken, Task<string>>((id, token) =>
            {
                recorded = (string)Saved["Tasks"][0]["Dispatch"]["AgentId"] == "Backup" &&
                           (string)Saved["Tasks"][0]["AssignedAgent"] == "Worker";
                token.Register(() => completion.TrySetCanceled());
                return completion.Task;
            }));
            Set(_session, "_autoRunAgents", true);
            Set(_session, "_agentStartsRemaining", 1);
            Call("PumpAgentQueue");
            Assert.IsTrue(recorded);
            Assert.AreEqual("InProgress", Get(card, "Status").ToString());
            StringAssert.Contains("[AgentFallback]", (string)Get(card, "NextAgentMessage"));
            Assert.AreEqual("Backup", (string)Saved["Tasks"][0]["Memory"]["Attempts"].Last["Agent"]);
            Assert.AreEqual("Backup", Call("ResolveWorkflowAgentId", card, false));
        }

        private bool Retry(object card, bool review = false, ProviderFailureKind kind = ProviderFailureKind.Unavailable, string error = "HTTP 503 Service Unavailable") =>
            (bool)Call("ScheduleProviderRetry", card, error, (ProviderFailureKind?)kind, review);

        [Test]
        public void Hybrid_RetrySurvivesReloadAndDueWakePreservesDependenciesAndMemory()
        {
            Card("root");
            var dependency = Card("dependency", "Backlog");
            var card = Card("retry");
            Set(dependency, "ParentTaskId", "root");
            Set(card, "ParentTaskId", "root");
            ((IList)Get(card, "DependsOnTaskIds")).Add("dependency");
            ((IList)Get(card, "DeclaredDependencyTitles")).Add("Summarize dependency");
            ((IList)Get(card, "DeclaredDependencyTitles")).Add("Summarize dependency");
            Assert.IsTrue(Retry(card));
            string due = (string)Get(card, "NextAttemptAtUtc");
            Assert.IsTrue((bool)Call("PersistSchedulerState"));
            UnityEngine.Object.DestroyImmediate(_session);
            _session = ScriptableObject.CreateInstance<AIRuntimeKanbanSession>();
            _objects.Add(_session);
            Set(_session, "_autoSave", false);
            ((HashSet<string>)Get(_session, "_activeKanbanAgentIds")).Add("Worker");
            card = Call("FindTask", "retry");
            Assert.AreEqual(due, Get(card, "NextAttemptAtUtc"));
            Call("ReleaseDueProviderRetries");
            Assert.AreEqual("Retry Available", Get(card, "ReviewStatus"));
            Set(card, "NextAttemptAtUtc", DateTimeOffset.UtcNow.AddMinutes(-1).ToString("O"));
            Call("ReleaseDueProviderRetries");
            Assert.AreEqual("Unfinished dependency", Call("GetTaskSchedulingBlocker", card));
            Assert.AreEqual("durable checkpoint", Get(card, "ExecutionMemory"));
            Assert.AreEqual(1, Get(card, "AutomaticRetryCount"));
            Assert.AreEqual(string.Empty, (string)Saved["Tasks"][2]["NextAttemptAtUtc"]);
        }

        [TestCase(ProviderFailureKind.Authentication, "HTTP 401")]
        [TestCase(ProviderFailureKind.InvalidRequest, "Invalid request")]
        [TestCase(ProviderFailureKind.Unavailable, "All eligible providers failed [Authentication] HTTP 503")]
        [TestCase(ProviderFailureKind.ModelUnavailable, "Model does not exist")]
        [TestCase(ProviderFailureKind.Cancelled, "Cancelled")]
        public void Hybrid_ConfigurationAndCancellationNeverScheduleAutomaticRetry(ProviderFailureKind kind, string error)
        {
            var card = Card("manual");
            Assert.IsFalse(Retry(card, false, kind, error));
            Assert.IsTrue(string.IsNullOrEmpty((string)Get(card, "NextAttemptAtUtc")));
            Assert.AreEqual(0, Get(card, "AutomaticRetryCount"));
        }

        [Test]
        public void Hybrid_ExplicitResetCleanupPreservesExistingInputsAndRefusesRunningBoard()
        {
            var card = Card("reset", "Todo", "Planning");
            Set(card, "ExecutionMemory", string.Empty);
            var inputs = (IList)Get(card, "ConsumedArtifactPaths");
            inputs.Add("Assets/__MissingResetInput_" + Guid.NewGuid().ToString("N") + ".cs");
            inputs.Add(TestAssetPaths.Resolve("Assets/Editor/AIRuntimeKanbanSession.cs"));
            Assert.AreEqual(1, Call("RemoveMissingResetRootInputs"));
            Assert.AreEqual(1, inputs.Count);
            Assert.AreEqual(TestAssetPaths.Resolve("Assets/Editor/AIRuntimeKanbanSession.cs"), inputs[0]);
            Set(_session, "_autoRunAgents", true);
            var error = Assert.Throws<TargetInvocationException>(() => Call("RemoveMissingResetRootInputs"));
            Assert.IsInstanceOf<InvalidOperationException>(error.InnerException);
        }

        [Test]
        public void Hybrid_RetryBudgetIsBoundedAndReviewStaysInReview()
        {
            var card = Card("review", "Review", "FinalReview");
            for (int attempt = 1; attempt <= 3; attempt++)
            {
                Assert.IsTrue(Retry(card, true));
                double seconds = (DateTimeOffset.Parse((string)Get(card, "NextAttemptAtUtc")) - DateTimeOffset.UtcNow).TotalSeconds;
                Assert.That(seconds, Is.InRange(30 * Math.Pow(2, attempt - 1) - 2, 30 * Math.Pow(2, attempt - 1) + 1));
                Set(card, "NextAttemptAtUtc", DateTimeOffset.UtcNow.AddSeconds(-1).ToString("O"));
                Call("ReleaseDueProviderRetries");
                Assert.AreEqual("Review", Get(card, "Status").ToString());
                Assert.AreEqual("Pending", Get(card, "ReviewStatus"));
            }
            Assert.IsTrue(Retry(card, true));
            Assert.AreEqual(string.Empty, Get(card, "NextAttemptAtUtc"));
            Assert.AreEqual(3, Get(card, "AutomaticRetryCount"));
            Call("ResumeProviderRetry", card);
            Assert.AreEqual(0, Get(card, "AutomaticRetryCount"));
            Assert.AreEqual("Review", Get(card, "Status").ToString());
        }

        [Test]
        public void Hybrid_ExclusiveOwnerAndDurableClaimPreventDuplicateDispatch()
        {
            var card = Card("claimed");
            Assert.IsTrue((bool)Call("TryClaimDispatch", card));
            Assert.AreEqual("Running", (string)Saved["Tasks"][0]["Dispatch"]["Outcome"]);
            Assert.IsFalse((bool)Call("TryClaimDispatch", card));
            var other = ScriptableObject.CreateInstance<AIRuntimeKanbanSession>();
            _objects.Add(other);
            Assert.IsFalse((bool)Invoke(other, "TryAcquireSchedulerOwnership"));
        }

        [Test]
        public void Hybrid_ConflictingSaveDoesNotDispatchOrConsumeStepBudget()
        {
            var card = Card("conflict");
            Call("PersistSchedulerState");
            string foreign = File.ReadAllText(_path).Replace("Summarize conflict", "Externally revised");
            File.WriteAllText(_path, foreign);
            Set(_session, "_agentStartsRemaining", 1);
            LogAssert.Expect(LogType.Error, new Regex("\\[Kanban\\] Refused to overwrite externally changed board"));
            Assert.IsFalse((bool)Call("StartAgentTask", card, "Worker"));
            Assert.AreEqual(1, Get(_session, "_agentStartsRemaining"));
            Assert.AreEqual(0, ((IDictionary)Get(_session, "_activeAgentRuns")).Count);
            Assert.AreEqual(foreign, File.ReadAllText(_path));
            Set(_session, "_isDirty", false);
        }

        private object Track(object card)
        {
            object run = Activator.CreateInstance(SessionType.GetNestedType("ActiveAgentRun", Flags));
            Set(run, "TaskId", Get(card, "Id"));
            Set(run, "AttemptId", Get(Get(card, "Dispatch"), "Id"));
            Set(run, "Cancellation", new CancellationTokenSource());
            Set(run, "LastActivityAtUtc", DateTime.UtcNow);
            ((IDictionary)Get(_session, "_activeAgentRuns")).Add("Worker", run);
            return run;
        }

        [Test]
        public void Hybrid_StaleCompletionAndJournalCannotOverwriteNewAttempt()
        {
            var card = Card("fenced");
            Assert.IsTrue((bool)Call("TryClaimDispatch", card));
            Track(card);
            foreach (string type in new[] { "PendingAgentCompletion", "PendingExecutionMemoryUpdate" })
            {
                object entry = Activator.CreateInstance(SessionType.GetNestedType(type, Flags));
                Set(entry, "TaskId", "fenced");
                Set(entry, "AttemptId", "older-attempt");
                if (type == "PendingAgentCompletion") { Set(entry, "AgentId", "Worker"); Set(entry, "Result", "stale result"); }
                else Set(entry, "Memory", "stale journal");
                object queue = Get(_session, type == "PendingAgentCompletion" ? "_pendingAgentCompletions" : "_pendingExecutionMemoryUpdates");
                queue.GetType().GetMethod("Enqueue").Invoke(queue, new[] { entry });
            }
            Call("DrainPendingExecutionMemoryUpdates");
            Call("DrainPendingAgentCompletions");
            Assert.AreEqual("durable checkpoint", Get(card, "ExecutionMemory"));
            Assert.AreEqual(1, ((IDictionary)Get(_session, "_activeAgentRuns")).Count);
            Assert.AreEqual("Running", Get(Get(card, "Dispatch"), "Outcome"));
        }

        [Test]
        public void Hybrid_RestartDoesNotStealLiveLeaseAndExpiredLeaseRequeuesOnce()
        {
            var card = Card("restart", "InProgress");
            Assert.IsTrue((bool)Call("TryClaimDispatch", card));
            var recover = SessionType.GetMethod("RecoverInterruptedTasks", Flags);
            Assert.AreEqual(0, recover.Invoke(null, new object[] { Tasks }));
            Set(Get(card, "Dispatch"), "LeaseUntilUtc", DateTimeOffset.UtcNow.AddMinutes(-1).ToString("O"));
            Assert.AreEqual(1, recover.Invoke(null, new object[] { Tasks }));
            Assert.AreEqual(0, recover.Invoke(null, new object[] { Tasks }));
            Assert.AreEqual("Todo", Get(card, "Status").ToString());
            Assert.AreEqual("durable checkpoint", Get(card, "ExecutionMemory"));
        }

        [Test]
        public void Hybrid_ReloadCancellationDrainsAndFailedCompilationCanRecover()
        {
            var card = Card("reload", "InProgress");
            Assert.IsTrue((bool)Call("TryClaimDispatch", card));
            var run = Track(card);
            string attempt = (string)Get(run, "AttemptId");
            Call("PrepareForAssemblyReload");
            Assert.AreEqual("Interrupted", Get(Get(card, "Dispatch"), "Outcome"));
            object completion = Activator.CreateInstance(SessionType.GetNestedType("PendingAgentCompletion", Flags));
            Set(completion, "TaskId", "reload");
            Set(completion, "AgentId", "Worker");
            Set(completion, "AttemptId", attempt);
            Set(completion, "Error", "Agent run was cancelled.");
            object queue = Get(_session, "_pendingAgentCompletions");
            queue.GetType().GetMethod("Enqueue").Invoke(queue, new[] { completion });
            Call("DrainPendingAgentCompletions");
            Assert.AreEqual(0, ((IDictionary)Get(_session, "_activeAgentRuns")).Count);
            Assert.IsTrue((bool)Call("TryRecoverFailedCompilationWithoutReload", false, false, true));
            Assert.IsFalse((bool)Get(_session, "_isPreparingForAssemblyReload"));
            Assert.AreEqual("durable checkpoint", Get(card, "ExecutionMemory"));
        }

        [Test]
        public void Hybrid_HeartbeatPersistsOwnershipAndExplicitRunIntentSurvivesSessionRestart()
        {
            var card = Card("heartbeat", "InProgress");
            Assert.IsTrue((bool)Call("TryClaimDispatch", card));
            Track(card);
            Set(Get(card, "Dispatch"), "LeaseUntilUtc", DateTimeOffset.UtcNow.AddSeconds(1).ToString("O"));
            Call("RefreshDispatchLeases");
            Assert.That((DateTimeOffset.Parse((string)Saved["Tasks"][0]["Dispatch"]["LeaseUntilUtc"]) - DateTimeOffset.UtcNow).TotalSeconds,
                Is.GreaterThan(110));
            Call("SetAutomaticAgentScheduling", true);
            Assert.IsTrue((bool)Saved["SchedulerEnabled"]);
            Call("PrepareForAssemblyReload");
            UnityEngine.Object.DestroyImmediate(_session);
            _session = (AIRuntimeKanbanSession)SessionType.GetMethod("CreateServiceSession", Flags).Invoke(null, new object[] { _path });
            _objects.Add(_session);
            Assert.IsTrue((bool)Get(_session, "_autoRunAgents"));
            Call("SetAutomaticAgentScheduling", false);
            Assert.IsFalse((bool)Saved["SchedulerEnabled"]);
        }

        [Test]
        public void Hybrid_AgedReviewWinsSharedAgentBeforeImplementation()
        {
            var implementation = Card("implementation");
            var review = Card("review", "Review", "FinalReview");
            Set(review, "ReviewStatus", "Pending");
            Set(review, "ReviewQueuedAtUtc", DateTimeOffset.UtcNow.AddMinutes(-6).ToString("O"));
            string started = null;
            var completion = new TaskCompletionSource<string>();
            Set(_session, "RunnerOverrideForTests", new Func<string, CancellationToken, Task<string>>((id, token) =>
            {
                started = id;
                token.Register(() => completion.TrySetCanceled());
                return completion.Task;
            }));
            Set(_session, "_autoRunAgents", true);
            Set(_session, "_agentStartsRemaining", 1);
            Call("PumpAgentQueue");
            Assert.AreEqual("review", started);
            Assert.AreEqual("Todo", Get(implementation, "Status").ToString());
            Assert.AreEqual(0, Get(_session, "_agentStartsRemaining"));
        }

        [UnityTest]
        public IEnumerator Hybrid_LiveEditorServiceFinishesAndPersistsAfterPanelCloses()
        {
            var card = Card("live-panel-close");
            var completion = new TaskCompletionSource<string>();
            bool claimPersistedBeforeRun = false;
            CancellationToken runningToken = default;
            Set(_session, "RunnerOverrideForTests", new Func<string, CancellationToken, Task<string>>((id, token) =>
            {
                claimPersistedBeforeRun = (string)Saved["Tasks"][0]["Dispatch"]["Outcome"] == "Running" &&
                                          (int)Saved["Tasks"][0]["Status"] == 2;
                runningToken = token;
                token.Register(() => completion.TrySetCanceled());
                return completion.Task;
            }));
            SchedulerType.GetField("_session", Flags).SetValue(null, _session);
            Set(_session, "_autoRunAgents", true);
            Set(_session, "_agentStartsRemaining", 1);
            SchedulerType.GetMethod("Wake", Flags).Invoke(null, null);
            var panel = ScriptableObject.CreateInstance<AIRuntimeKanbanWindow>();
            _objects.Add(panel);
            double deadline = EditorApplication.timeSinceStartup + 12;
            while (!claimPersistedBeforeRun && EditorApplication.timeSinceStartup < deadline) yield return null;
            Assert.IsTrue(claimPersistedBeforeRun, "The live editor update must start a durably claimed attempt.");
            UnityEngine.Object.DestroyImmediate(panel);
            Assert.IsFalse(runningToken.IsCancellationRequested, "Detaching the view must not cancel its session.");
            completion.SetResult("Architecture inspection completed.");
            while ((string)Get(Get(card, "Dispatch"), "Outcome") == "Running" && EditorApplication.timeSinceStartup < deadline) yield return null;
            Assert.AreEqual("Completed", Get(Get(card, "Dispatch"), "Outcome"));
            Assert.AreEqual("Review", Get(card, "Status").ToString());
            Assert.AreEqual("Completed", (string)Saved["Tasks"][0]["Dispatch"]["Outcome"]);
            Assert.AreEqual("Architecture inspection completed.", (string)Saved["Tasks"][0]["AgentResult"]);
            Assert.AreEqual(0, Get(_session, "_agentStartsRemaining"));
            string evidence = Path.Combine(Application.dataPath, "../Logs/hybrid-verification/live-lifecycle.json");
            Directory.CreateDirectory(Path.GetDirectoryName(evidence));
            File.WriteAllText(evidence, new JObject {
                ["VerifiedAtUtc"] = DateTimeOffset.UtcNow.ToString("O"),
                ["ClaimPersistedBeforeRun"] = claimPersistedBeforeRun,
                ["PanelCloseCancelledRun"] = runningToken.IsCancellationRequested,
                ["Worker"] = "Deterministic test worker; no external provider",
                ["FinalBoard"] = Saved
            }.ToString());
        }

        [Test]
        public void Cli_BlockedSelectionDoesNotDispatchAnotherEligibleCard()
        {
            Card("eligible");
            var blocked = Card("blocked", "Backlog");
            bool called = false;
            Set(_session, "RunnerOverrideForTests", new Func<string, CancellationToken, Task<string>>((id, token) =>
            {
                called = true;
                return Task.FromResult("Unexpected run");
            }));
            Call("PrepareCommandLineStep", "blocked");
            var result = (JObject)Call("PollCommandLineStep");
            Assert.AreEqual("Blocked", (string)result["Outcome"]);
            Assert.AreEqual("blocked", (string)result["TaskId"]);
            Assert.IsFalse(called);
        }

        [Test]
        public void Cli_RejectsEnabledBoardAndUnknownTask()
        {
            Card("selected");
            Assert.Throws<TargetInvocationException>(() => Call("PrepareCommandLineStep", "missing"));
            Set(Get(_session, "_board"), "SchedulerEnabled", true);
            Assert.Throws<TargetInvocationException>(() => Call("PrepareCommandLineStep", "selected"));
        }

        [UnityTest]
        public IEnumerator Cli_SelectedDispatchCompletesDurablyWithoutOpeningPanel()
        {
            Card("other");
            Card("selected");
            var completion = new TaskCompletionSource<string>();
            int starts = 0;
            Set(_session, "RunnerOverrideForTests", new Func<string, CancellationToken, Task<string>>((id, token) =>
            {
                Assert.AreEqual("selected", id);
                Assert.AreEqual("Running", (string)Saved["Tasks"][1]["Dispatch"]["Outcome"]);
                starts++;
                return completion.Task;
            }));
            Call("PrepareCommandLineStep", "selected");
            Assert.IsNull(Call("PollCommandLineStep"));
            completion.SetResult("Architecture inspection completed.");
            JObject result = null;
            double until = EditorApplication.timeSinceStartup + 10;
            while (result == null && EditorApplication.timeSinceStartup < until)
            {
                yield return null;
                result = (JObject)Call("PollCommandLineStep");
            }
            Assert.NotNull(result);
            Assert.AreEqual("Completed", (string)result["Outcome"]);
            Assert.AreEqual(1, starts);
            Assert.AreEqual("Completed", (string)Saved["Tasks"][1]["Dispatch"]["Outcome"]);
            Assert.IsTrue(string.IsNullOrEmpty(Saved["Tasks"][0]["Dispatch"]?.Value<string>("Id")));
            Assert.IsFalse((bool)Saved["SchedulerEnabled"]);
        }

        [Test]
        public void Cli_TimeoutCancelsAndPersistsInterruptedAttempt()
        {
            Card("selected");
            var completion = new TaskCompletionSource<string>();
            CancellationToken captured = default;
            Set(_session, "RunnerOverrideForTests", new Func<string, CancellationToken, Task<string>>((id, token) =>
            {
                captured = token;
                return completion.Task;
            }));
            Call("PrepareCommandLineStep", "selected");
            Assert.IsNull(Call("PollCommandLineStep"));
            var result = (JObject)Call("StopCommandLineStep", "TimedOut", "Test deadline");
            Assert.IsTrue(captured.IsCancellationRequested);
            Assert.AreEqual("TimedOut", (string)result["Outcome"]);
            Assert.AreEqual("Interrupted", (string)Saved["Tasks"][0]["Dispatch"]["Outcome"]);
        }
    }
}
