using System.ComponentModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.External;

public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record StatusValueDto(string Status, string Meaning);

public sealed record TaskDto(
    string Id,
    string ListId,
    string Title,
    string? Description,
    string Status,
    string? Result,
    string? CreatedBy,
    DateTime CreatedAt,
    DateTime? StartedAt,
    DateTime? FinishedAt,
    bool IsMyDay,
    int SortOrder);

public sealed record WorktreeInfoDto(
    string Path, string Branch, string HeadCommit, string BaseCommit,
    int Ahead, int Behind, bool IsDirty);

public sealed record TaskDiffDto(
    string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);

public sealed record MergeTaskResultDto(
    bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts);

public sealed record WorktreeListItemDto(
    string? TaskId, string Path, string Branch,
    string HeadCommit, bool IsDirty, bool MergedIntoMain);

public sealed record CleanupWorktreeResult(
    bool Removed, string WorktreePath, bool BranchDeleted);

public sealed record DailyPrepCandidateDto(
    string Id, string ListId, string ListName, string Title, string? Description,
    bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);

public sealed record DailyPrepDataDto(
    int MaxTasks,
    IReadOnlyList<DailyPrepCandidateDto> Candidates,
    IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);

[McpServerToolType]
public sealed class ExternalMcpService
{
    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;
    private readonly QueueService _queue;
    private readonly HubBroadcaster _broadcaster;
    private readonly ITaskStateService _state;
    private readonly GitService _git;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly WorktreeMaintenanceService _maintenance;
    private readonly TaskMergeService _merge;

    public ExternalMcpService(
        TaskRepository tasks,
        ListRepository lists,
        QueueService queue,
        HubBroadcaster broadcaster,
        ITaskStateService state,
        GitService git,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        WorktreeMaintenanceService maintenance,
        TaskMergeService merge)
    {
        _tasks = tasks;
        _lists = lists;
        _queue = queue;
        _broadcaster = broadcaster;
        _state = state;
        _git = git;
        _dbFactory = dbFactory;
        _maintenance = maintenance;
        _merge = merge;
    }

    [McpServerTool, Description("List all task lists available in ClaudeDo.")]
    public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
    {
        var lists = await _lists.GetAllAsync(cancellationToken);
        return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
    }

