using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;

namespace ErsatzTV.Core.Tests.Scheduling;

// Golden-file characterization tests that lock the output of the playout builders — the core
// scheduling surface that turns a ProgramSchedule/Block calendar + Collection into a concrete list of
// PlayoutItems (issue #163). This slice covers the Classic builder (PlaybackOrder.Chronological) and
// the Block builder. Sequential (YAML) + Scripted goldens are tracked as a follow-up in #381 (they need
// a YAML fixture / an external-process harness respectively — not just a clock seam).
//
// This is the scheduling counterpart to ChannelPlaylistGoldenTests (#11, M3U) and
// ChannelGuideGoldenTests (#28, XMLTV). Goldens live under Goldens/Goldens/ and are regenerated via
// the Regenerate_goldens test or ETV_UPDATE_PLAYOUT_GOLDENS=1 — review the diff before committing.
//
// DETERMINISM: time enters the build ONLY via the pinned start (finish = start + 2 days); no wall clock
// is read. We snapshot the raw PlayoutItem.Start/Finish (DateTime, treated as UTC) — NOT the *Offset
// properties, which call .ToLocalTime() and would make the golden machine-timezone dependent.
//
// A few determinism invariants worth stating so a future reader doesn't "helpfully" break them:
//   * We snapshot the builder's raw output (buildResult.AddedItems), NOT the persisted playout. With
//     TrimStart, production would delete items before RemoveBefore (~start - 4h), so the golden's early
//     lines are pre-trim. That is intentional: this locks the BUILDER's output, and it is deterministic.
//   * Classic is TZ-independent for the captured fields (its internal DateTime->offset conversions only
//     gate the day-by-day loop; the anchor carries currentTime forward as UTC), so it needs no TZ guard —
//     unlike Block below. Do not add/remove a guard without re-checking this.
//   * ResetPlayout randomizes playout.Seed, but the classic fixture neutralizes it: RandomStartPoint and
//     ShuffleScheduleItems both default false and Chronological orders by (distinct) release date, so the
//     seed cannot perturb output. Introducing release-date ties or flipping those flags would reintroduce
//     nondeterminism.
[TestFixture]
public class PlayoutBuildGoldenTests
{
    // Pinned build window — deterministic, UTC, no wall-clock dependency.
    private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);

    private SqliteConnection _connection;
    private IDbContextFactory<TvContext> _dbContextFactory;

    [OneTimeSetUp]
    public async Task SetUpDatabase()
    {
        // Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
        _connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
        await _connection.OpenAsync();

        DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
            .UseSqlite(_connection)
            .Options;

        _dbContextFactory = new TestTvContextFactory(options);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        // EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
        // replaying every migration. The MediaCollectionRepository's Dapper queries run against it fine.
        await context.Database.EnsureCreatedAsync();
        await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
    }

    [OneTimeTearDown]
    public void TearDownDatabase() => _connection?.Dispose();

    [Test]
    public Task Classic_chronological() => Verify("classic-chronological.txt", BuildChronologicalPlayout);

    [Test]
    public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);

    // Issue #77: clock-boundary padding. A PostRoll FillerPreset with FillerMode.Pad +
    // PadToNearestMinute = 15 snaps each ragged content item up to the next :00/:15/:30/:45 mark, filling
    // the gap with filler. This locks BOTH the exact builder output (golden) AND the invariant that every
    // content item after the first begins on a quarter-hour boundary — the end-to-end proof that the
    // existing Pad machinery already delivers #77's "clean clock-aligned guides" for Classic playouts.
    [Test]
    public async Task Classic_clock_padded()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout();

        List<PlayoutItem> content = items
            .Where(i => i.FillerKind == FillerKind.None)
            .OrderBy(i => i.Start)
            .ToList();

        // Sanity: the fixture must actually produce several content items and pad filler between them.
        content.Count.ShouldBeGreaterThan(3);
        items.ShouldContain(i => i.FillerKind == FillerKind.PostRoll);

        // The invariant: content items (the first is the raw anchor at :00) start on a :15 boundary because
        // the preceding item was padded up to it. Raw Start is UTC here, matching the deterministic fixture.
        foreach (PlayoutItem item in content.Skip(1))
        {
            (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0);
        }

        await CompareGolden("classic-clock-padded.txt", items, titles);
    }

    // Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
    // the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
    // the build clock to the boundary even when nothing fills the gap.
    [Test]
    public async Task Classic_schedule_clock_padded_offline()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);

        List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
        content.Count.ShouldBeGreaterThan(2);

        // No filler of any kind is emitted (offline gaps only).
        items.ShouldNotContain(i => i.FillerKind != FillerKind.None);

        foreach (PlayoutItem item in content.Skip(1))
        {
            (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0);
        }

        await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
    }

    // Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
    // the :15 boundary (no offline gap).
    [Test]
    public async Task Classic_schedule_clock_padded_fallback()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);

        items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);

        List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
        foreach (PlayoutItem item in content.Skip(1))
        {
            (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0);
        }

        await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
    }

    // #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
    [Test]
    public async Task Classic_item_pad_wins_over_schedule_pad()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
        await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
    }

    // #392: the schedule-level pad + OFFLINE advance is shared machinery — AddFiller records the offline
    // target and every scheduler honors it. The offline goldens only exercise PlayoutModeSchedulerOne, so
    // these invariant tests cover Flood / Duration / Multiple across a 2-day window (two midnight crossings)
    // to prove the offline advance AND its day-seam anchor survival are not One-specific. No golden files:
    // the invariants (boundary alignment of every content item, zero filler emitted, and resumption on a
    // boundary on both later days) fully pin the behavior and are the exact thing the day-seam clamp fix
    // must preserve. If the day-boundary anchor clamp wrongly discarded an offline advance, the first
    // content item after a midnight would land mid-interval and fail here.
    [TestCase("Flood")]
    [TestCase("Duration")]
    [TestCase("Multiple")]
    public async Task Schedule_clock_padded_offline_multimode(string mode)
    {
        List<PlayoutItem> items = await BuildSchedulePaddedModePlayout(mode);

        List<PlayoutItem> content = items
            .Where(i => i.FillerKind == FillerKind.None)
            .OrderBy(i => i.Start)
            .ToList();

        // Sanity: a 2-day window over sub-hour content must yield many items spanning >1 day.
        content.Count.ShouldBeGreaterThan(10);
        content.Select(i => i.Start.Date).Distinct().Count().ShouldBeGreaterThan(2);

        // Offline variant: NO filler of any kind is emitted (gaps up to the boundary are left offline).
        items.ShouldNotContain(i => i.FillerKind != FillerKind.None, $"[{mode}] offline pad must emit no filler");

        // Every content item begins on a :15 boundary. The very first item is the raw anchor at the pinned
        // Start (06:00, itself a :15 boundary); every later item — including the first of day 2 and day 3 —
        // is on a boundary only because the preceding item's offline pad advanced the clock to it AND the
        // day-boundary anchor clamp preserved that advance across each midnight seam.
        foreach (PlayoutItem item in content)
        {
            (item.Start.Minute % 15).ShouldBe(
                0,
                $"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0, $"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not second-aligned");
        }

        // Explicit day-seam assertion: the first content item on each day after the first still lands on a
        // boundary (this is precisely what regressed before the clamp fix, once per simulated midnight).
        List<PlayoutItem> firstOfEachDay = content
            .GroupBy(i => i.Start.Date)
            .OrderBy(g => g.Key)
            .Select(g => g.OrderBy(i => i.Start).First())
            .ToList();
        foreach (PlayoutItem dayStart in firstOfEachDay.Skip(1))
        {
            (dayStart.Start.Minute % 15).ShouldBe(
                0,
                $"[{mode}] first content item of {dayStart.Start:yyyy-MM-dd} at {dayStart.Start:HH:mm:ss} resumed mid-interval");
        }
    }

    // Regression for the whole-branch-review defect: Fill-With-Group schedule items (FillWithGroupMode
    // .FillWithOrderedGroups / FillWithShuffledGroups) are scheduled via a FAKE ProgramScheduleItem that
    // PlayoutBuilder synthesizes with DeepCopy() (Newtonsoft serialization). ProgramScheduleItem
    // .ProgramSchedule is [JsonIgnore]'d there, so without reassigning it on the copy, the schedule-level
    // PadToNearestMinute silently no-ops for fill-with-group items only — the normal (non-group) path
    // reads the schedule nav that Build() populates centrally and was never broken. No golden: this is an
    // invariant-only regression test (boundary alignment + zero filler), the same assertions
    // Classic_schedule_clock_padded_offline uses for the non-group path.
    [Test]
    public async Task Schedule_clock_padded_fill_with_group_offline()
    {
        List<PlayoutItem> items = await BuildSchedulePaddedFillWithGroupPlayout();

        List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
        content.Count.ShouldBeGreaterThan(2);

        // No filler of any kind is emitted (offline gaps only).
        items.ShouldNotContain(i => i.FillerKind != FillerKind.None);

        foreach (PlayoutItem item in content.Skip(1))
        {
            (item.Start.Minute % 15).ShouldBe(
                0,
                $"fill-with-group content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0);
        }
    }

    // Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
    // (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
    // ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
    // seed, so this build pins playout.Seed and uses Continue mode (Reset would randomize the seed).
    [Test]
    public Task Classic_shuffle() => Verify("classic-shuffle.txt", BuildShufflePlayout);

    // Classic + PlaybackOrder.WeightedShuffle (#70): the weight only means anything end-to-end if it survives
    // MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight -> ShuffleSourceBuilder ->
    // WeightedShuffleCollectionEnumerator. The unit tests pin the enumerator's sequence in isolation; this pins
    // that the real builder actually distributes by weight. The fixture is deliberately lopsided -- the LIGHTER
    // source (weight 1) is the LARGER collection (4 items vs 2) -- so a golden that merely tracked collection
    // size, or that ignored weight, would look obviously different from one that honors a 3:1 ratio.
    // Same determinism contract as Classic_shuffle: pinned Seed + Continue (Reset would randomize the seed).
    [Test]
    public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);

    // Sequential (YAML) builder (#381, follow-up to #163). SequentialPlayoutBuilder reads a YAML schedule
    // file (Playout.ScheduleFile) instead of a ProgramSchedule/Block calendar. The committed fixture
    // Goldens/Fixtures/sequential-schedule.yml schedules two `count: 2` instructions over one chronological
    // collection, so the builder lays exactly four items back-to-back from the pinned start (the enumerator
    // is cached by content key and continues across the two instructions). Besides the golden we assert the
    // contiguity invariant: it is what "sequential" means here, and a deliberate change to a count or a
    // duration flips both the assertion and the golden (#12 non-vacuity). The count/all/duration handlers do
    // pure UTC arithmetic off the caller-supplied start (no TimeZoneInfo.Local / ToLocalTime), so this case
    // is TZ-independent and needs no Assume guard — unlike Block, and unlike the wait_until/pad_* handlers
    // the fixture deliberately avoids.
    [Test]
    public async Task Sequential_yaml()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSequentialPlayout();

        List<PlayoutItem> ordered = items.OrderBy(i => i.Start).ToList();

        ordered.Count.ShouldBe(4);
        ordered.ShouldAllBe(i => i.FillerKind == FillerKind.None);
        ordered[0].Start.ShouldBe(Start.UtcDateTime);
        for (var i = 1; i < ordered.Count; i++)
        {
            ordered[i].Start.ShouldBe(
                ordered[i - 1].Finish,
                $"sequential item {i} at {ordered[i].Start:HH:mm:ss} is not contiguous with the previous finish");
        }

        await CompareGolden("sequential-yaml.txt", items, titles);
    }

    [Test]
    [Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
    public async Task Regenerate_goldens()
    {
        Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
        try
        {
            foreach (Func<Task> regen in new Func<Task>[]
                     {
                         Classic_chronological, Block_playout, Classic_clock_padded,
                         Classic_schedule_clock_padded_offline, Classic_schedule_clock_padded_fallback,
                         Classic_shuffle, Classic_weighted, Sequential_yaml
                     })
            {
                try
                {
                    await regen();
                }
                catch (InconclusiveException)
                {
                    // expected — Verify writes its golden then reports inconclusive
                }
            }
        }
        finally
        {
            Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", null);
        }
    }

    // --- harness ---

    private async Task Verify(
        string goldenName,
        Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
        await CompareGolden(goldenName, items, titles);
    }

    // Snapshot + golden-compare (or regenerate). Split out of Verify so a test that also runs explicit
    // assertions on the built items (e.g. Classic_clock_padded) can reuse the same golden mechanics.
    private async Task CompareGolden(string goldenName, List<PlayoutItem> items, Dictionary<int, string> titles)
    {
        string actual = Canonicalize(Snapshot(items, titles));

        string path = Path.Combine(GoldenDir(), goldenName);

        if (Environment.GetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS") == "1")
        {
            Directory.CreateDirectory(GoldenDir());
            await File.WriteAllTextAsync(path, actual);
            Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
            return;
        }

        // A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
        File.Exists(path).ShouldBeTrue(
            $"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_PLAYOUT_GOLDENS=1) and commit it.");

        string expected = Canonicalize(await File.ReadAllTextAsync(path));
        actual.ShouldBe(expected);
    }

    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildChronologicalPlayout()
    {
        var cancellationToken = CancellationToken.None;

        // Seed a fresh, deterministic dataset for this build. Titles + release dates are fixed and the
        // durations vary (30/45/60) so the chronological ordering and item boundaries are visible.
        var (playoutId, titles) = await SeedData(cancellationToken);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);

        // Build ONCE with Reset over the pinned 2-day window (internal overload = explicit start/finish).
        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Reset,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildShufflePlayout()
    {
        var cancellationToken = CancellationToken.None;

        var (playoutId, titles) = await SeedShuffleData(cancellationToken);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);

        // Continue (not Reset): Reset randomizes playout.Seed, which would make the shuffle
        // nondeterministic. The pinned Seed (set in SeedShuffleData) flows to the enumerator seed, so the
        // shuffled order is stable. Anchor is null and there are no existing items, so Continue still
        // builds the full pinned 2-day window from the start.
        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Continue,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedShuffleData(
        CancellationToken cancellationToken)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        var path = new LibraryPath { Path = "Shuffle LibraryPath" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
        var movies = new List<Movie>();
        for (var i = 1; i <= 6; i++)
        {
            var movie = new Movie
            {
                MediaVersions = new List<MediaVersion>
                {
                    new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
                },
                MovieMetadata = new List<MovieMetadata>
                {
                    new()
                    {
                        Title = $"Shuffle Movie {i:D2}",
                        ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
                    }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            };
            movies.Add(movie);
        }

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);

        var collection = new Collection
        {
            Name = "Shuffle Test Collection",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddAsync(collection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var scheduleItems = new List<ProgramScheduleItem>
        {
            new ProgramScheduleItemDuration
            {
                Collection = collection,
                CollectionId = collection.Id,
                CollectionType = CollectionType.Collection,
                PlayoutDuration = TimeSpan.FromHours(1),
                TailMode = TailMode.None,
                PlaybackOrder = PlaybackOrder.Shuffle
            }
        };

        var ffmpegProfile = new FFmpegProfile { Name = "Shuffle FFmpeg Profile" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000004"))
        {
            Name = "Shuffle Test Channel",
            Number = "4",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule { Name = "Shuffle Test Schedule", Items = scheduleItems };
        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic,
            // Pinned so the shuffle is deterministic; Continue mode preserves it (Reset would overwrite).
            Seed = 1234567
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildWeightedPlayout()
    {
        var cancellationToken = CancellationToken.None;

        var (playoutId, titles) = await SeedWeightedData(cancellationToken);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);

        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Continue,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedWeightedData(
        CancellationToken cancellationToken)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        var path = new LibraryPath { Path = "Weighted LibraryPath" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Titles carry their source so the golden reads as a rotation rather than a list of ids.
        // "Heavy" is the SMALL collection with weight 3; "Light" is the LARGE one with weight 1.
        async Task<List<Movie>> SeedMovies(string prefix, int count)
        {
            var movies = new List<Movie>();
            for (var i = 1; i <= count; i++)
            {
                movies.Add(
                    new Movie
                    {
                        MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(30) } },
                        MovieMetadata = new List<MovieMetadata>
                        {
                            new()
                            {
                                Title = $"{prefix} Movie {i:D2}",
                                ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
                            }
                        },
                        LibraryPath = path,
                        LibraryPathId = path.Id
                    });
            }

            await context.Movies.AddRangeAsync(movies, cancellationToken);
            await context.SaveChangesAsync(cancellationToken);
            return movies;
        }

        List<Movie> heavy = await SeedMovies("Heavy", 2);
        List<Movie> light = await SeedMovies("Light", 4);

        var titles = heavy.Concat(light).ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);

        var heavyCollection = new Collection
        {
            Name = "Weighted Heavy Collection",
            MediaItems = heavy.Cast<MediaItem>().ToList()
        };
        var lightCollection = new Collection
        {
            Name = "Weighted Light Collection",
            MediaItems = light.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddRangeAsync([heavyCollection, lightCollection], cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var multiCollection = new MultiCollection
        {
            Name = "Weighted Multi Collection",
            MultiCollectionItems =
            [
                new MultiCollectionItem
                {
                    CollectionId = heavyCollection.Id,
                    ScheduleAsGroup = true,
                    PlaybackOrder = PlaybackOrder.WeightedShuffle,
                    Weight = 3
                },
                new MultiCollectionItem
                {
                    CollectionId = lightCollection.Id,
                    ScheduleAsGroup = true,
                    PlaybackOrder = PlaybackOrder.WeightedShuffle,
                    Weight = 1
                }
            ]
        };
        await context.MultiCollections.AddAsync(multiCollection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var scheduleItems = new List<ProgramScheduleItem>
        {
            new ProgramScheduleItemDuration
            {
                MultiCollection = multiCollection,
                MultiCollectionId = multiCollection.Id,
                CollectionType = CollectionType.MultiCollection,
                PlayoutDuration = TimeSpan.FromHours(1),
                TailMode = TailMode.None,
                PlaybackOrder = PlaybackOrder.WeightedShuffle
            }
        };

        var ffmpegProfile = new FFmpegProfile { Name = "Weighted FFmpeg Profile" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
        var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000005"))
        {
            Name = "Weighted Test Channel",
            Number = "5",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule { Name = "Weighted Test Schedule", Items = scheduleItems };
        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic,
            // Pinned so the weighted rotation is deterministic; Continue preserves it (Reset would overwrite).
            Seed = 7654321
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        var path = new LibraryPath { Path = "Test LibraryPath" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Six movies, fixed titles + release dates, varied durations to make ordering/boundaries visible.
        int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
        var movies = new List<Movie>();
        for (var i = 1; i <= 6; i++)
        {
            var movie = new Movie
            {
                MediaVersions = new List<MediaVersion>
                {
                    new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
                },
                MovieMetadata = new List<MovieMetadata>
                {
                    new()
                    {
                        Title = $"Movie {i:D2}",
                        ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
                    }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            };
            movies.Add(movie);
        }

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);

        var collection = new Collection
        {
            Name = "Test Collection",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddAsync(collection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var scheduleItems = new List<ProgramScheduleItem>
        {
            new ProgramScheduleItemDuration
            {
                Collection = collection,
                CollectionId = collection.Id,
                CollectionType = CollectionType.Collection,
                PlayoutDuration = TimeSpan.FromHours(1),
                TailMode = TailMode.None,
                PlaybackOrder = PlaybackOrder.Chronological
            }
        };

        var ffmpegProfile = new FFmpegProfile { Name = "Test FFmpeg Profile" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
        {
            Name = "Test Channel",
            Number = "1",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule { Name = "Test Schedule", Items = scheduleItems };
        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private static async Task<PlayoutReferenceData> GetReferenceData(TvContext dbContext, int playoutId)
    {
        Channel channel = await dbContext.Channels
            .AsNoTracking()
            .Where(c => c.Playouts.Any(p => p.Id == playoutId))
            .FirstOrDefaultAsync();

        ProgramSchedule programSchedule = await dbContext.ProgramSchedules
            .AsNoTracking()
            .Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.Collection)
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.MediaItem)
            .FirstOrDefaultAsync();

        return new PlayoutReferenceData(
            channel,
            Option<Deco>.None,
            [],
            [],
            programSchedule,
            [],
            [],
            TimeSpan.Zero);
    }

    // --- Clock-boundary pad builder (issue #77) ---

    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout(
        int? schedulePadMinutes = null)
    {
        var cancellationToken = CancellationToken.None;

        var (playoutId, titles) = await SeedPaddedData(cancellationToken, schedulePadMinutes);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetPaddedReferenceData(context, playoutId);

        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Reset,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
        CancellationToken cancellationToken,
        int? schedulePadMinutes = null)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        // Suffix distinguishes this call from the base (schedulePadMinutes: null) call so unique-name/guid
        // constraints don't collide when both are seeded into the same shared in-memory database. It never
        // touches a Movie/MovieMetadata title, so the golden snapshot (which only records those) is unaffected.
        string suffix = schedulePadMinutes.HasValue ? $" SchedulePad{schedulePadMinutes}" : string.Empty;

        var path = new LibraryPath { Path = $"Padded LibraryPath{suffix}" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
        int[] durationsMinutes = [22, 37, 52];
        var movies = new List<Movie>();
        for (var i = 1; i <= 3; i++)
        {
            movies.Add(new Movie
            {
                MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
                MovieMetadata = new List<MovieMetadata>
                {
                    new() { Title = $"Padded Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            });
        }

        // Filler: a SINGLE 1-minute clip. The builder shuffles filler collections (seed-dependent), so a
        // one-item collection keeps the golden deterministic while still filling any integer-minute gap.
        var fillerClip = new Movie
        {
            MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
            MovieMetadata = new List<MovieMetadata>
            {
                new() { Title = "Filler Clip", ReleaseDate = new DateTime(2019, 1, 1) }
            },
            LibraryPath = path,
            LibraryPathId = path.Id
        };

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.Movies.AddAsync(fillerClip, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
        titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;

        var contentCollection = new Collection
        {
            Name = $"Padded Content Collection{suffix}",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        var fillerCollection = new Collection
        {
            Name = $"Padded Filler Collection{suffix}",
            MediaItems = new List<MediaItem> { fillerClip }
        };
        await context.Collections.AddAsync(contentCollection, cancellationToken);
        await context.Collections.AddAsync(fillerCollection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // TZ-INDEPENDENCE INVARIANT: the pad ceiling is computed on the LOCAL minute-of-hour
        // (PlayoutModeSchedulerBase uses StartOffset = ToLocalTime()), yet this golden snapshots raw UTC
        // Start/Finish. Those agree across machine timezones ONLY because PadToNearestMinute = 15 divides
        // every real IANA UTC offset (all are multiples of 15 min — Kolkata +5:30, Nepal +5:45, Chatham
        // +12:45), so the pad delta is offset-invariant. Do NOT regenerate this golden with a non-15-divisor
        // increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
        var padFiller = new FillerPreset
        {
            Name = $"Pad To Quarter Hour{suffix}",
            FillerKind = FillerKind.PostRoll,
            FillerMode = FillerMode.Pad,
            PadToNearestMinute = 15,
            CollectionType = CollectionType.Collection,
            Collection = fillerCollection,
            CollectionId = fillerCollection.Id
        };
        await context.FillerPresets.AddAsync(padFiller, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var scheduleItems = new List<ProgramScheduleItem>
        {
            new ProgramScheduleItemOne
            {
                Collection = contentCollection,
                CollectionId = contentCollection.Id,
                CollectionType = CollectionType.Collection,
                PlaybackOrder = PlaybackOrder.Chronological,
                PostRollFiller = padFiller,
                PostRollFillerId = padFiller.Id
            }
        };

        var ffmpegProfile = new FFmpegProfile { Name = $"Padded FFmpeg Profile{suffix}" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(
            schedulePadMinutes.HasValue
                ? Guid.Parse("00000000-0000-0000-0000-000000000007")
                : Guid.Parse("00000000-0000-0000-0000-000000000003"))
        {
            Name = $"Padded Test Channel{suffix}",
            Number = schedulePadMinutes.HasValue ? "7" : "3",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule { Name = $"Padded Test Schedule{suffix}", Items = scheduleItems };
        if (schedulePadMinutes.HasValue)
        {
            schedule.PadToNearestMinute = schedulePadMinutes.Value;
        }

        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private static async Task<PlayoutReferenceData> GetPaddedReferenceData(TvContext dbContext, int playoutId)
    {
        Channel channel = await dbContext.Channels
            .AsNoTracking()
            .Where(c => c.Playouts.Any(p => p.Id == playoutId))
            .FirstOrDefaultAsync();

        ProgramSchedule programSchedule = await dbContext.ProgramSchedules
            .AsNoTracking()
            .Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.Collection)
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.MediaItem)
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.PostRollFiller)
            .FirstOrDefaultAsync();

        return new PlayoutReferenceData(
            channel,
            Option<Deco>.None,
            [],
            [],
            programSchedule,
            [],
            [],
            TimeSpan.Zero);
    }

    // #392: schedule-level PadToNearestMinute (no item-level Pad filler). Mirrors BuildPaddedPlayout/
    // SeedPaddedData/GetPaddedReferenceData above, but the item has no PostRollFiller and the schedule
    // itself carries the pad divisor.
    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSchedulePaddedPlayout(
        bool withFallback)
    {
        var cancellationToken = CancellationToken.None;

        var (playoutId, titles) = await SeedSchedulePaddedData(cancellationToken, withFallback);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);

        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Reset,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedSchedulePaddedData(
        CancellationToken cancellationToken,
        bool withFallback)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        // Suffix distinguishes the offline/fallback variants so unique-name/guid constraints don't
        // collide when both fixtures are seeded into the same shared in-memory database.
        string suffix = withFallback ? "Fallback" : "Offline";

        var path = new LibraryPath { Path = $"Schedule Padded LibraryPath {suffix}" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
        int[] durationsMinutes = [22, 37, 52];
        var movies = new List<Movie>();
        for (var i = 1; i <= 3; i++)
        {
            movies.Add(new Movie
            {
                MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
                MovieMetadata = new List<MovieMetadata>
                {
                    new() { Title = $"Schedule Padded Movie {suffix} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            });
        }

        // Filler: a SINGLE 1-minute clip, only used for the fallback variant.
        var fillerClip = new Movie
        {
            MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
            MovieMetadata = new List<MovieMetadata>
            {
                new() { Title = $"Schedule Fallback Filler Clip {suffix}", ReleaseDate = new DateTime(2019, 1, 1) }
            },
            LibraryPath = path,
            LibraryPathId = path.Id
        };

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.Movies.AddAsync(fillerClip, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
        titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;

        var contentCollection = new Collection
        {
            Name = $"Schedule Padded Content Collection {suffix}",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        var fillerCollection = new Collection
        {
            Name = $"Schedule Padded Fallback Collection {suffix}",
            MediaItems = new List<MediaItem> { fillerClip }
        };
        await context.Collections.AddAsync(contentCollection, cancellationToken);
        await context.Collections.AddAsync(fillerCollection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        FillerPreset fallbackFiller = null;
        if (withFallback)
        {
            fallbackFiller = new FillerPreset
            {
                Name = $"Schedule Pad Fallback {suffix}",
                FillerKind = FillerKind.Fallback,
                FillerMode = FillerMode.None,
                CollectionType = CollectionType.Collection,
                Collection = fillerCollection,
                CollectionId = fillerCollection.Id
            };
            await context.FillerPresets.AddAsync(fallbackFiller, cancellationToken);
            await context.SaveChangesAsync(cancellationToken);
        }

        var scheduleItem = new ProgramScheduleItemOne
        {
            Collection = contentCollection,
            CollectionId = contentCollection.Id,
            CollectionType = CollectionType.Collection,
            PlaybackOrder = PlaybackOrder.Chronological
        };
        if (withFallback)
        {
            scheduleItem.FallbackFiller = fallbackFiller;
            scheduleItem.FallbackFillerId = fallbackFiller.Id;
        }

        var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };

        var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded FFmpeg Profile {suffix}" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(Guid.Parse(withFallback ? "00000000-0000-0000-0000-000000000009" : "00000000-0000-0000-0000-000000000008"))
        {
            Name = $"Schedule Padded Test Channel {suffix}",
            Number = withFallback ? "9" : "8",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule
        {
            Name = $"Schedule Padded Test Schedule {suffix}",
            Items = scheduleItems,
            PadToNearestMinute = 15
        };
        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private static async Task<PlayoutReferenceData> GetSchedulePaddedReferenceData(TvContext dbContext, int playoutId)
    {
        Channel channel = await dbContext.Channels
            .AsNoTracking()
            .Where(c => c.Playouts.Any(p => p.Id == playoutId))
            .FirstOrDefaultAsync();

        ProgramSchedule programSchedule = await dbContext.ProgramSchedules
            .AsNoTracking()
            .Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.Collection)
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.MediaItem)
            .Include(ps => ps.Items)
            .ThenInclude(psi => psi.FallbackFiller)
            .FirstOrDefaultAsync();

        return new PlayoutReferenceData(
            channel,
            Option<Deco>.None,
            [],
            [],
            programSchedule,
            [],
            [],
            TimeSpan.Zero);
    }

    // #392: schedule-level pad + offline advance for a NON-One scheduler (Flood / Duration / Multiple).
    // Same shape as SeedSchedulePaddedData (no item-level Pad filler, no FallbackFiller → offline), only the
    // ProgramScheduleItem subtype differs. Reuses GetSchedulePaddedReferenceData for the build query.
    private async Task<List<PlayoutItem>> BuildSchedulePaddedModePlayout(string mode)
    {
        var cancellationToken = CancellationToken.None;

        int playoutId = await SeedSchedulePaddedModeData(cancellationToken, mode);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);

        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Reset,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return buildResult.AddedItems;
    }

    private async Task<int> SeedSchedulePaddedModeData(CancellationToken cancellationToken, string mode)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        // Per-mode suffix + GUID keep unique constraints from colliding across fixtures in the shared DB.
        string guid = mode switch
        {
            "Flood" => "00000000-0000-0000-0000-00000000000a",
            "Duration" => "00000000-0000-0000-0000-00000000000b",
            "Multiple" => "00000000-0000-0000-0000-00000000000c",
            _ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
        };

        var path = new LibraryPath { Path = $"Schedule Padded Mode LibraryPath {mode}" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Sub-15-min durations (7/11/13) so each padded item occupies exactly one :15 slot. This makes a
        // whole :15-multiple block (Duration's 3h) tile exactly with no leftover — Duration therefore never
        // packs a final item unpadded to fill the block (its fill-the-duration contract legitimately
        // overrides per-item clock-pad when content straddles a :15, which would obscure the pad/seam signal
        // this test is pinning). Every mode then holds the same strict boundary invariant.
        int[] durationsMinutes = [7, 11, 13];
        var movies = new List<Movie>();
        for (var i = 1; i <= 3; i++)
        {
            movies.Add(new Movie
            {
                MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
                MovieMetadata = new List<MovieMetadata>
                {
                    new() { Title = $"Schedule Padded Mode Movie {mode} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            });
        }

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var contentCollection = new Collection
        {
            Name = $"Schedule Padded Mode Content Collection {mode}",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddAsync(contentCollection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        ProgramScheduleItem scheduleItem = mode switch
        {
            "Flood" => new ProgramScheduleItemFlood
            {
                Collection = contentCollection,
                CollectionId = contentCollection.Id,
                CollectionType = CollectionType.Collection,
                PlaybackOrder = PlaybackOrder.Chronological
            },
            "Duration" => new ProgramScheduleItemDuration
            {
                Collection = contentCollection,
                CollectionId = contentCollection.Id,
                CollectionType = CollectionType.Collection,
                PlayoutDuration = TimeSpan.FromHours(3),
                TailMode = TailMode.Offline,
                PlaybackOrder = PlaybackOrder.Chronological
            },
            "Multiple" => new ProgramScheduleItemMultiple
            {
                Collection = contentCollection,
                CollectionId = contentCollection.Id,
                CollectionType = CollectionType.Collection,
                MultipleMode = MultipleMode.Count,
                Count = "3",
                PlaybackOrder = PlaybackOrder.Chronological
            },
            _ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
        };

        var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };

        var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded Mode FFmpeg Profile {mode}" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(Guid.Parse(guid))
        {
            Name = $"Schedule Padded Mode Channel {mode}",
            Number = mode switch { "Flood" => "10", "Duration" => "11", _ => "12" },
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule
        {
            Name = $"Schedule Padded Mode Schedule {mode}",
            Items = scheduleItems,
            PadToNearestMinute = 15
        };
        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return playout.Id;
    }

    // Regression harness for the Fill-With-Group DeepCopy defect: same shape as
    // SeedSchedulePaddedModeData (off-boundary durations, no item-level Pad filler, no FallbackFiller
    // -> offline), except the single ProgramScheduleItemMultiple sets FillWithGroupMode so PlayoutBuilder
    // schedules it via a synthesized (DeepCopy'd) fake schedule item instead of the original.
    private async Task<int> SeedSchedulePaddedFillWithGroupData(CancellationToken cancellationToken)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        var path = new LibraryPath { Path = "Schedule Padded FillWithGroup LibraryPath" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Off-boundary durations (22/37/52 min), same as SeedSchedulePaddedData, so padding to :15 is visible.
        int[] durationsMinutes = [22, 37, 52];
        var movies = new List<Movie>();
        for (var i = 1; i <= 3; i++)
        {
            movies.Add(new Movie
            {
                MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
                MovieMetadata = new List<MovieMetadata>
                {
                    new() { Title = $"Schedule Padded FillWithGroup Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            });
        }

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var contentCollection = new Collection
        {
            Name = "Schedule Padded FillWithGroup Content Collection",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddAsync(contentCollection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var scheduleItem = new ProgramScheduleItemMultiple
        {
            Collection = contentCollection,
            CollectionId = contentCollection.Id,
            CollectionType = CollectionType.Collection,
            MultipleMode = MultipleMode.Count,
            Count = "3",
            PlaybackOrder = PlaybackOrder.Chronological,
            FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups
        };

        var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };

        var ffmpegProfile = new FFmpegProfile { Name = "Schedule Padded FillWithGroup FFmpeg Profile" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-00000000000d"))
        {
            Name = "Schedule Padded FillWithGroup Channel",
            Number = "13",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var schedule = new ProgramSchedule
        {
            Name = "Schedule Padded FillWithGroup Schedule",
            Items = scheduleItems,
            PadToNearestMinute = 15
        };
        await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ProgramSchedule = schedule,
            ProgramScheduleId = schedule.Id,
            ScheduleKind = PlayoutScheduleKind.Classic
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return playout.Id;
    }

    private async Task<List<PlayoutItem>> BuildSchedulePaddedFillWithGroupPlayout()
    {
        var cancellationToken = CancellationToken.None;

        int playoutId = await SeedSchedulePaddedFillWithGroupData(cancellationToken);

        var builder = new PlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
            new MockFileSystem(),
            Substitute.For<IRerunHelper>(),
            NullLogger<PlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);

        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            playout,
            referenceData,
            PlayoutBuildResult.Empty,
            PlayoutBuildMode.Reset,
            Start,
            Start.AddDays(2),
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return buildResult.AddedItems;
    }

    // --- Block builder ---
    //
    // BlockPlayoutBuilder maps template times-of-day to absolute instants via
    // EffectiveBlock.GetEffectiveBlocks(..., TimeZoneInfo.Local, ...), so its output is machine-timezone
    // dependent. This is a CHARACTERIZATION test: rather than change production code to inject the zone
    // (that seam is issue #380's scope), we capture the golden under UTC and GUARD with Assume.That so the
    // test RUNS under TZ=UTC (CI) and reports INCONCLUSIVE (a graceful skip, not a failure) under any other
    // TZ — mirroring ChannelPlaylistGoldenTests' GuardVolatileEnvironment. Classic + other TZ-independent
    // goldens are unaffected.
    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildBlockPlayout()
    {
        // Guard on the offset AT the build instant (GetUtcOffset(Start)), not BaseUtcOffset: the latter is
        // zero for DST zones like Europe/London year-round, so it would pass in a summer-dated fixture where
        // London != UTC. GetUtcOffset pins the actual instant and stays correct regardless of fixture date.
        Assume.That(
            TimeZoneInfo.Local.GetUtcOffset(Start),
            Is.EqualTo(TimeSpan.Zero),
            "Block golden is captured under UTC; run with TZ=UTC. A real TZ seam is issue #380's scope.");

        var cancellationToken = CancellationToken.None;

        var (playoutId, titles) = await SeedBlockData(cancellationToken);

        var builder = new BlockPlayoutBuilder(
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
            new ArtistRepository(_dbContextFactory),
            Substitute.For<ICollectionEtag>(),
            NullLogger<BlockPlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetBlockReferenceData(context, playoutId);

        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            Start,
            playout,
            referenceData,
            PlayoutBuildMode.Reset,
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedBlockData(
        CancellationToken cancellationToken)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        var path = new LibraryPath { Path = "Block LibraryPath" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Six movies, fixed titles + release dates, varied durations so chronological ordering and block
        // boundaries are visible across the scheduled blocks.
        int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
        var movies = new List<Movie>();
        for (var i = 1; i <= 6; i++)
        {
            var movie = new Movie
            {
                MediaVersions = new List<MediaVersion>
                {
                    new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
                },
                MovieMetadata = new List<MovieMetadata>
                {
                    new()
                    {
                        Title = $"Block Movie {i:D2}",
                        ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
                    }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            };
            movies.Add(movie);
        }

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);

        var collection = new Collection
        {
            Name = "Block Test Collection",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddAsync(collection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // A single 60-minute block with three chronological items over the same collection. With
        // AfterDurationEnd, each block fills until currentTime passes the block finish; history carries the
        // chronological cursor across the blocks scheduled on successive days.
        var blockGroup = new BlockGroup { Name = "Block Test Group" };
        await context.BlockGroups.AddAsync(blockGroup, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var block = new Block
        {
            BlockGroup = blockGroup,
            BlockGroupId = blockGroup.Id,
            Name = "Test Block",
            Minutes = 60,
            StopScheduling = BlockStopScheduling.AfterDurationEnd,
            Items = new List<BlockItem>
            {
                new()
                {
                    Index = 1,
                    CollectionType = CollectionType.Collection,
                    Collection = collection,
                    CollectionId = collection.Id,
                    PlaybackOrder = PlaybackOrder.Chronological
                },
                new()
                {
                    Index = 2,
                    CollectionType = CollectionType.Collection,
                    Collection = collection,
                    CollectionId = collection.Id,
                    PlaybackOrder = PlaybackOrder.Chronological
                },
                new()
                {
                    Index = 3,
                    CollectionType = CollectionType.Collection,
                    Collection = collection,
                    CollectionId = collection.Id,
                    PlaybackOrder = PlaybackOrder.Chronological
                }
            }
        };
        await context.Blocks.AddAsync(block, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var templateGroup = new TemplateGroup { Name = "Template Test Group" };
        await context.TemplateGroups.AddAsync(templateGroup, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var template = new Template
        {
            TemplateGroup = templateGroup,
            TemplateGroupId = templateGroup.Id,
            Name = "Test Template",
            Items = new List<TemplateItem>()
        };
        template.Items.Add(new TemplateItem
        {
            Block = block,
            BlockId = block.Id,
            StartTime = TimeSpan.FromHours(9)
        });
        await context.Templates.AddAsync(template, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var ffmpegProfile = new FFmpegProfile { Name = "Block FFmpeg Profile" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000002"))
        {
            Name = "Block Test Channel",
            Number = "2",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ScheduleKind = PlayoutScheduleKind.Block
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var playoutTemplate = new PlayoutTemplate
        {
            Playout = playout,
            PlayoutId = playout.Id,
            Template = template,
            TemplateId = template.Id,
            Index = 1,
            DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
            DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
            MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
        };
        await context.PlayoutTemplates.AddAsync(playoutTemplate, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private static async Task<PlayoutReferenceData> GetBlockReferenceData(TvContext dbContext, int playoutId)
    {
        Channel channel = await dbContext.Channels
            .AsNoTracking()
            .Where(c => c.Playouts.Any(p => p.Id == playoutId))
            .FirstOrDefaultAsync();

        List<PlayoutItem> existingItems = await dbContext.PlayoutItems
            .AsNoTracking()
            .Where(pi => pi.PlayoutId == playoutId)
            .ToListAsync();

        List<PlayoutTemplate> playoutTemplates = await dbContext.PlayoutTemplates
            .AsNoTracking()
            .Where(pt => pt.PlayoutId == playoutId)
            .Include(t => t.Template)
            .ThenInclude(t => t.Items)
            .ThenInclude(i => i.Block)
            .ThenInclude(b => b.Items)
            .Include(t => t.DecoTemplate)
            .ThenInclude(t => t.Items)
            .ThenInclude(i => i.Deco)
            .ToListAsync();

        return new PlayoutReferenceData(
            channel,
            Option<Deco>.None,
            existingItems,
            playoutTemplates,
            null,
            [],
            [],
            TimeSpan.Zero);
    }

    // --- Sequential (YAML) builder (issue #381) ---
    //
    // SequentialPlayoutBuilder reads its schedule from a YAML file at Playout.ScheduleFile. It checks the
    // file's existence through the injected IFileSystem but reads the bytes with the static System.IO.File,
    // so the test writes a REAL committed fixture on disk (Goldens/Fixtures/sequential-schedule.yml) and
    // only stubs IFileSystem.File.Exists -> true. The schema validator is stubbed (the real one loads a JSON
    // schema from a runtime cache folder that a unit test has no reason to populate); this golden locks the
    // BUILDER's PlayoutItem output, not the validator, which is a separate surface.
    private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSequentialPlayout()
    {
        var cancellationToken = CancellationToken.None;

        var (playoutId, titles) = await SeedSequentialData(cancellationToken);

        var fileSystem = Substitute.For<System.IO.Abstractions.IFileSystem>();
        fileSystem.File.Exists(Arg.Any<string>()).Returns(true);

        var validator = Substitute.For<ISequentialScheduleValidator>();
        validator.ValidateSchedule(Arg.Any<string>(), Arg.Any<bool>()).Returns(Task.FromResult(true));

        var builder = new SequentialPlayoutBuilder(
            fileSystem,
            new ConfigElementRepository(_dbContextFactory),
            new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
            Substitute.For<IChannelRepository>(),
            Substitute.For<IGraphicsElementRepository>(),
            validator,
            NullLogger<SequentialPlayoutBuilder>.Instance);

        await using TvContext context = _dbContextFactory.CreateDbContext();

        Playout playout = await context.Playouts
            .Include(p => p.ProgramScheduleAnchors)
            .ThenInclude(a => a.EnumeratorState)
            .Include(p => p.FillGroupIndices)
            .ThenInclude(fgi => fgi.EnumeratorState)
            .SingleAsync(p => p.Id == playoutId, cancellationToken);

        PlayoutReferenceData referenceData = await GetSequentialReferenceData(context, playoutId);

        // Reset over the pinned window: with no prior Anchor, Reset avoids the YamlPlayoutContext.Reset path
        // (its ToLocalTime() only runs on a saved-anchor Continue), keeping the build TZ-independent.
        Either<BaseError, PlayoutBuildResult> result = await builder.Build(
            Start,
            playout,
            referenceData,
            PlayoutBuildMode.Reset,
            cancellationToken);

        PlayoutBuildResult buildResult = result.Match(
            r => r,
            error => throw new AssertionException($"Build returned error: {error.Value}"));

        return (buildResult.AddedItems, titles);
    }

    private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedSequentialData(
        CancellationToken cancellationToken)
    {
        await using TvContext context = _dbContextFactory.CreateDbContext();

        var path = new LibraryPath { Path = "Sequential LibraryPath" };
        var library = new LocalLibrary
        {
            MediaKind = LibraryMediaKind.Movies,
            Paths = new List<LibraryPath> { path },
            MediaSource = new LocalMediaSource()
        };
        await context.Libraries.AddAsync(library, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Six movies, distinct release dates (so chronological order is unambiguous) and varied durations so
        // item boundaries are visible. Only the first four are scheduled (2 + 2 counts).
        int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
        var movies = new List<Movie>();
        for (var i = 1; i <= 6; i++)
        {
            var movie = new Movie
            {
                MediaVersions = new List<MediaVersion>
                {
                    new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
                },
                MovieMetadata = new List<MovieMetadata>
                {
                    new()
                    {
                        Title = $"Sequential Movie {i:D2}",
                        ReleaseDate = new DateTime(2005, 1, 1).AddDays(i)
                    }
                },
                LibraryPath = path,
                LibraryPathId = path.Id
            };
            movies.Add(movie);
        }

        await context.Movies.AddRangeAsync(movies, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);

        // Name must match the fixture YAML's `collection:` value — EnumeratorCache resolves content by
        // Collection.Name.
        var collection = new Collection
        {
            Name = "Sequential Test Collection",
            MediaItems = movies.Cast<MediaItem>().ToList()
        };
        await context.Collections.AddAsync(collection, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var ffmpegProfile = new FFmpegProfile { Name = "Sequential FFmpeg Profile" };
        await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
        var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000006"))
        {
            Name = "Sequential Test Channel",
            Number = "6",
            FFmpegProfile = ffmpegProfile,
            FFmpegProfileId = ffmpegProfile.Id
        };
        await context.Channels.AddAsync(channel, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        // Sequential playout: no ProgramSchedule; content comes from the YAML ScheduleFile.
        var playout = new Playout
        {
            Channel = channel,
            ChannelId = channel.Id,
            ScheduleKind = PlayoutScheduleKind.Sequential,
            ScheduleFile = Path.Combine(FixtureDir(), "sequential-schedule.yml")
        };
        await context.Playouts.AddAsync(playout, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return (playout.Id, titles);
    }

    private static async Task<PlayoutReferenceData> GetSequentialReferenceData(TvContext dbContext, int playoutId)
    {
        Channel channel = await dbContext.Channels
            .AsNoTracking()
            .Where(c => c.Playouts.Any(p => p.Id == playoutId))
            .FirstOrDefaultAsync();

        // Sequential reads content from the YAML file, not a ProgramSchedule; empty history + a fresh build.
        return new PlayoutReferenceData(
            channel,
            Option<Deco>.None,
            [],
            [],
            null,
            [],
            [],
            TimeSpan.Zero);
    }

    // One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
    // serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
    private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
    {
        var ordered = items
            .OrderBy(i => i.Start)
            .ThenBy(i => i.MediaItemId)
            .ToList();

        var sb = new StringBuilder();
        for (var index = 0; index < ordered.Count; index++)
        {
            PlayoutItem item = ordered[index];
            string title = titles.TryGetValue(item.MediaItemId, out string t) ? t : $"#{item.MediaItemId}";
            sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
            sb.Append(" | ");
            sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
            sb.Append(" - ");
            sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
            sb.Append(" | ");
            sb.Append(item.FillerKind.ToString());
            sb.Append(" | ");
            sb.Append(title);
            sb.Append('\n');
        }

        return sb.ToString();
    }

    private static string Canonicalize(string text) =>
        text.TrimStart('﻿').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";

    private static string GoldenDir([CallerFilePath] string thisFile = "") =>
        Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");

    // Committed YAML input fixtures (not golden outputs) for builders that read a schedule file. Note
    // GoldenDir nests as Goldens/Goldens (this test file already lives under Goldens/), so fixtures sit in a
    // sibling Goldens/Fixtures to keep inputs and snapshot outputs visually separate.
    private static string FixtureDir([CallerFilePath] string thisFile = "") =>
        Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");

    private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
    {
        public TvContext CreateDbContext() =>
            new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
    }
}
