using System.Globalization;
using System.Reflection;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Logging;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using System.Text.Json;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Worker.Hub;

public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);

public record AppSettingsDto(
    string DefaultClaudeInstructions,
    string DefaultModel,
    int DefaultMaxTurns,
    string DefaultPermissionMode,
    int MaxParallelExecutions,
    string WorktreeStrategy,
    string? CentralWorktreeRoot,
    bool WorktreeAutoCleanupEnabled,
    int WorktreeAutoCleanupDays,
    string? ReportExcludedPaths,
    int StandupWeekday,
    int DailyPrepMaxTasks,
    List<string>? SessionSkills = null);

public record SessionSkillDto(
    string Name,
    string Description,
    string SourceUrl,
    string PinnedRef,
    DateTimeOffset AddedAt);

public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);

public record WorktreeOverviewDto(
    string TaskId,
    string TaskTitle,
    ClaudeDo.Data.Models.TaskStatus TaskStatus,
    string ListId,
    string ListName,
    string Path,
    string BranchName,
    string BaseCommit,
    WorktreeState State,
    string? DiffStat,
    DateTime CreatedAt,
    bool PathExistsOnDisk);

public record ForceRemoveResultDto(bool Removed, string? Reason);
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record SeedResultDto(int Copied, int Skipped);

public record OnlineInboxStateDto(
    bool Enabled,
    string ApiBaseUrl,
    string Authority,
    string ClientId,
    string Scopes,
    string RedirectUri,
    bool SignedIn,
    int PollIntervalSeconds);

public record OnlineInboxConfigInput(
    bool Enabled,
    string ApiBaseUrl,
    int PollIntervalSeconds,
    string Authority,
    string ClientId,
    string Scopes,
    string RedirectUri);

