using System.Text.Json;
using ClaudeDo.Ui.Services;
using Xunit;

namespace ClaudeDo.Ui.Tests.Services;

public class OperationTimingTests
{
    [Fact]
    public void Record_AppendsOneWellFormedNdjsonLinePerOperation()
    {
        var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
        var path = Path.Combine(dir, "operation-timing.ndjson");
        var sink = new OperationTiming(path);

        try
        {
            sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(42), ok: true);
            sink.Record("db", "TasksIsland.LoadForList", TimeSpan.FromMilliseconds(7), ok: false);

            var lines = File.ReadAllLines(path);
            Assert.Equal(2, lines.Length);

            using var doc1 = JsonDocument.Parse(lines[0]);
            var root1 = doc1.RootElement;
            Assert.Equal("hub", root1.GetProperty("kind").GetString());
            Assert.Equal("RunNow", root1.GetProperty("op").GetString());
            Assert.Equal(42, root1.GetProperty("ms").GetInt64());
            Assert.True(root1.GetProperty("ok").GetBoolean());
            Assert.True(root1.TryGetProperty("ts", out _));

            using var doc2 = JsonDocument.Parse(lines[1]);
            var root2 = doc2.RootElement;
            Assert.Equal("db", root2.GetProperty("kind").GetString());
            Assert.Equal("TasksIsland.LoadForList", root2.GetProperty("op").GetString());
            Assert.Equal(7, root2.GetProperty("ms").GetInt64());
            Assert.False(root2.GetProperty("ok").GetBoolean());

            // Runs of the app interleave in one file — without the pid a restart is
            // indistinguishable from concurrent work inside one instance.
            Assert.Equal(Environment.ProcessId, root1.GetProperty("pid").GetInt32());
        }
        finally
        {
            if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
        }
    }

    [Fact]
    public void Record_DropsFastSuccessesButKeepsEveryFailure()
    {
        var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
        var path = Path.Combine(dir, "operation-timing.ndjson");
        var sink = new OperationTiming(path, minMs: 25);

        try
        {
            sink.Record("db", "Fast", TimeSpan.FromMilliseconds(3), ok: true);       // noise
            sink.Record("db", "Cancelled", TimeSpan.FromMilliseconds(1), ok: false); // churn signal
            sink.Record("db", "Slow", TimeSpan.FromMilliseconds(25), ok: true);      // boundary: kept

            var ops = File.ReadAllLines(path)
                .Select(l => JsonDocument.Parse(l).RootElement.GetProperty("op").GetString())
                .ToArray();

            Assert.Equal(new[] { "Cancelled", "Slow" }, ops);
        }
        finally
        {
            if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
        }
    }

    [Fact]
    public void Record_RollsTheFileOverOnceItPassesTheSizeCap()
    {
        var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
        var path = Path.Combine(dir, "operation-timing.ndjson");
        var sink = new OperationTiming(path, maxBytes: 200);

        try
        {
            for (var i = 0; i < 12; i++)
                sink.Record("db", "Op" + i, TimeSpan.FromMilliseconds(100), ok: true);

            Assert.True(File.Exists(path + ".1"), "expected one rolled-over file");
            Assert.True(new FileInfo(path).Length < 200 + 200, "live file should have been truncated by the roll");

            // The newest line survives the roll — rotation must never eat the current write.
            var last = File.ReadAllLines(path)[^1];
            Assert.Equal("Op11", JsonDocument.Parse(last).RootElement.GetProperty("op").GetString());
        }
        finally
        {
            if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
        }
    }

    [Fact]
    public void Record_DoesNothingWhenDisabled()
    {
        var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
        var path = Path.Combine(dir, "operation-timing.ndjson");
        var sink = new OperationTiming(path, enabled: false);

        try
        {
            var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: false));

            Assert.Null(exception);
            Assert.False(File.Exists(path));
            Assert.False(Directory.Exists(dir));
        }
        finally
        {
            if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
        }
    }

    [Fact]
    public void Record_SwallowsWriteFailureInsteadOfThrowing()
    {
        // A plain file sitting where the sink expects a directory makes Directory.CreateDirectory
        // throw -- standing in for the disk-full/locked-file/no-permission failures this sink
        // must survive without disturbing the caller.
        var root = Path.Combine(Path.GetTempPath(), "claudedo_optiming_invalid_" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(root);
        var blockingFile = Path.Combine(root, "not-a-directory");
        File.WriteAllText(blockingFile, "");
        var targetPath = Path.Combine(blockingFile, "timing.ndjson");
        var sink = new OperationTiming(targetPath);

        try
        {
            var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: true));

            Assert.Null(exception);
            Assert.False(File.Exists(targetPath));
        }
        finally
        {
            Directory.Delete(root, recursive: true);
        }
    }
}
