using System.Text.Json;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Runner;

public sealed class TaskRunner
{
    private readonly IClaudeProcess _claude;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly HubBroadcaster _broadcaster;
    private readonly WorktreeManager _wtManager;
    private readonly ClaudeArgsBuilder _argsBuilder;
    private readonly WorkerConfig _cfg;
    private readonly ILogger<TaskRunner> _logger;
    private readonly ITaskStateService _state;
    private readonly TaskRunTokenRegistry _tokens;
    private readonly AttachmentStore _attachments;
    private readonly ISessionSkillSeeder _skillSeeder;
    private readonly ITranscriptUsageReader _usageReader;

    public TaskRunner(
        IClaudeProcess claude,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        HubBroadcaster broadcaster,
        WorktreeManager wtManager,
        ClaudeArgsBuilder argsBuilder,
        WorkerConfig cfg,
        ILogger<TaskRunner> logger,
        ITaskStateService state,
        TaskRunTokenRegistry tokens,
        AttachmentStore attachments,
        ISessionSkillSeeder skillSeeder,
        ITranscriptUsageReader usageReader)
    {
        _claude = claude;
        _dbFactory = dbFactory;
        _broadcaster = broadcaster;
        _wtManager = wtManager;
        _argsBuilder = argsBuilder;
        _cfg = cfg;
        _logger = logger;
        _state = state;
        _tokens = tokens;
        _attachments = attachments;
        _skillSeeder = skillSeeder;
        _usageReader = usageReader;
    }