public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
    private static readonly string Version =
        Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";

    private readonly QueueService _queue;
    private readonly IQueueWaker _waker;
    private readonly AgentFileService _agentService;
    private readonly DefaultAgentSeeder _seeder;
    private readonly HubBroadcaster _broadcaster;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly WorktreeMaintenanceService _wtMaintenance;
    private readonly TaskResetService _resetService;
    private readonly TaskMergeService _mergeService;
    private readonly PlanningSessionManager _planning;
    private readonly ITerminalLauncher _launcher;
    private readonly PlanningAggregator _planningAggregator;
    private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
    private readonly PlanningChainCoordinator _planningChain;
    private readonly IPrimeScheduleSignal _primeSignal;
    private readonly IPrimeRunner _primeRunner;
    private readonly ITaskStateService _state;
    private readonly IWeekReportService _report;
    private readonly IRefineRunner _refineRunner;
    private readonly WorkerConfig _cfg;
    private readonly OnlineInboxConfig _onlineInboxConfig;
    private readonly OnlineTokenStore _onlineTokenStore;
    private readonly Runner.PendingQuestionRegistry _pendingQuestions;
    private readonly LogRingBuffer? _logBuffer;
    private readonly ISessionSkillRegistry _skillRegistry;
    private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;

    public WorkerHub(
        QueueService queue,
        IQueueWaker waker,
        AgentFileService agentService,
        DefaultAgentSeeder seeder,
        HubBroadcaster broadcaster,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        WorktreeMaintenanceService wtMaintenance,
        TaskResetService resetService,
        TaskMergeService mergeService,
        PlanningSessionManager planning,
        ITerminalLauncher launcher,
        PlanningAggregator planningAggregator,
        PlanningMergeOrchestrator planningMergeOrchestrator,
        PlanningChainCoordinator planningChain,
        IPrimeScheduleSignal primeSignal,
        IPrimeRunner primeRunner,
        ITaskStateService state,
        IWeekReportService report,
        IRefineRunner refineRunner,
        WorkerConfig cfg,
        OnlineInboxConfig onlineInboxConfig,
        OnlineTokenStore onlineTokenStore,
        Runner.PendingQuestionRegistry pendingQuestions,
        ISessionSkillRegistry skillRegistry,
        LogRingBuffer? logBuffer = null,
        IInteractiveLaunchSpecService? interactiveLaunchSpec = null)
    {
        _queue = queue;
        _waker = waker;
        _agentService = agentService;
        _seeder = seeder;
        _broadcaster = broadcaster;
        _dbFactory = dbFactory;
        _wtMaintenance = wtMaintenance;
        _resetService = resetService;
        _mergeService = mergeService;
        _planning = planning;
        _launcher = launcher;
        _planningAggregator = planningAggregator;
        _planningMergeOrchestrator = planningMergeOrchestrator;
        _planningChain = planningChain;
        _primeSignal = primeSignal;
        _primeRunner = primeRunner;
        _state = state;
        _report = report;
        _refineRunner = refineRunner;
        _cfg = cfg;
        _onlineInboxConfig = onlineInboxConfig;
        _onlineTokenStore = onlineTokenStore;
        _pendingQuestions = pendingQuestions;
        _skillRegistry = skillRegistry;
        _logBuffer = logBuffer;
        _interactiveLaunchSpec = interactiveLaunchSpec;
    }

    // Persistence boundary for the session_skills JSON-array columns (task/list/global).
    // A null/empty selection persists as null so "inherit / none" stays clean, and the
    // shape matches TaskRunner.UnionSkillNames's expected JSON string array.
    private static string? SkillsToJson(List<string>? names) =>
        names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names);

    private static List<string>? SkillsFromJson(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return null;
        try { return JsonSerializer.Deserialize<List<string>>(json); }
        catch (JsonException) { return null; }
    }

    /// <summary>Deliver the user's answer to a question a running task raised via AskUser.
    /// Returns false if no matching question is still pending (already answered or timed out).</summary>
    public bool AnswerTaskQuestion(string taskId, string questionId, string answer) =>
        _pendingQuestions.TryAnswer(taskId, questionId, answer ?? string.Empty);

    /// <summary>The question a running task is currently blocked on, if any (for UI re-attach).</summary>
    public PendingQuestionDto? GetPendingQuestion(string taskId)
    {
        var q = _pendingQuestions.Get(taskId);
        return q is null ? null : new PendingQuestionDto(q.TaskId, q.QuestionId, q.Question);
    }

    /// <summary>Recent worker log records (last 30 min, all levels) for the Log Visualizer overlay.</summary>
    public IReadOnlyList<WorkerLogRecord> GetRecentLogs() =>
        _logBuffer?.Snapshot() ?? Array.Empty<WorkerLogRecord>();

    // Maps the two exceptions service methods throw into client-facing HubExceptions:
    // KeyNotFoundException -> notFoundMessage, InvalidOperationException -> its own message.
    private static async Task HubGuard(Func<Task> action, string notFoundMessage = "task not found")
    {
        try { await action(); }
        catch (KeyNotFoundException) { throw new HubException(notFoundMessage); }
        catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
    }

    private static async Task<T> HubGuard<T>(Func<Task<T>> action, string notFoundMessage = "task not found")
    {
        try { return await action(); }
        catch (KeyNotFoundException) { throw new HubException(notFoundMessage); }
        catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
    }

    public async Task QueuePlanningSubtasksAsync(string parentTaskId)
    {
        try
        {
            await _planningChain.QueuePlanAsync(parentTaskId, Context.ConnectionAborted);
        }
        catch (InvalidOperationException ex)
        {
            throw new HubException(ex.Message);
        }

        await using var ctx = await _dbFactory.CreateDbContextAsync();
        var childIds = await ctx.Tasks
            .Where(t => t.ParentTaskId == parentTaskId)
            .Select(t => t.Id)
            .ToListAsync();
        await _broadcaster.TaskUpdated(parentTaskId);
        foreach (var id in childIds)
            await _broadcaster.TaskUpdated(id);
    }

    public string Ping() => $"pong v{Version}";

    public IReadOnlyList<ActiveTaskDto> GetActive()
    {
        return _queue.GetActive()
            .Select(a => new ActiveTaskDto(a.slot, a.taskId, a.startedAt))
            .ToList();
    }

    public async Task RunNow(string taskId)
    {
        try
        {
            await _queue.RunNow(taskId);
        }
        catch (InvalidOperationException)
        {
            throw new HubException("override slot busy");
        }
        catch (KeyNotFoundException)
        {
            throw new HubException("task not found");
        }
    }

    public Task<string> ContinueTask(string taskId, string followUpPrompt)
        => HubGuard(() => _queue.ContinueTask(taskId, followUpPrompt));

    public Task ResetTask(string taskId)
        => HubGuard(() => _resetService.ResetAsync(taskId, CancellationToken.None));

    public bool CancelTask(string taskId) => _queue.CancelTask(taskId);

    public void WakeQueue() => _waker.Wake();

    public async Task<List<AgentInfo>> GetAgents() => await _agentService.ScanAsync();

    public async Task RefreshAgents() => await _agentService.ScanAsync();

    public async Task<SeedResultDto> RestoreDefaultAgents()
    {
        var result = await _seeder.SeedMissingAsync();
        return new SeedResultDto(result.Copied, result.Skipped);
    }

    public async Task<AppSettingsDto> GetAppSettings()
    {
        using var ctx = _dbFactory.CreateDbContext();
        var row = await new AppSettingsRepository(ctx).GetAsync();
        return new AppSettingsDto(
            row.DefaultClaudeInstructions,
            row.DefaultModel,
            row.DefaultMaxTurns,
            row.DefaultPermissionMode,
            row.MaxParallelExecutions,
            row.WorktreeStrategy,
            row.CentralWorktreeRoot,
            row.WorktreeAutoCleanupEnabled,
            row.WorktreeAutoCleanupDays,
            row.ReportExcludedPaths,
            row.StandupWeekday,
            row.DailyPrepMaxTasks,
            SkillsFromJson(row.SessionSkills));
    }

    public async Task UpdateAppSettings(AppSettingsDto dto)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new AppSettingsRepository(ctx);
        await repo.UpdateAsync(new AppSettingsEntity
        {
            Id = AppSettingsEntity.SingletonId,
            DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "",
            DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias,
            DefaultMaxTurns = dto.DefaultMaxTurns,
            DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode,
            MaxParallelExecutions = dto.MaxParallelExecutions,
            WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
            CentralWorktreeRoot = dto.CentralWorktreeRoot,
            WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled,
            WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays,
            ReportExcludedPaths = dto.ReportExcludedPaths,
            StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
            DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
            SessionSkills = SkillsToJson(dto.SessionSkills),
        });
    }

    public async Task<List<SessionSkillDto>> GetSessionSkills()
    {
        var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
        return rows.Select(r => new SessionSkillDto(r.Name, r.Description, r.SourceUrl, r.PinnedRef, r.AddedAt)).ToList();
    }

    public Task<List<string>> InstallSessionSkill(string url) => HubGuard(async () =>
    {
        var installed = await _skillRegistry.InstallAsync(url, Context.ConnectionAborted);
        return installed.ToList();
    });

    public Task UpdateSessionSkill(string sourceUrl) => HubGuard(
        () => _skillRegistry.UpdateAsync(sourceUrl, Context.ConnectionAborted));

    public Task RemoveSessionSkill(string sourceUrl) => HubGuard(
        () => _skillRegistry.RemoveAsync(sourceUrl, Context.ConnectionAborted));

    public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
    {
        var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
        foreach (var id in result.RemovedTaskIds)
            await _broadcaster.WorktreeUpdated(id);
        return new WorktreeCleanupDto(result.Removed);
    }

    public async Task<WorktreeResetDto> ResetAllWorktrees()
    {
        var result = await _wtMaintenance.ResetAllAsync();
        foreach (var id in result.RemovedTaskIds)
            await _broadcaster.WorktreeUpdated(id);
        return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
    }

    public async Task<List<WorktreeOverviewDto>> GetWorktreesOverview(string? listId)
    {
        var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
        return rows.Select(r => new WorktreeOverviewDto(
            r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
            r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
    }

    public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new WorktreeRepository(ctx);
        var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
        if (existing is null) throw new HubException("worktree not found");

        // Allowed transitions: Active -> Merged | Discarded | Kept. Terminal states are final.
        if (existing.State == newState) return true;
        if (existing.State != WorktreeState.Active || newState == WorktreeState.Active)
            throw new HubException($"invalid worktree state transition {existing.State} -> {newState}");

        await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
        await _broadcaster.WorktreeUpdated(taskId);
        return true;
    }

    public async Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId)
    {
        var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted);
        if (result.Removed)
            await _broadcaster.WorktreeUpdated(taskId);
        return new ForceRemoveResultDto(result.Removed, result.Reason);
    }

    public Task<MergeResultDto> MergeTask(
        string taskId, string targetBranch, bool removeWorktree, string commitMessage)
        => HubGuard(async () =>
        {
            var r = await _mergeService.MergeAsync(
                taskId,
                targetBranch ?? "",
                removeWorktree,
                string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage,
                CancellationToken.None);
            return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
        });

    public Task<MergeTargetsDto> GetMergeTargets(string taskId)
        => HubGuard(async () =>
        {
            var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
            return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
        });

    public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
        => HubGuard(async () =>
        {
            var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
            return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
        });

    public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
        => HubGuard(async () =>
        {
            var r = await _mergeService.MergeAsync(
                taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
                leaveConflictsInTree: true, CancellationToken.None);
            if (r.Status == TaskMergeService.StatusBlocked)
                throw new HubException(r.ErrorMessage ?? "merge blocked");
            return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
        });

    public Task<MergeConflictDocumentsDto> GetMergeConflictDocuments(string taskId)
        => HubGuard(async () =>
        {
            var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None);
            return new MergeConflictDocumentsDto(
                c.TaskId,
                c.Files.Select(f => new ConflictDocumentDto(
                    f.Path, f.IsBinary,
                    f.Segments.Select(s => new MergeSegmentDto(
                        s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList());
        });

    public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
        => HubGuard(() => _mergeService.WriteResolutionAsync(
            taskId, path, resolvedContent ?? "", CancellationToken.None));

    public Task<MergeResultDto> ContinueConflictMerge(string taskId)
        => HubGuard(async () =>
        {
            var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
            if (r.Status == TaskMergeService.StatusBlocked)
                throw new HubException(r.ErrorMessage ?? "continue failed");
            return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
        });

    public Task AbortConflictMerge(string taskId)
        => HubGuard(async () =>
        {
            var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
            if (r.Status == TaskMergeService.StatusBlocked)
                throw new HubException(r.ErrorMessage ?? "abort failed");
        });

    public async Task UpdateList(UpdateListDto dto)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new ListRepository(ctx);
        var entity = await repo.GetByIdAsync(dto.Id);
        if (entity is null) throw new HubException("list not found");

        entity.Name = dto.Name;
        entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
        entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
        await repo.UpdateAsync(entity);

        await _broadcaster.ListUpdated(dto.Id);
    }

    public async Task UpdateListConfig(UpdateListConfigDto dto)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new ListRepository(ctx);

        var model = dto.Model.NullIfBlank();
        var systemPrompt = dto.SystemPrompt.NullIfBlank();
        var agentPath = dto.AgentPath.NullIfBlank();
        var sessionSkills = SkillsToJson(dto.SessionSkills);

        if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
        {
            await repo.DeleteConfigAsync(dto.ListId);
        }
        else
        {
            await repo.SetConfigAsync(new ListConfigEntity
            {
                ListId = dto.ListId,
                Model = model,
                SystemPrompt = systemPrompt,
                AgentPath = agentPath,
                MaxTurns = dto.MaxTurns,
                SessionSkills = sessionSkills,
            });
        }

        await _broadcaster.ListUpdated(dto.ListId);
    }

    public async Task<ListConfigDto?> GetListConfig(string listId)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new ListRepository(ctx);
        var config = await repo.GetConfigAsync(listId);
        if (config is null) return null;
        return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
    }

    public async Task SetTaskStatus(string taskId, string status)
    {
        if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
            throw new HubException($"unknown status: {status}");
        // Queueing goes through the gated transition so draft subtasks can't be queued;
        // other statuses keep the unconditional "set status freely" affordance.
        var result = parsed == TaskStatus.Queued
            ? await _state.EnqueueAsync(taskId, Context.ConnectionAborted)
            : await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted);
        if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
    }

    public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
        => HubGuard(async () =>
        {
            bool hasChildren;
            await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
                hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, CancellationToken.None);

            if (hasChildren)
            {
                await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
                return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty<string>(), null);
            }

            var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
            if (r.Status == TaskMergeService.StatusBlocked)
                throw new HubException(r.ErrorMessage ?? "approve failed");
            return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
        });

    public async Task RejectReviewToQueue(string taskId, string feedback)
    {
        var result = await _state.RejectToQueueAsync(taskId, feedback, Context.ConnectionAborted);
        if (!result.Ok) throw new HubException(result.Reason ?? "reject failed");
    }

    public async Task RejectReviewToIdle(string taskId)
    {
        var result = await _state.RejectToIdleAsync(taskId, Context.ConnectionAborted);
        if (!result.Ok) throw new HubException(result.Reason ?? "park failed");
    }

    public async Task CancelReview(string taskId)
    {
        var result = await _state.CancelAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
        if (!result.Ok) throw new HubException(result.Reason ?? "cancel failed");
    }

    public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new TaskRepository(ctx);
        await repo.UpdateAgentSettingsAsync(
            dto.TaskId,
            dto.Model.NullIfBlank(),
            dto.SystemPrompt.NullIfBlank(),
            dto.AgentPath.NullIfBlank(),
            dto.MaxTurns,
            SkillsToJson(dto.SessionSkills));

        await _broadcaster.TaskUpdated(dto.TaskId);
    }

    public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
    {
        var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
        try
        {
            await _launcher.LaunchPlanningStartAsync(ctx, Context.ConnectionAborted);
        }
        catch (TerminalLaunchException)
        {
            // Launch failed before any children could be created; force-cleanup is safe.
            await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
            throw;
        }
        await Clients.All.SendAsync("TaskUpdated", taskId);
        return ctx;
    }

    public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
    {
        var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
        await _launcher.LaunchPlanningResumeAsync(ctx, Context.ConnectionAborted);
        return ctx;
    }

    // Picks up a task's Claude session in a real terminal window (--resume) so the user can
    // drive it by hand. Only for tasks the worker isn't actively running, with a persisted
    // session id and a live worktree.
    public Task ResumeTaskInTerminal(string taskId) => HubGuard(async () =>
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync();

        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
            ?? throw new KeyNotFoundException();
        if (task.Status is TaskStatus.Running or TaskStatus.Queued)
            throw new InvalidOperationException("Can't pick up a running or queued task — interrupt it first.");

        var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, Context.ConnectionAborted);
        if (run?.SessionId is not { Length: > 0 } sessionId)
            throw new InvalidOperationException("This task has no resumable Claude session yet.");

        var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
        if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
            throw new InvalidOperationException("This task has no active worktree to resume in.");
        if (!Directory.Exists(worktree.Path))
            throw new InvalidOperationException("The task's worktree directory no longer exists.");

        try
        {
            await _launcher.LaunchResumeAsync(worktree.Path, sessionId, Context.ConnectionAborted);
        }
        catch (TerminalLaunchException ex)
        {
            throw new InvalidOperationException(ex.Message);
        }
    });

    // Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an
    // interactive Claude session in a task's worktree -- same worktree prep as an
    // autonomous run (session-skills seeding, run env vars), --resume if the task has a
    // persisted session or a fresh-start spec otherwise. Guards mirror ResumeTaskInTerminal.
    public Task<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
    {
        if (_interactiveLaunchSpec is null)
            throw new InvalidOperationException("Interactive launch spec service is not configured.");
        return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
    });

    // Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
    // no task, no worktree, no session-skills seeding.
    public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
    {
        if (_interactiveLaunchSpec is null)
            throw new InvalidOperationException("Interactive launch spec service is not configured.");
        return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
    });

    public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
    {
        var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
        if (outcome.Result == DiscardPlanningResult.Discarded)
            await Clients.All.SendAsync("TaskUpdated", taskId);
        return outcome;
    }

    public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
    {
        var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
        await Clients.All.SendAsync("TaskUpdated", taskId);
        return count;
    }

    public Task<int> GetPendingDraftCountAsync(string taskId)
        => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);

    public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregate(string planningTaskId)
        => HubGuard<IReadOnlyList<SubtaskDiffDto>>(async () =>
        {
            var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None);
            return diffs.Select(d => new SubtaskDiffDto(
                d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList();
        }, "planning task not found");

    public Task<CombinedDiffResultDto> BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch)
        => HubGuard(async () =>
        {
            var result = await _planningAggregator.BuildIntegrationBranchAsync(
                planningTaskId, targetBranch ?? "", CancellationToken.None);
            return result switch
            {
                CombinedDiffResult.Ok ok => new CombinedDiffResultDto(
                    true, ok.Value.IntegrationBranch, ok.Value.UnifiedDiff, null, null),
                CombinedDiffResult.Failed f => new CombinedDiffResultDto(
                    false, null, null, f.Value.FirstConflictSubtaskId, f.Value.ConflictedFiles),
                _ => throw new InvalidOperationException("unknown result type"),
            };
        }, "planning task not found");

    public async Task ContinuePlanningMerge(string planningTaskId)
    {
        try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); }
        catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
    }

    public async Task AbortPlanningMerge(string planningTaskId)
    {
        try { await _planningMergeOrchestrator.AbortAsync(planningTaskId, CancellationToken.None); }
        catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
    }

    public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
    {
        using var ctx = _dbFactory.CreateDbContext();
        var rows = await new PrimeScheduleRepository(ctx).ListAsync();
        return rows.Select(e => new PrimeScheduleDto(
            e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
    }

    public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var repo = new PrimeScheduleRepository(ctx);
        var existing = await repo.GetAsync(dto.Id);
        var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
        {
            Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
            Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
            TimeOfDay = dto.TimeOfDay,
            Enabled = dto.Enabled,
            PromptOverride = dto.PromptOverride,
            CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
            LastRunAt = existing?.LastRunAt,
        };
        await repo.UpsertAsync(entity);
        _primeSignal.Signal();
        return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
            entity.Enabled, entity.LastRunAt, entity.PromptOverride);
    }

    public async Task DeletePrimeSchedule(Guid id)
    {
        using var ctx = _dbFactory.CreateDbContext();
        await new PrimeScheduleRepository(ctx).DeleteAsync(id);
        _primeSignal.Signal();
    }

    public Task RefineTask(string taskId)
    {
        _ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
        return Task.CompletedTask;
    }

    public async Task<bool> RunDailyPrepNow()
    {
        var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
        var firedAt = DateTimeOffset.Now;
        var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted);
        await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt);
        return outcome.Success;
    }

    private static DateOnly Day(string iso) => DateOnly.ParseExact(iso, "yyyy-MM-dd", CultureInfo.InvariantCulture);

    public Task<string?> GetWeekReport(string startIso, string endIso) =>
        _report.GetStoredAsync(Day(startIso), Day(endIso));

    public Task<string> GenerateWeekReport(string startIso, string endIso) =>
        HubGuard(() => _report.GenerateAsync(Day(startIso), Day(endIso)), "report generation failed");

    public async Task<List<DailyNoteDto>> GetDailyNotes(string dayIso)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
        return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList();
    }

    public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
        return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder);
    }

    public async Task UpdateDailyNote(string id, string text)
    {
        using var ctx = _dbFactory.CreateDbContext();
        await new DailyNoteRepository(ctx).UpdateAsync(id, text);
    }

    public async Task DeleteDailyNote(string id)
    {
        using var ctx = _dbFactory.CreateDbContext();
        await new DailyNoteRepository(ctx).DeleteAsync(id);
    }

    public Task<string> GetLastPrepLog()
    {
        var path = DailyPrepPrompt.LogPath();
        if (!File.Exists(path)) return Task.FromResult(string.Empty);

        const int maxBytes = 256 * 1024;
        var bytes = File.ReadAllBytes(path);
        var text = bytes.Length <= maxBytes
            ? System.Text.Encoding.UTF8.GetString(bytes)
            : System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes);
        return Task.FromResult(text);
    }

    public async Task<int> ClearMyDay()
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync();
        var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync();
        if (ids.Count == 0) return 0;

        await ctx.Tasks.Where(t => t.IsMyDay)
            .ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false));

        foreach (var id in ids)
            await _broadcaster.TaskUpdated(id);

        return ids.Count;
    }