    [McpServerTool, Description(
        "List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
        "Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
    public async Task<IReadOnlyList<TaskDto>> ListTasks(
        string listId,
        string? createdBy,
        string? status,
        CancellationToken cancellationToken)
    {
        TaskStatus? statusFilter = null;
        if (!string.IsNullOrWhiteSpace(status))
        {
            if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
                throw new InvalidOperationException(
                    $"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.");
            statusFilter = parsed;
        }

        var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
        IEnumerable<TaskEntity> query = tasks;
        if (createdBy is not null)
            query = query.Where(t => t.CreatedBy == createdBy);
        if (statusFilter is not null)
            query = query.Where(t => t.Status == statusFilter);

        return query.Select(ToDto).ToList();
    }

    [McpServerTool, Description(
        "Get a single task by id, including its current status and result. " +
        "Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
        "A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
        "Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
    public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
    {
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        return ToDto(task);
    }

    [McpServerTool, Description(
        "Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
        "Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
        "'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
        "Leave model null to inherit the list/global default.")]
    public async Task<TaskDto> AddTask(
        string listId,
        string title,
        string? description = null,
        string? createdBy = null,
        bool queueImmediately = false,
        string? model = null,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(listId))
            throw new InvalidOperationException("listId is required.");
        if (string.IsNullOrWhiteSpace(title))
            throw new InvalidOperationException("title is required.");

        var list = await _lists.GetByIdAsync(listId, cancellationToken)
            ?? throw new InvalidOperationException($"List {listId} not found.");

        var entity = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = title,
            Description = description,
            Status = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            CommitType = list.DefaultCommitType,
            CreatedBy = createdBy.NullIfBlank() ?? "mcp",
            Model = ModelRegistry.NormalizeAlias(model),
        };
        await _tasks.AddAsync(entity, cancellationToken);

        if (queueImmediately)
        {
            var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
            if (!enqueue.Ok)
                throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
            entity.Status = TaskStatus.Queued;
        }

        await _broadcaster.TaskUpdated(entity.Id);
        return ToDto(entity);
    }

    [McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
    public async Task<TaskDto> UpdateTask(
        string taskId,
        string? title,
        string? description,
        string? commitType,
        CancellationToken cancellationToken)
    {
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot update a running task. Cancel it first.");

        if (title is not null) task.Title = title;
        if (description is not null) task.Description = description;
        if (commitType is not null) task.CommitType = commitType;
        await _tasks.UpdateAsync(task, cancellationToken);

        var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
        await _broadcaster.TaskUpdated(taskId);
        return ToDto(reload);
    }

    [McpServerTool, Description(
        "Append a subtask (step) to a task. orderNum defaults to the end. " +
        "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
    public async Task<TaskDto> AddSubtask(
        string taskId,
        string title,
        int? orderNum,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new InvalidOperationException("title is required.");

        await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
        var tasks = new TaskRepository(ctx);
        var subtasks = new SubtaskRepository(ctx);

        var task = await tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");

        var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
        var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);

        await subtasks.AddAsync(new SubtaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            TaskId = taskId,
            Title = title.Trim(),
            Completed = false,
            OrderNum = order,
            CreatedAt = DateTime.UtcNow,
        }, cancellationToken);

        await _broadcaster.TaskUpdated(taskId);
        return ToDto(task);
    }

    [McpServerTool, Description(
        "Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
        "use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
        "Settable: Idle (reset to editable), Queued (enqueue for execution). " +
        "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
    public async Task<TaskDto> UpdateTaskStatus(
        string taskId,
        string status,
        CancellationToken cancellationToken)
    {
        if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
            throw new InvalidOperationException(
                $"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");

        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        switch (target)
        {
            case TaskStatus.Idle:
                await _tasks.ResetToManualAsync(taskId, cancellationToken);
                await _broadcaster.TaskUpdated(taskId);
                break;

            case TaskStatus.Queued:
                var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
                if (!enqueueResult.Ok)
                    throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
                break;

            default:
                throw new InvalidOperationException(
                    $"Status '{target}' is not settable externally. Use run_task_now or cancel_task.");
        }

        var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
        return ToDto(reload);
    }

    [McpServerTool, Description(
        "Review a task that is WaitingForReview. " +
        "decision='approve' → Done. " +
        "decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
        "decision='reject_park' → Idle for manual editing (feedback ignored). " +
        "decision='cancel' → Cancelled. " +
        "Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
    public async Task<TaskDto> ReviewTask(
        string taskId,
        string decision,
        string? feedback,
        CancellationToken cancellationToken)
    {
        _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        TransitionResult result = decision.Trim().ToLowerInvariant() switch
        {
            "approve"      => await _state.ApproveReviewAsync(taskId, cancellationToken),
            "reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
            "reject_park"  => await _state.RejectToIdleAsync(taskId, cancellationToken),
            "cancel"       => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
            _ => throw new InvalidOperationException(
                $"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
        };

        if (!result.Ok)
            throw new InvalidOperationException(result.Reason ?? "Review action failed.");

        return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
    }

    [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
    public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
    {
        try
        {
            await _queue.RunNow(taskId);
        }
        catch (InvalidOperationException)
        {
            throw new InvalidOperationException("Override slot busy. Try again later.");
        }
        catch (KeyNotFoundException)
        {
            throw new InvalidOperationException($"Task {taskId} not found.");
        }
        await _broadcaster.TaskUpdated(taskId);
    }

    [McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
    public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
    {
        var cancelled = _queue.CancelTask(taskId);
        if (cancelled) await _broadcaster.TaskUpdated(taskId);
        return new CancelTaskResult(cancelled, taskId);
    }

    [McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
    public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
    {
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");

        await _tasks.DeleteAsync(taskId, cancellationToken);
        await _broadcaster.TaskUpdated(taskId);
        return new DeleteTaskResult(true, taskId);
    }

    // ── Status reference ─────────────────────────────────────────────────────

    [McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
    public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
        Task.FromResult<IReadOnlyList<StatusValueDto>>([
            new("Idle",                "Not yet queued; task is editable and will not run until enqueued."),
            new("Queued",              "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
            new("Running",             "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
            new("WaitingForReview",    "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
            new("WaitingForChildren",  "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
            new("Done",                "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
            new("Failed",              "Execution ended with an error; task can be reset to Idle or re-queued directly."),
            new("Cancelled",           "Cancelled by the user; task can be reset to Idle or re-queued directly."),
        ]);

    // ── Worktree / git tools ──────────────────────────────────────────────────

    [McpServerTool, Description(
        "Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
        "baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
        "behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
        "isDirty (has uncommitted changes in the worktree directory). " +
        "Throws if the task or its worktree does not exist.")]
    public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
    {
        var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);

        var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
            ? wt.HeadCommit
            : await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;

        var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
        var ahead  = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
        var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);

        return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty);
    }

    [McpServerTool, Description(
        "Get the diff for a task's worktree relative to its base commit. " +
        "stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
        "stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
        "files always lists the changed file paths regardless of stat mode. " +
        "totalBytes is the uncapped diff size (useful when truncated=true). " +
        "Throws if the task has no worktree or the worktree directory is missing from disk.")]
    public async Task<TaskDiffDto> GetTaskDiff(
        string taskId, bool stat = false, CancellationToken cancellationToken = default)
    {
        var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);

        if (!Directory.Exists(wt.Path))
            throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");

        const int maxBytes = 200 * 1024;

        if (stat)
        {
            var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken);
            return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
        }

        var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken);
        var files = ParseDiffFileNames(diff);

        if (diff.Length <= maxBytes)
            return new TaskDiffDto(diff, files, false, diff.Length);

        return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
    }

    [McpServerTool, Description(
        "Merge a task's worktree branch into targetBranch (default: main). " +
        "noFf=true (default): always creates a merge commit (--no-ff). " +
        "dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
        "allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
        "On success: merged=true, mergeCommit contains the new merge commit SHA. " +
        "On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
    public async Task<MergeTaskResultDto> MergeTask(
        string taskId,
        string targetBranch = "main",
        bool noFf = true,
        bool dryRun = false,
        bool allowWaitingForReview = false,
        CancellationToken cancellationToken = default)
    {
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var canMerge = task.Status == TaskStatus.Done ||
                       (allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
        if (!canMerge)
            throw new InvalidOperationException(
                $"Task must be Done to merge (current status: {task.Status}). " +
                "Pass allowWaitingForReview=true to also merge a WaitingForReview task.");

        var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);

        if (dryRun)
        {
            using var ctx = _dbFactory.CreateDbContext();
            var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
                ?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
            if (wt.State != WorktreeState.Active)
                throw new InvalidOperationException(
                    $"Worktree state must be Active to merge (current: {wt.State}).");
            return new MergeTaskResultDto(false, null, []);
        }

        var commitMessage = $"Merge task branch for: {task.Title}";
        var result = await _merge.MergeAsync(
            taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken);

        if (result.Status == TaskMergeService.StatusMerged)
        {
            string? mergeCommit = null;
            try
            {
                if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
                    mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
            }
            catch { /* mergeCommit is optional */ }
            return new MergeTaskResultDto(true, mergeCommit, []);
        }

        if (result.Status == TaskMergeService.StatusConflict)
            return new MergeTaskResultDto(false, null, result.ConflictFiles);

        throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
    }

    [McpServerTool, Description(
        "List all ClaudeDo-tracked worktrees. " +
        "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
        "isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
        "Only worktrees recorded in the ClaudeDo database are returned.")]
    public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
    {
        var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
        var results = await Task.WhenAll(rows.Select(async row =>
        {
            var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
            var headCommit = row.PathExistsOnDisk
                ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
                : "";
            return new WorktreeListItemDto(
                row.TaskId, row.Path, row.BranchName, headCommit,
                isDirty, row.State == WorktreeState.Merged);
        }));
        return results;
    }

    [McpServerTool, Description(
        "Remove a task's worktree directory and delete its git branch. " +
        "force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
        "force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
        "Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
    public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
        string taskId, bool force = false, CancellationToken cancellationToken = default)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} has no worktree.");

        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot remove worktree of a running task.");

        if (!force && Directory.Exists(wt.Path))
        {
            var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken);
            if (isDirty)
                throw new InvalidOperationException(
                    "Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
        }

        var path = wt.Path;
        var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
        return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted);
    }

    [McpServerTool, Description(
        "Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
        "The agent resumes using --resume with the session ID from the task's last run. " +
        "Runs in the override execution slot; throws if the slot is busy — try again later. " +
        "Returns a status string from the execution slot.")]
    public async Task<string> ContinueTask(
        string taskId,
        string followUpPrompt,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(followUpPrompt))
            throw new InvalidOperationException("followUpPrompt is required.");

        string result;
        try
        {
            result = await _queue.ContinueTask(taskId, followUpPrompt);
        }
        catch (InvalidOperationException)
        {
            throw new InvalidOperationException("Override slot busy. Try again later.");
        }
        catch (KeyNotFoundException)
        {
            throw new InvalidOperationException($"Task {taskId} not found.");
        }
        await _broadcaster.TaskUpdated(taskId);
        return result;
    }

    // ── Daily prep ───────────────────────────────────────────────────────────

    [McpServerTool, Description(
        "Daily prep: returns the open tasks eligible for today's MyDay selection. " +
        "candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
        "currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
        "maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
    public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);

        var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
        var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
        var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;

        var idle = await ctx.Tasks
            .AsNoTracking()
            .Include(t => t.List)
            .Where(t => t.Status == TaskStatus.Idle)
            .ToListAsync(cancellationToken);

        var currentMyDay = idle
            .Where(t => t.IsMyDay)
            .OrderBy(t => t.SortOrder)
            .Select(ToCandidate)
            .ToList();

        var candidates = idle
            .Where(t => !t.IsMyDay
                        && t.BlockedByTaskId == null
                        && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
            .OrderBy(t => t.CreatedAt)
            .Select(ToCandidate)
            .ToList();

        return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
    }

    [McpServerTool, Description(
        "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
        "(use consecutive sortOrder values to keep related tasks together). " +
        "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
        "clearing (isMyDay=false) is always allowed.")]
    public async Task<TaskDto> SetMyDay(
        string taskId,
        bool isMyDay,
        int? sortOrder,
        CancellationToken cancellationToken)
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);

        var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        if (isMyDay && !task.IsMyDay)
        {
            var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
            var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
            var openMyDay = await ctx.Tasks.CountAsync(
                t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
            if (openMyDay >= max)
                throw new InvalidOperationException(
                    $"MyDay limit {max} reached. Clear a task before adding another.");
        }

        task.IsMyDay = isMyDay;
        if (sortOrder is not null) task.SortOrder = sortOrder.Value;
        await ctx.SaveChangesAsync(cancellationToken);

        await _broadcaster.TaskUpdated(taskId);
        return ToDto(task);
    }

    private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
        t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
        t.IsStarred, t.ScheduledFor, t.CreatedAt);

    // ── Private helpers ───────────────────────────────────────────────────────

    private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
        string taskId, CancellationToken ct)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
            ?? throw new InvalidOperationException("List not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
        return (task, list, wt);
    }

    private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
    {
        try { return await _git.HasChangesAsync(path, ct); }
        catch { return false; }
    }

    // Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
    private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
    {
        try
        {
            var psi = new ProcessStartInfo("git")
            {
                UseShellExecute = false,
                RedirectStandardOutput = true,
                CreateNoWindow = true,
            };
            psi.ArgumentList.Add("-C");
            psi.ArgumentList.Add(dir);
            foreach (var a in args) psi.ArgumentList.Add(a);
            using var proc = Process.Start(psi)!;
            await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
            var stdout = await proc.StandardOutput.ReadToEndAsync();
            await proc.WaitForExitAsync(CancellationToken.None);
            ct.ThrowIfCancellationRequested();
            return proc.ExitCode == 0 ? stdout.Trim() : null;
        }
        catch (OperationCanceledException) { throw; }
        catch { return null; }
    }

    private static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
    {
        var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
        return int.TryParse(result, out var n) ? n : 0;
    }

    private static IReadOnlyList<string> ParseDiffFileNames(string diff)
    {
        var files = new List<string>();
        foreach (var line in diff.Split('\n'))
        {
            var s = line.TrimEnd('\r');
            if (s.StartsWith("+++ b/", StringComparison.Ordinal))
                files.Add(s[6..]);
        }
        return files;
    }

    private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
    {
        var files = new List<string>();
        foreach (var line in stat.Split('\n'))
        {
            var idx = line.IndexOf('|');
            if (idx > 0) files.Add(line[..idx].Trim());
        }
        return files;
    }

    private static TaskDto ToDto(TaskEntity t) => new(
        t.Id,
        t.ListId,
        t.Title,
        t.Description,
        t.Status.ToString(),
        t.Result,
        t.CreatedBy,
        t.CreatedAt,
        t.StartedAt,
        t.FinishedAt,
        t.IsMyDay,
        t.SortOrder);
}

internal static class DailyPrepFilter
{
    public static string[] ParseExcludes(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return [];
        try
        {
            var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
            return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
        }
        catch (System.Text.Json.JsonException) { return []; }
    }

    public static bool IsIncludedRepo(string? workingDir, string[] excludes)
    {
        if (string.IsNullOrWhiteSpace(workingDir)) return false;
        var norm = Normalize(workingDir);
        return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
    }

    private static string Normalize(string path) =>
        path.Trim().Replace('/', '\\').TrimEnd('\\');
}