    public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false)
    {
        string? mcpToken = null;
        string? mcpConfigPath = null;
        try
        {
            ListEntity? list;
            ListConfigEntity? listConfig;
            List<SubtaskEntity> subtasks;

            List<string>? attachmentPaths = null;
            using (var context = _dbFactory.CreateDbContext())
            {
                var listRepo = new ListRepository(context);
                list = await listRepo.GetByIdAsync(task.ListId, ct);
                if (list is null)
                {
                    await MarkFailed(task.Id, task.Number, task.Title, slot, "List not found.");
                    return;
                }
                listConfig = await listRepo.GetConfigAsync(task.ListId, ct);

                var subtaskRepo = new SubtaskRepository(context);
                subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct);

                var attachmentRepo = new TaskAttachmentRepository(context);
                var attachments = await attachmentRepo.ListByTaskIdAsync(task.Id, ct);
                if (attachments.Count > 0)
                    attachmentPaths = attachments.Select(a => Path.Combine(_attachments.TaskDir(task.Id), a.FileName)).ToList();
            }

            var now = DateTime.UtcNow;
            // Claim Running before creating any resources (worktree, MCP token file, ...):
            // the queue picker claims Queued→Running atomically (incl. StartedAt) before
            // dispatching, so only unclaimed dispatches (override slot) need to claim here.
            // Claiming first means a losing double-dispatch (RunNow racing the picker for the
            // same row) bails out immediately instead of creating a worktree the winner then
            // has to self-heal past.
            if (!alreadyClaimed)
            {
                var startResult = await _state.StartRunningAsync(task.Id, now, ct);
                if (!startResult.Ok)
                {
                    _logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", task.Id, startResult.Reason);
                    return;
                }
            }
            else
            {
                // Queue-claimed dispatches skip StartRunningAsync (the atomic SQL claim in
                // QueuePicker already flipped the row to Running), so it never broadcasts
                // TaskUpdated for this transition. Send it here so the task-list badge flips
                // live instead of staying on "Queued" until the run finishes.
                await _broadcaster.TaskUpdated(task.Id);
            }
            await _broadcaster.TaskStarted(slot, task.Id, now);

            // Determine working directory: worktree or sandbox.
            var prep = await PrepareRunDirectoryAsync(task, list, ct);
            if (prep.FailureReason is not null)
            {
                await MarkFailed(task.Id, task.Number, task.Title, slot, prep.FailureReason);
                return;
            }
            var wtCtx = prep.WtCtx;
            var runDir = prep.RunDir!;

            var resolvedConfig = await ResolveConfigAsync(task, list, listConfig, null, ct);
            (mcpToken, mcpConfigPath, resolvedConfig) = await SetupMcpConfigAsync(task, resolvedConfig, ct, token => mcpToken = token);

            await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);

            // Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
            var prompt = TaskPromptComposer.Compose(
                task.Title, task.Description,
                subtasks.Select(s => (s.Title, s.Completed)),
                attachmentPaths);

            // Run 1.
            var result = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);

            if (result.IsSuccess)
            {
                await HandleSuccess(task, list, slot, wtCtx, result, ct);
            }
            else
            {
                // Auto-retry: one attempt if we have a session ID.
                if (result.SessionId is not null)
                {
                    _logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId);
                    var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
                    var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);

                    var retryResult = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);

                    if (retryResult.IsSuccess)
                    {
                        await HandleSuccess(task, list, slot, wtCtx, retryResult, ct);
                    }
                    else
                    {
                        await MarkFailed(task.Id, task.Number, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
                            retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason, retryResult.ErrorMarkdown));
                    }
                }
                else
                {
                    await MarkFailed(task.Id, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
                        resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason, result.ErrorMarkdown));
                }
            }

            await _broadcaster.TaskUpdated(task.Id);
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("Task {TaskId} was cancelled", task.Id);
            await MarkFailed(task.Id, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
            await MarkFailed(task.Id, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
        }
        finally
        {
            if (mcpToken is not null)
            {
                _tokens.Unregister(mcpToken);
                if (mcpConfigPath is not null)
                    try { File.Delete(mcpConfigPath); } catch { /* best effort */ }
            }
        }
    }

    public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct, bool alreadyClaimed = false)
    {
        TaskEntity task;
        TaskRunEntity lastRun;
        ListEntity list;
        ListConfigEntity? listConfig;
        WorktreeEntity? worktree;

        using (var context = _dbFactory.CreateDbContext())
        {
            var taskRepo = new TaskRepository(context);
            task = await taskRepo.GetByIdAsync(taskId, ct)
                ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");

            var runRepo = new TaskRunRepository(context);
            lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct)
                ?? throw new InvalidOperationException("No previous run to continue.");

            if (lastRun.SessionId is null)
                throw new InvalidOperationException("Previous run has no session ID — cannot resume.");

            var listRepo = new ListRepository(context);
            list = await listRepo.GetByIdAsync(task.ListId, ct)
                ?? throw new InvalidOperationException("List not found.");
            listConfig = await listRepo.GetConfigAsync(task.ListId, ct);

            var wtRepo = new WorktreeRepository(context);
            worktree = await wtRepo.GetByTaskIdAsync(taskId, ct);
        }

        var resolvedConfig = await ResolveConfigAsync(task, list, listConfig, lastRun.SessionId, ct);

        // Determine run directory from existing worktree or sandbox.
        string runDir;
        WorktreeContext? wtCtx = null;
        if (worktree is not null)
        {
            runDir = worktree.Path;
            wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
        }
        else
        {
            runDir = Path.Combine(_cfg.SandboxRoot, taskId);
        }

        string? mcpToken = null;
        string? mcpConfigPath = null;
        try
        {
            var now = DateTime.UtcNow;
            // See RunAsync: queue dispatches arrive pre-claimed by the picker. Claim, skill
            // seeding, and MCP setup all live inside this try (mirroring RunAsync) so a failure
            // in any of them still lands the task in Failed instead of stuck Running.
            if (!alreadyClaimed)
            {
                var startResult = await _state.StartRunningAsync(taskId, now, ct);
                if (!startResult.Ok)
                {
                    _logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", taskId, startResult.Reason);
                    return;
                }
            }
            else
            {
                // Queue-claimed dispatches skip StartRunningAsync, so broadcast TaskUpdated here
                // (see RunAsync for the full rationale).
                await _broadcaster.TaskUpdated(taskId);
            }
            await _broadcaster.TaskStarted(slot, taskId, now);

            await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);

            (mcpToken, mcpConfigPath, resolvedConfig) = await SetupMcpConfigAsync(task, resolvedConfig, ct, token => mcpToken = token);

            var nextRunNumber = lastRun.RunNumber + 1;
            var result = await RunOnceAsync(taskId, task.Number, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);

            if (result.IsSuccess)
            {
                await HandleSuccess(task, list, slot, wtCtx, result, ct);
            }
            else
            {
                await MarkFailed(taskId, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
                    resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason, result.ErrorMarkdown));
            }

            await _broadcaster.TaskUpdated(taskId);
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
            await MarkFailed(taskId, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
            await MarkFailed(taskId, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
        }
        finally
        {
            if (mcpToken is not null)
            {
                _tokens.Unregister(mcpToken);
                if (mcpConfigPath is not null)
                    try { File.Delete(mcpConfigPath); } catch { /* best effort */ }
            }
        }
    }

    // Every run gets a per-run MCP identity so the agent can ask the user a mid-run question
    // via AskUser. Improvement-eligible (standalone top-level) runs additionally get
    // SuggestImprovement for filing out-of-scope follow-ups. Used by both a fresh run and a
    // --resume continuation, which needs the same wiring or the resumed session loses every
    // mcp__claudedo_run__* tool.
    // onTokenRegistered fires the instant the token is registered, before any I/O that could
    // still fail — callers assign it straight into their outer (pre-try) mcpToken variable so a
    // later throw in this method still leaves the finally block able to unregister it.
    private async Task<(string McpToken, string McpConfigPath, ClaudeRunConfig Config)> SetupMcpConfigAsync(
        TaskEntity task, ClaudeRunConfig resolvedConfig, CancellationToken ct, Action<string>? onTokenRegistered = null)
    {
        var mcpToken = TaskRunTokenRegistry.GenerateToken();
        _tokens.Register(mcpToken, task.Id);
        onTokenRegistered?.Invoke(mcpToken);
        Directory.CreateDirectory(_cfg.LogRoot);
        var mcpConfigPath = Path.Combine(_cfg.LogRoot, $"{task.Id}_mcp.json");
        await File.WriteAllTextAsync(mcpConfigPath, BuildRunMcpConfigJson(mcpToken), ct);

        var improvementEligible = task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None;
        var config = resolvedConfig with
        {
            McpConfigPath = mcpConfigPath,
            AllowedTools = improvementEligible
                ? "mcp__claudedo_run__AskUser,mcp__claudedo_run__SuggestImprovement"
                : "mcp__claudedo_run__AskUser",
        };
        return (mcpToken, mcpConfigPath, config);
    }

    private readonly record struct RunDirResult(string? RunDir, WorktreeContext? WtCtx, string? FailureReason);

    private async Task<RunDirResult> PrepareRunDirectoryAsync(TaskEntity task, ListEntity list, CancellationToken ct)
    {
        if (list.WorkingDir is not null)
        {
            try
            {
                var wtCtx = await _wtManager.CreateAsync(task, list, ct);
                await _broadcaster.WorkerLog($"Created worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
                // The worktrees row was just inserted; without this the UI keeps showing the task
                // as having no worktree until some unrelated event happens to refresh it.
                await _broadcaster.WorktreeUpdated(task.Id);
                return new RunDirResult(wtCtx.WorktreePath, wtCtx, null);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id);
                return new RunDirResult(null, null, $"Worktree creation failed: {ex.Message}");
            }
        }

        var sandboxDir = Path.Combine(_cfg.SandboxRoot, task.Id);
        Directory.CreateDirectory(sandboxDir);
        return new RunDirResult(sandboxDir, null, null);
    }

    private async Task<RunResult> RunOnceAsync(
        string taskId, int taskNumber, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
        int runNumber, bool isRetry, string prompt, CancellationToken ct)
    {
        var runId = Guid.NewGuid().ToString();
        var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson");

        var run = new TaskRunEntity
        {
            Id = runId,
            TaskId = taskId,
            RunNumber = runNumber,
            IsRetry = isRetry,
            Prompt = prompt,
            LogPath = logPath,
            StartedAt = DateTime.UtcNow,
            Model = config.Model,
        };

        using (var context = _dbFactory.CreateDbContext())
        {
            var runRepo = new TaskRunRepository(context);
            await runRepo.AddAsync(run, ct);

            // Point the task at this run's log immediately so the UI can replay
            // live output when the user navigates away and back mid-run.
            var taskRepo = new TaskRepository(context);
            await taskRepo.SetLogPathAsync(taskId, logPath, ct);
        }

        var arguments = _argsBuilder.Build(config);

        await using var logWriter = new LogWriter(logPath);

        try
        {
            await _broadcaster.WorkerLog($"Started Claude for #{taskNumber} \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
            var result = await _claude.RunAsync(
                arguments,
                prompt,
                runDir,
                async line =>
                {
                    await logWriter.WriteLineAsync(line, ct);
                    await _broadcaster.TaskMessage(taskId, "[stdout] " + line);
                },
                ct);

            if (!result.IsSuccess)
                result = result with { ErrorMarkdown = BuildFailureMarkdown(result, config.MaxTurns) };

            // Update the run record with results. Use CancellationToken.None:
            // this is a terminal write that must always complete, even if the
            // caller's token is already cancelled.
            run.SessionId = result.SessionId;
            run.ResultMarkdown = result.ResultMarkdown;
            run.StructuredOutputJson = result.StructuredOutputJson;
            run.ErrorMarkdown = result.ErrorMarkdown;
            run.ExitCode = result.ExitCode;
            run.TurnCount = result.TurnCount;
            run.ResultSubtype = result.ResultSubtype;
            run.TerminalReason = result.TerminalReason;
            run.Errors = result.Errors.Count > 0 ? string.Join("\n", result.Errors) : null;
            if (result.SessionId is not null)
                await ApplyUsageAsync(run, taskId, result.SessionId);
            run.FinishedAt = DateTime.UtcNow;

            using (var context = _dbFactory.CreateDbContext())
            {
                var runRepo = new TaskRunRepository(context);
                await runRepo.UpdateAsync(run, CancellationToken.None);
            }

            return result;
        }
        catch (OperationCanceledException)
        {
            // Ensure the run row is completed so ContinueAsync / inspection
            // isn't left staring at a null session_id / finished_at.
            run.ErrorMarkdown = "Cancelled.";
            run.TerminalReason = "cancelled";
            run.ExitCode = -1;
            run.FinishedAt = DateTime.UtcNow;
            try
            {
                using var context = _dbFactory.CreateDbContext();
                var runRepo = new TaskRunRepository(context);
                await runRepo.UpdateAsync(run, CancellationToken.None);
            }
            catch (Exception updateEx)
            {
                _logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
            }
            throw;
        }
    }

    /// Populates the run's raw token fields from the session transcript (input, output,
    /// cache-read, cache-write — the API's "input_tokens" alone is only the uncached
    /// remainder and undercounts the real prompt size by orders of magnitude). A resumed
    /// session's transcript is cumulative, so the delta against prior runs sharing the same
    /// SessionId is stored, not the running total. Any failure here (missing/unreadable
    /// transcript) leaves the fields null and must never fail the run itself.
    private async Task ApplyUsageAsync(TaskRunEntity run, string taskId, string sessionId)
    {
        try
        {
            var totals = await _usageReader.ReadSessionTotalsAsync(sessionId, CancellationToken.None);
            if (totals is null) return;

            List<TaskRunEntity> priorRuns;
            using (var context = _dbFactory.CreateDbContext())
                priorRuns = await new TaskRunRepository(context).GetByTaskIdAsync(taskId, CancellationToken.None);

            var sameSession = priorRuns.Where(r => r.Id != run.Id && r.SessionId == sessionId).ToList();
            var priorInput = sameSession.Sum(r => (long)(r.TokensIn ?? 0));
            var priorOutput = sameSession.Sum(r => (long)(r.TokensOut ?? 0));
            var priorCacheRead = sameSession.Sum(r => (long)(r.CacheReadTokens ?? 0));
            var priorCacheWrite = sameSession.Sum(r => (long)(r.CacheWriteTokens ?? 0));

            run.TokensIn = (int)Math.Max(0, totals.InputTokens - priorInput);
            run.TokensOut = (int)Math.Max(0, totals.OutputTokens - priorOutput);
            run.CacheReadTokens = (int)Math.Max(0, totals.CacheReadTokens - priorCacheRead);
            run.CacheWriteTokens = (int)Math.Max(0, totals.CacheCreationTokens - priorCacheWrite);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to read session usage totals for task {TaskId}, session {SessionId}", taskId, sessionId);
        }
    }

    private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
    {
        var committed = false;
        if (wtCtx is not null)
        {
            committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
            if (committed)
            {
                await _broadcaster.WorkerLog($"Committed changes in #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
                await _broadcaster.WorktreeUpdated(task.Id);
            }
        }

        // A run can report success (exit 0, non-null result text) while every write it
        // attempted was denied by the permission gate — e.g. the claude-cli haiku/"auto"
        // downgrade to interactive "default" (see PermissionModeResolver). Left alone this
        // lands as a normal WaitingForReview with an empty diff, and a reviewer sees only
        // that emptiness with no clue why. Surface it as a failure instead.
        //
        // Narrow on purpose. It only fires when a worktree exists (a sandbox run has no diff
        // whose emptiness could mean anything) and only for denied WRITE tools: a research task
        // that legitimately changes nothing, or one that had a single Bash call denied, is a
        // successful run and must not be reported as "all edits blocked".
        var deniedWrites = result.PermissionDenials.Where(IsWriteTool).Distinct().ToList();
        if (wtCtx is not null && !committed && deniedWrites.Count > 0)
        {
            await MarkFailed(
                task.Id, task.Number, task.Title, slot,
                $"All edits were blocked by permission denials ({string.Join(", ", deniedWrites)}) and nothing " +
                "was changed. Check the run's permission mode (get_effective_run_config).",
                result.TurnCount);
            return;
        }

        // Terminal DB write uses CancellationToken.None so the task status
        // is never left as 'running' because of a cancel that arrived
        // after the Claude run already succeeded.
        // Standalone tasks gate on review; planning children go straight to Done
        // so the sequential chain (which advances on terminal states) is unaffected.
        // Planning parents (PlanningPhase != None) are containers, not reviewable work.
        var finishedAt = DateTime.UtcNow;
        using (var ctx = _dbFactory.CreateDbContext())
        {
            await new TaskRepository(ctx).SetRoadblockCountAsync(task.Id, result.Blocks.Count, CancellationToken.None);
        }
        var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks);
        bool isStandalone = task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None;

        List<TaskEntity> pendingChildren = new();
        if (isStandalone)
        {
            using var ctx = _dbFactory.CreateDbContext();
            var children = await new TaskRepository(ctx).GetChildrenAsync(task.Id, CancellationToken.None);
            pendingChildren = children
                .Where(c => c.Status is TaskStatus.Idle or TaskStatus.Queued)
                .ToList();
        }

        if (isStandalone && pendingChildren.Count > 0)
        {
            var transition = await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
            if (!transition.Ok)
                _logger.LogWarning("SubmitForChildrenAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason);
            foreach (var child in pendingChildren)
                await _state.EnqueueAsync(child.Id, CancellationToken.None);
            await _broadcaster.WorkerLog(
                $"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
                WorkerLogLevel.Success, DateTime.UtcNow);
            await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
        }
        else if (isStandalone)
        {
            var transition = await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
            if (!transition.Ok)
                _logger.LogWarning("SubmitForReviewAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason);
            await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
            await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
        }
        else
        {
            var transition = await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
            if (!transition.Ok)
                _logger.LogWarning("CompleteAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason);
            await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
            await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
        }
        _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
            task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
    }

    private async Task MarkFailed(
        string taskId, int taskNumber, string taskTitle, string slot, string? error, int turnCount = 0,
        int? maxTurns = null, string failureReason = "error")
    {
        // Terminal write for a failed task: never cancel (the status must always
        // be persisted) and never throw (a logging failure must not mask the error).
        try
        {
            var finishedAt = DateTime.UtcNow;
            var transition = await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None,
                failureReason, turnCount > 0 ? turnCount : null, maxTurns);
            if (!transition.Ok)
                _logger.LogWarning("FailAsync rejected for task {TaskId}: {Reason}", taskId, transition.Reason);
            await _broadcaster.WorkerLog($"Finished #{taskNumber} \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
            await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
            _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to mark task {TaskId} as failed", taskId);
        }
    }

    // Tools whose denial means the run could not change files. Everything else (Bash, WebFetch,
    // an MCP tool, …) can be denied in a perfectly successful run, so it must not mark one failed.
    private static readonly HashSet<string> WriteTools = new(StringComparer.OrdinalIgnoreCase)
    {
        "Edit", "Write", "MultiEdit", "NotebookEdit", "Update",
    };

    internal static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName);

    // The CLI has no dedicated terminal_reason for a session/account usage-limit stop — it comes
    // back as a plain "api_error" with the provider's own text (session/rate limit + reset time)
    // riding in the result/error text (see ClaudeProcess.RunAsync). Matched against the rendered
    // failure message (BuildFailureMarkdown's output), never against Status alone.
    private static readonly Regex UsageLimitPattern = new(
        "usage limit|session limit|rate.?limit", RegexOptions.IgnoreCase | RegexOptions.Compiled);

    internal static bool IsUsageLimitMessage(string? text) => !string.IsNullOrEmpty(text) && UsageLimitPattern.IsMatch(text);

    /// <summary>Classifies the CLI's raw <c>terminal_reason</c> (plus, for "api_error", the
    /// rendered failure message) into the small, MCP-facing enum
    /// (<c>max_turns|timeout|usage_limit|error</c>) get_task/batch_get_tasks report as failureReason.
    /// "cancelled" is set explicitly at the call sites that know it (there's no CLI signal for it).</summary>
    internal static string ClassifyFailureReason(string? terminalReason, string? messageText = null) => terminalReason switch
    {
        "max_turns" => "max_turns",
        "timeout" => "timeout",
        "api_error" when IsUsageLimitMessage(messageText) => "usage_limit",
        _ => "error",
    };

    private string BuildRunMcpConfigJson(string token)
    {
        var payload = new
        {
            mcpServers = new
            {
                claudedo_run = new
                {
                    type = "http",
                    url = $"http://127.0.0.1:{_cfg.SignalRPort}/mcp",
                    headers = new Dictionary<string, string>
                    {
                        ["Authorization"] = $"Bearer {token}",
                    },
                },
            },
        };
        return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
    }

    private async Task<ClaudeRunConfig> ResolveConfigAsync(
        TaskEntity task, ListEntity list, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
    {
        AppSettingsEntity global;
        using (var ctx = _dbFactory.CreateDbContext())
        {
            var settingsRepo = new AppSettingsRepository(ctx);
            global = await settingsRepo.GetAsync(ct);
        }

        var systemFile = PromptFiles.ReadOrDefault(PromptKind.System);

        // Improvement children (filed via SuggestImprovement; CreatedBy == ParentTaskId) get a
        // narrow follow-up prompt so they stay tightly scoped instead of doing "too much".
        var isImprovementChild = task.ParentTaskId is not null && task.CreatedBy == task.ParentTaskId;
        var improvementPrompt = isImprovementChild ? PromptFiles.ReadOrDefault(PromptKind.ImprovementChild) : null;

        var findingsPointer = BuildFindingsPointer(list.WorkingDir);

        var instructions = MergeInstructions(
            systemFile, improvementPrompt, global.DefaultClaudeInstructions, findingsPointer,
            listConfig?.SystemPrompt, task.SystemPrompt);

        var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
        var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);

        // Model/max-turns/effort/agent-path resolution is shared with get_effective_run_config
        // so the two can never report different numbers for the same task.
        var effective = EffectiveRunConfigResolver.Resolve(task, listConfig, global, systemFile, improvementPrompt);
        if (effective.MaxTurnsClamped)
        {
            _logger.LogWarning(
                "Task {TaskId}: max turns clamped to ceiling (requested={Requested}, effective={Effective}, ceiling={Ceiling})",
                task.Id, effective.RequestedMaxTurns, effective.MaxTurns, global.MaxTurnsCeiling);
        }

        return new ClaudeRunConfig(
            Model: effective.Model,
            SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
            AgentPath: effective.AgentPath,
            ResumeSessionId: resumeSessionId,
            MaxTurns: effective.MaxTurns,
            PermissionMode: effective.PermissionMode,
            SkillNames: skillNames,
            Effort: effective.Effort);
    }

    private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
        string taskId, IReadOnlyList<string> requestedSkills, CancellationToken ct)
    {
        if (requestedSkills.Count == 0) return requestedSkills;

        List<SessionSkillEntity> installed;
        using (var ctx = _dbFactory.CreateDbContext())
        {
            var skillRepo = new SessionSkillRepository(ctx);
            installed = (await skillRepo.ListAsync(ct)).ToList();
        }

        var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal);
        var resolved = FilterToInstalled(requestedSkills, installedNames);
        var dropped = requestedSkills.Where(n => !installedNames.Contains(n)).ToList();
        if (dropped.Count > 0)
        {
            _logger.LogWarning(
                "Task {TaskId}: dropped unknown session skill(s) not found in registry: {SkillNames}",
                taskId, string.Join(", ", dropped));
        }

        return resolved;
    }

    /// <summary>Shared with get_effective_run_config so reported skill names match what a run
    /// would actually filter down to.</summary>
    internal static IReadOnlyList<string> FilterToInstalled(
        IReadOnlyList<string> requestedSkills, IReadOnlySet<string> installedNames)
        => requestedSkills.Where(installedNames.Contains).ToList();

    internal static IReadOnlyList<string> UnionSkillNames(params string?[] jsonArrays)
    {
        var names = new List<string>();
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var json in jsonArrays)
        {
            if (string.IsNullOrWhiteSpace(json)) continue;

            string[]? parsed;
            try
            {
                parsed = JsonSerializer.Deserialize<string[]>(json);
            }
            catch (JsonException)
            {
                continue;
            }
            if (parsed is null) continue;

            foreach (var name in parsed)
            {
                if (!string.IsNullOrWhiteSpace(name) && seen.Add(name))
                    names.Add(name);
            }
        }
        return names;
    }

    internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault, int ceiling)
        => Math.Min(taskTurns ?? listTurns ?? globalDefault, ceiling);

    /// <summary>
    /// Points the run at the project's findings index. Deliberately terse and emitted only when the
    /// index exists: it enters the prefix of every message in the run, so its cost is multiplied by
    /// the turn count. The closing nudge to record findings is what keeps the store fed — do not drop
    /// it when trimming.
    /// </summary>
    internal static string? BuildFindingsPointer(string? workingDir)
    {
        if (string.IsNullOrWhiteSpace(workingDir)) return null;
        var index = Path.Combine(workingDir, ".claudedo", "INDEX.md");
        if (!File.Exists(index)) return null;
        return $"Known traps for this project are indexed at {index}. Read that index before exploring "
             + "the code, and open only the findings that touch your task. When you hit a lasting, "
             + "non-obvious trap yourself, record it with the save_finding tool.";
    }

    public static string MergeInstructions(params string?[] parts)
    {
        var trimmed = parts
            .Where(p => !string.IsNullOrWhiteSpace(p))
            .Select(p => p!.Trim());
        return string.Join("\n\n", trimmed);
    }

    public static string? BuildFailureMarkdown(RunResult result, int? configuredMaxTurns)
    {
        switch (result.TerminalReason)
        {
            case "max_turns":
                var ofConfigured = configuredMaxTurns is int m ? $" von {m}" : "";
                return $"Turn-Budget erschöpft ({result.TurnCount}{ofConfigured} Turns). "
                    + "Erhöhe `maxTurns` am Task (`set_task_config`) und stelle den Task erneut in die Queue "
                    + "– der Worktree bleibt erhalten.";

            case "api_error":
                if (!string.IsNullOrWhiteSpace(result.ResultMarkdown)) return result.ResultMarkdown;
                if (result.Errors.Count > 0) return string.Join(" ", result.Errors);
                return result.ErrorMarkdown;

            default:
                return string.IsNullOrWhiteSpace(result.TerminalReason)
                    ? result.ErrorMarkdown
                    : $"{result.ErrorMarkdown} (terminal_reason: {result.TerminalReason})";
        }
    }

    public static string BuildRetryPrompt(string? capturedError)
    {
        var basePrompt = PromptFiles.ReadOrDefault(PromptKind.Retry);
        var isReal = !string.IsNullOrWhiteSpace(capturedError)
            && !capturedError!.StartsWith(ClaudeProcess.NoResultPrefix, StringComparison.Ordinal);
        return isReal
            ? $"{basePrompt}\n\nCaptured error from the failed run:\n\n{capturedError!.Trim()}"
            : basePrompt;
    }

    public static string? ComposeReviewResult(string? result, IReadOnlyList<string> blocks)
    {
        if (blocks.Count == 0) return result;
        var section = "⚠ Roadblocks reported during the run:\n"
            + string.Join('\n', blocks.Select(b => $"- {b}"));
        return string.IsNullOrWhiteSpace(result) ? section : $"{result}\n\n{section}";
    }
}