#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here.
    public OnlineInboxStateDto GetOnlineInboxState()
    {
        var signedIn = _onlineTokenStore.Read() is not null;
        return new OnlineInboxStateDto(
            _onlineInboxConfig.Enabled,
            _onlineInboxConfig.ApiBaseUrl,
            _onlineInboxConfig.Zitadel.Authority,
            _onlineInboxConfig.Zitadel.ClientId,
            _onlineInboxConfig.Zitadel.Scopes,
            _onlineInboxConfig.RedirectUri,
            signedIn,
            _onlineInboxConfig.PollIntervalSeconds);
    }

    public void SetOnlineInboxConfig(OnlineInboxConfigInput input)
    {
        _onlineInboxConfig.Enabled = input.Enabled;
        _onlineInboxConfig.ApiBaseUrl = input.ApiBaseUrl ?? "";
        _onlineInboxConfig.PollIntervalSeconds = input.PollIntervalSeconds;
        _onlineInboxConfig.RedirectUri = input.RedirectUri ?? "http://localhost:8765/callback";
        _onlineInboxConfig.Zitadel.Authority = input.Authority ?? "";
        _onlineInboxConfig.Zitadel.ClientId = input.ClientId ?? "";
        _onlineInboxConfig.Zitadel.Scopes = input.Scopes ?? "openid offline_access";
        _cfg.SaveOnlineInbox();
    }

    public void SetOnlineInboxAuth(string refreshToken)
    {
        _onlineTokenStore.Save(refreshToken);
    }

    public void ClearOnlineInboxAuth()
    {
        _onlineTokenStore.Clear();
    }
#pragma warning restore CA1416
}
