using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;

namespace ClaudeDo.Worker.Tests.Prime;

public class PrimeRunnerTests : IDisposable
{
    private readonly DbFixture _db = new();
    public void Dispose() => _db.Dispose();

    private sealed class FakeClock : IPrimeClock
    {
        public DateTimeOffset Now { get; set; } = new DateTimeOffset(2026, 6, 3, 8, 0, 0, TimeSpan.FromHours(2));
    }

    private sealed class FakeClaudeProcess : IClaudeProcess
    {
        private readonly TimeSpan _delay;
        private readonly int _exitCode;
        private readonly string[] _emitLines;
        private readonly string? _result;

        public IReadOnlyList<string>? LastArguments { get; private set; }
        public string? LastPrompt { get; private set; }

        public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null)
        {
            _delay = delay;
            _exitCode = exitCode;
            _emitLines = emitLines ?? [];
            _result = result;
        }

        public async Task<RunResult> RunAsync(
            IReadOnlyList<string> arguments,
            string prompt,
            string workingDirectory,
            Func<string, Task> onStdoutLine,
            CancellationToken ct)
        {
            LastArguments = arguments;
            LastPrompt = prompt;

            if (_delay > TimeSpan.Zero)
                await Task.Delay(_delay, ct);

            foreach (var line in _emitLines)
                await onStdoutLine(line);

            return new RunResult { ExitCode = _exitCode, ResultMarkdown = _result ?? (_exitCode == 0 ? "ok" : null) };
        }
    }

    private sealed class RecordingPrimeBroadcaster : IPrimeBroadcaster
    {
        public int StartedCount { get; private set; }
        public List<string> Lines { get; } = [];
        public List<bool> FinishedResults { get; } = [];

        public Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => Task.CompletedTask;

        public Task PrepStartedAsync() { StartedCount++; return Task.CompletedTask; }
        public Task PrepLineAsync(string line) { Lines.Add(line); return Task.CompletedTask; }
        public Task PrepFinishedAsync(bool success) { FinishedResults.Add(success); return Task.CompletedTask; }
    }

    private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) =>
        NewRunner(new FakeClaudeProcess(claudeDelay, exitCode), new RecordingPrimeBroadcaster());

    private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster) =>
        new PrimeRunner(
            claude,
            _db.CreateFactory(),
            new FakeClock(),
            NullLogger<PrimeRunner>.Instance,
            broadcaster);

    private static PrimeScheduleDto DefaultSchedule() =>
        new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay);

    private static PrimeScheduleDto Schedule(PrimeActionKind kind, string? promptOverride = null) =>
        new(Guid.Empty, 0, TimeSpan.Zero, true, null, promptOverride, kind);

    [Fact]
    public async Task FireAsync_returns_success_when_claude_exits_zero()
    {
        var runner = NewRunner();
        var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None);

        Assert.True(outcome.Success);
        Assert.Equal("Daily prep complete", outcome.Message);
    }

    [Fact]
    public async Task FireAsync_returns_failure_when_claude_exits_nonzero()
    {
        var runner = NewRunner(exitCode: 1);
        var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None);

        Assert.False(outcome.Success);
        Assert.Contains("exit code", outcome.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task FireAsync_returns_already_running_when_gate_held()
    {
        var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
        var schedule = DefaultSchedule();

        var first = runner.FireAsync(schedule, CancellationToken.None);
        var second = await runner.FireAsync(schedule, CancellationToken.None);

        Assert.False(second.Success);
        Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
        await first;
    }

    [Fact]
    public async Task FireAsync_streams_started_lines_and_finished()
    {
        var broadcaster = new RecordingPrimeBroadcaster();
        var claude = new FakeClaudeProcess(emitLines: ["{\"a\":1}", "{\"b\":2}"], exitCode: 0, result: "ok");
        var runner = NewRunner(claude, broadcaster);
        var schedule = DefaultSchedule();

        var outcome = await runner.FireAsync(schedule, CancellationToken.None);

        Assert.True(outcome.Success);
        Assert.Equal(1, broadcaster.StartedCount);
        Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines);
        Assert.Single(broadcaster.FinishedResults);
        Assert.True(broadcaster.FinishedResults[0]);
    }

    [Fact]
    public async Task FireAsync_writes_last_run_to_prep_log_file()
    {
        var path = DailyPrepPrompt.LogPath();
        if (File.Exists(path)) File.Delete(path);

        var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
        var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
        await runner.FireAsync(DefaultSchedule(), CancellationToken.None);

        var contents = await File.ReadAllTextAsync(path);
        Assert.Contains("lineA", contents);
        Assert.Contains("lineB", contents);

        // Truncation: a second run with different lines replaces the file.
        var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
        var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
        await runner2.FireAsync(DefaultSchedule(), CancellationToken.None);
        var after = await File.ReadAllTextAsync(path);
        Assert.DoesNotContain("lineA", after);
        Assert.Contains("lineC", after);
    }

    [Fact]
    public async Task Ping_runs_a_single_turn_with_no_mcp_servers()
    {
        var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
        var runner = NewRunner(claude, new RecordingPrimeBroadcaster());

        var outcome = await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);

        Assert.True(outcome.Success);
        Assert.Contains("--strict-mcp-config", claude.LastArguments!);
        var turnsIndex = claude.LastArguments!.ToList().IndexOf("--max-turns");
        Assert.Equal("1", claude.LastArguments[turnsIndex + 1]);
        Assert.DoesNotContain(DailyPrepPrompt.SetMyDayTool, claude.LastArguments);
    }

    [Fact]
    public async Task Ping_writes_no_prep_log_and_raises_no_prep_events()
    {
        var path = DailyPrepPrompt.LogPath();
        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        await File.WriteAllTextAsync(path, "previous fill-my-day run");

        var broadcaster = new RecordingPrimeBroadcaster();
        var claude = new FakeClaudeProcess(emitLines: ["ping-line"], exitCode: 0, result: "ok");
        var runner = NewRunner(claude, broadcaster);

        await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);

        Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
        Assert.Equal(0, broadcaster.StartedCount);
        Assert.Empty(broadcaster.Lines);
        Assert.Empty(broadcaster.FinishedResults);
    }

    [Fact]
    public async Task Custom_sends_the_prompt_override_verbatim_with_claudedo_mcp_only()
    {
        var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
        var runner = NewRunner(claude, new RecordingPrimeBroadcaster());

        var outcome = await runner.FireAsync(
            Schedule(PrimeActionKind.Custom, "Queue the three oldest idle tasks."),
            CancellationToken.None);

        Assert.True(outcome.Success);
        Assert.Equal("Queue the three oldest idle tasks.", claude.LastPrompt);
        Assert.Contains("--allowedTools", claude.LastArguments!);
        Assert.Contains("mcp__claudedo", claude.LastArguments!);
        Assert.DoesNotContain("--strict-mcp-config", claude.LastArguments!);
    }

    [Fact]
    public async Task Custom_writes_no_prep_log_and_raises_no_prep_events()
    {
        var path = DailyPrepPrompt.LogPath();
        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        await File.WriteAllTextAsync(path, "previous fill-my-day run");

        var broadcaster = new RecordingPrimeBroadcaster();
        var claude = new FakeClaudeProcess(emitLines: ["custom-line"], exitCode: 0, result: "ok");
        var runner = NewRunner(claude, broadcaster);

        await runner.FireAsync(Schedule(PrimeActionKind.Custom, "do a thing"), CancellationToken.None);

        Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
        Assert.Equal(0, broadcaster.StartedCount);
        Assert.Empty(broadcaster.FinishedResults);
    }

    [Fact]
    public async Task FillMyDay_still_appends_the_prompt_override()
    {
        var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
        var runner = NewRunner(claude, new RecordingPrimeBroadcaster());

        await runner.FireAsync(
            Schedule(PrimeActionKind.FillMyDay, "Prefer the LogX tasks."),
            CancellationToken.None);

        Assert.Contains("Prefer the LogX tasks.", claude.LastPrompt);
        Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!);
    }

    [Fact]
    public async Task Gate_is_shared_across_kinds()
    {
        var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));

        var first = runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
        var second = await runner.FireAsync(Schedule(PrimeActionKind.FillMyDay), CancellationToken.None);

        Assert.False(second.Success);
        Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
        await first;
    }
}
