using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Scanner.Tests.Core.Metadata;

// #477/#484: the deterministic policy behind the media-server anti-nuke guard. Two cases skip the sweep
// (and log), both requiring existing items to be at risk: an EMPTY incoming set (#477) and a non-zero
// PROJECTION FAILURE count (#484). Every other combination reconciles normally — in particular a short
// incoming set caused by deliberate skips, and a genuine bulk deletion, both still sweep.
public class MediaServerReconciliationGuardTests
{
    [Test]
    public void Empty_Incoming_With_Existing_Items_Skips_And_Warns()
    {
        var logger = Substitute.For<ILogger>();

        bool shouldFlag = MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5);

        shouldFlag.ShouldBeFalse();
        logger.Received(1).Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    [Test]
    public void Partial_Deletion_Still_Flags()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 5).ShouldBeTrue();

        logger.DidNotReceive().Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    [Test]
    public void Empty_Incoming_With_No_Existing_Items_Is_A_Noop_Sweep()
    {
        var logger = Substitute.For<ILogger>();

        // nothing exists, so an empty incoming set flags nothing either way — allow the (empty) sweep
        // rather than special-casing it, and do not emit the scary warning.
        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 0).ShouldBeTrue();

        logger.DidNotReceive().Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    [Test]
    public void Full_Fetch_Into_Empty_Library_Still_Flags()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 0).ShouldBeTrue();
    }

    // #484: an item whose projection threw was silently dropped, so it is missing from the incoming set
    // even though the server DID return it. Sweeping here would flag a healthy item FileNotFound.
    [Test]
    public void Projection_Failure_Skips_And_Warns()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 4, 5, 1).ShouldBeFalse();

        logger.Received(1).Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    // #484 THE regression guard. Deliberate guard-clause skips (a STRM file, a virtual item, an
    // unsupported type) are NOT projection failures: they never reach the counter, so a short incoming
    // set caused only by them must still sweep. If skips were counted, one STRM file in a library would
    // permanently disable that library's reconciliation and stale rows would accumulate forever.
    [Test]
    public void Deliberate_Skips_Do_Not_Suppress_The_Sweep()
    {
        var logger = Substitute.For<ILogger>();

        // 5 exist locally, the server returned 5, 2 were deliberately skipped => 3 incoming, 0 failures
        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 5, 0).ShouldBeTrue();

        logger.DidNotReceive().Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    // a genuine bulk deletion produces zero projection failures, so it is unaffected by #484 — this is
    // why the rejected ratio threshold was not needed to tell the two apart.
    [Test]
    public void Bulk_Deletion_With_No_Failures_Still_Flags()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 1, 500, 0).ShouldBeTrue();
    }

    [Test]
    public void Projection_Failure_With_No_Existing_Items_Is_A_Noop_Sweep()
    {
        var logger = Substitute.For<ILogger>();

        // nothing exists locally, so the sweep flags nothing either way — don't emit the scary warning
        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 2, 0, 3).ShouldBeTrue();

        logger.DidNotReceive().Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    // #484: the nested per-show season / per-season episode sweeps. They get the SAME projection-failure
    // predicate...
    [Test]
    public void Descendant_Sweep_Skips_And_Warns_On_A_Projection_Failure()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 4, 5, 1)
            .ShouldBeFalse();

        logger.Received(1).Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    // ...but deliberately NOT #477's empty-fetch branch. A per-parent empty is a plausible legitimate
    // state (every episode of a season really was deleted) with a bounded blast radius, and #476's
    // descendant cascade depends on it still sweeping. Importing that branch here would be a silent
    // behaviour change, which is why this is a separate entry point rather than a shared signature.
    [Test]
    public void Descendant_Sweep_Still_Runs_On_An_Empty_Per_Parent_Fetch()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "season 1 of show Keeper episodes", 0, 5, 0)
            .ShouldBeTrue();

        logger.DidNotReceive().Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    [Test]
    public void Descendant_Sweep_Runs_With_No_Failures_And_Is_A_Noop_When_Nothing_Exists()
    {
        var logger = Substitute.For<ILogger>();

        // deliberate skips only => still sweeps (the STRM regression guard at the descendant level)
        MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 3, 5, 0)
            .ShouldBeTrue();

        // failures but nothing exists locally => the sweep flags nothing anyway, so don't warn
        MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 2, 0, 3)
            .ShouldBeTrue();

        logger.DidNotReceive().Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Any<object>(),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }

    // both refusals can be true at once (a fetch that dropped everything it returned); either is
    // sufficient, and the failure branch is reported because it names the actual cause.
    [Test]
    public void Projection_Failure_Wins_Over_The_Zero_Incoming_Message()
    {
        var logger = Substitute.For<ILogger>();

        MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5, 2).ShouldBeFalse();

        logger.Received(1).Log(
            LogLevel.Warning,
            Arg.Any<EventId>(),
            Arg.Is<object>(o => o.ToString()!.Contains("silently dropped")),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception?, string>>());
    }
}
