using System.Text.RegularExpressions;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Architecture.Tests;

/// <summary>
///     ersatztv#491: <c>TvContext</c> carries settable provider statics (<c>LastInsertedRowId</c>,
///     <c>CaseInsensitiveCollation</c>, <c>IsUniqueConstraintViolation</c>, …) that Infrastructure code
///     reads at runtime. There are TWO composition roots that execute that Infrastructure code —
///     <c>ErsatzTV/Startup.cs</c> (the host) and <c>ErsatzTV.Scanner/Program.cs</c> (a separate
///     executable launched per scan by <c>CallLibraryScannerHandler</c>) — and each wires the statics in
///     its own copy of the provider branch.
///     <para>
///         The failure mode this guards is "a static nobody assigned": #491 added
///         <c>IsUniqueConstraintViolation</c> to <c>Startup</c> only, so every production caller of
///         <c>GetOrAddFolder</c> (all of which live in the scanner) silently kept the conservative
///         <c>_ =&gt; false</c> default and the new catch was inert. Nothing about that is visible in a
///         unit test, because every test harness wires the classifier itself.
///     </para>
///     <para>
///         Source-level rather than reflective on purpose: the wiring lives inside a host-builder
///         lambda that cannot be invoked without standing up a real application, and the thing being
///         asserted is precisely that a line of code exists in both files.
///     </para>
/// </summary>
[TestFixture]
public class ProviderStaticsWiringTests
{
    /// <summary>
    ///     Statics the host wires that the scanner deliberately does not. Add to this only with a reason:
    ///     the default must be provably harmless in the scanner process.
    /// </summary>
    private static readonly Dictionary<string, string> ScannerExemptions = new()
    {
        // Only read by DbInitializer / DatabaseMigratorService, which run in the host exclusively; no
        // Infrastructure code on a scan path reads it. Pre-dates #491.
        ["IsSqlite"] = "read only by DbInitializer + DatabaseMigratorService, both host-only"
    };

    private static string HostSource => ReadRepoFile(Path.Combine("ErsatzTV", "Startup.cs"));

    private static string ScannerSource => ReadRepoFile(Path.Combine("ErsatzTV.Scanner", "Program.cs"));

    [Test]
    public void Scanner_should_wire_every_TvContext_provider_static_the_host_wires()
    {
        HashSet<string> host = AssignedStatics(HostSource);
        HashSet<string> scanner = AssignedStatics(ScannerSource);

        // sanity: the parser found the wiring at all, so a rename can't turn this test into a no-op
        host.ShouldContain("LastInsertedRowId");
        host.ShouldContain("IsUniqueConstraintViolation");
        scanner.ShouldContain("LastInsertedRowId");

        List<string> missing = host
            .Except(scanner)
            .Except(ScannerExemptions.Keys)
            .OrderBy(name => name, StringComparer.Ordinal)
            .ToList();

        missing.ShouldBeEmpty(
            "ErsatzTV.Scanner/Program.cs does not assign TvContext static(s) that ErsatzTV/Startup.cs "
            + $"assigns: {string.Join(", ", missing)}. The scanner is a separate process, so an unassigned "
            + "static keeps its default in every library scan. Wire it in BOTH provider branches, or add "
            + "it to ScannerExemptions with a reason if the default is provably harmless there.");
    }

    [Test]
    public void Both_hosts_should_wire_the_unique_constraint_classifier_for_both_providers()
    {
        // The specific #491 regression, asserted directly rather than via set arithmetic: the classifier
        // must be pointed at a real provider implementation on BOTH branches of BOTH composition roots.
        foreach ((string name, string source) in new[] { ("host", HostSource), ("scanner", ScannerSource) })
        {
            source.ShouldContain(
                "TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation",
                customMessage: $"{name} does not wire the Sqlite unique-constraint classifier");
            source.ShouldContain(
                "TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation",
                customMessage: $"{name} does not wire the MySql unique-constraint classifier");
        }
    }

    private static HashSet<string> AssignedStatics(string source) =>
        Regex.Matches(source, @"\bTvContext\.(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=[^=]")
            .Select(m => m.Groups["name"].Value)
            .ToHashSet(StringComparer.Ordinal);

    private static string ReadRepoFile(string relativePath)
    {
        var directory = new DirectoryInfo(AppContext.BaseDirectory);
        while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ErsatzTV.sln")))
        {
            directory = directory.Parent;
        }

        directory.ShouldNotBeNull("could not locate the repository root (no ErsatzTV.sln above the test binary)");

        string path = Path.Combine(directory!.FullName, relativePath);
        File.Exists(path).ShouldBeTrue($"expected source file not found: {path}");
        return File.ReadAllText(path);
    }
}
