import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { describe, expect, it } from 'vitest';
import {
    BREAK_GAP_TOLERANCE_SECONDS,
    buildDayPlacementContext,
    decideBreakPlacement,
    findAdjacentBreakSlot,
    findBreakSlotNearInDay,
    findValidBreakGap,
    findValidBreakGapNear,
    planMoveInsert,
    planSplitEntry,
    suggestMovePlan,
    type MovableInterval,
} from './breakPlacementMath';

dayjs.extend(utc);

const HALF_HOUR = 1800;
const HOUR = 3600;
const DAY = '2026-07-14';
const dayStart = `${DAY}T00:00:00Z`;
const dayEnd = `${DAY}T24:00:00Z`;
const MIDNIGHT = '2026-07-15T00:00:00Z';

function iv(startH: number, endH: number) {
    const h = (n: number) => {
        const totalMin = Math.round(n * 60);
        const hh = Math.floor(totalMin / 60);
        const mm = totalMin % 60;
        return `${DAY}T${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00Z`;
    };
    return { start: h(startH), end: h(endH) };
}

describe('decideBreakPlacement', () => {
    const context = (work: MovableInterval[] = []) => ({
        work,
        breaks: [],
        blocked: [],
        dayStart,
        dayEnd: MIDNIGHT,
    });
    const movable = (id: string, startH: number, endH: number): MovableInterval => ({
        id,
        ...iv(startH, endH),
    });

    it('returns a direct save when an existing gap fits', () => {
        const decision = decideBreakPlacement({
            date: DAY,
            durationSeconds: HOUR,
            context: context([movable('morning', 9, 12), movable('afternoon', 13, 17)]),
        });

        expect(decision).toEqual({
            kind: 'save',
            slot: { start: `${DAY}T12:00:00Z`, end: `${DAY}T13:00:00Z` },
        });
    });

    it('selects generic free-window placement for an empty day', () => {
        expect(
            decideBreakPlacement({
                date: DAY,
                durationSeconds: HOUR,
                context: context(),
            })
        ).toEqual({ kind: 'place-in-free-window' });
    });

    it('returns a modal request when work must be rearranged', () => {
        const decision = decideBreakPlacement({
            date: DAY,
            durationSeconds: HOUR,
            context: context([movable('work', 9, 17)]),
        });

        expect(decision).toEqual(
            expect.objectContaining({
                kind: 'needs-input',
                request: expect.objectContaining({
                    date: DAY,
                    defaultBreakStart: `${DAY}T13:00:00Z`,
                }),
            })
        );
    });

    it('saves the break next to work too short to split', () => {
        const decision = decideBreakPlacement({
            date: DAY,
            durationSeconds: HALF_HOUR,
            context: context([movable('quick', 9, 9 + 1 / 60)]),
        });

        expect(decision).toEqual({
            kind: 'save',
            slot: { start: `${DAY}T09:01:00Z`, end: `${DAY}T09:31:00Z` },
        });
    });

    it('rejects a day that cannot fit or rearrange the break', () => {
        expect(
            decideBreakPlacement({
                date: DAY,
                durationSeconds: HOUR,
                context: context([movable('all-day', 0, 24)]),
            })
        ).toEqual({ kind: 'reject' });
    });
});

describe('findValidBreakGap', () => {
    it('centers the break in a gap that fits within tolerance', () => {
        // 09-12 and 13-17 → 1h gap, 30m break → centered at 12:15-12:45
        const gap = findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR);
        expect(gap).toEqual({ start: `${DAY}T12:15:00Z`, end: `${DAY}T12:45:00Z` });
    });

    it('rejects a gap that is too small for the break', () => {
        // 09-12 and 12:15-17 → 15m gap, 30m break does not fit
        expect(findValidBreakGap([iv(9, 12), iv(12.25, 17)], HALF_HOUR)).toBeNull();
    });

    it('places the break flush after work in an oversized gap instead of rejecting it', () => {
        // 09-12 and 14-17 → 2h gap. No placement keeps both sides within tolerance,
        // but the gap easily holds the break — place it flush after the first entry
        // and leave the gap otherwise untouched (distance to work is only a soft hint).
        expect(findValidBreakGap([iv(9, 12), iv(14, 17)], HALF_HOUR)).toEqual({
            start: `${DAY}T12:00:00Z`,
            end: `${DAY}T12:30:00Z`,
        });
    });

    it('prefers a within-tolerance gap over an earlier oversized gap', () => {
        // 09-10, 13-14, 15-16: the first gap (3h) is oversized, the second (1h) is
        // valid → center in the second instead of going flush-left in the first.
        expect(findValidBreakGap([iv(9, 10), iv(13, 14), iv(15, 16)], HALF_HOUR)).toEqual({
            start: `${DAY}T14:15:00Z`,
            end: `${DAY}T14:45:00Z`,
        });
    });

    it('slides past an obstacle when placing into an oversized gap', () => {
        // 09-12 and 16-17 with an existing break flush at 12:00 → the new break
        // lands right after that break.
        expect(findValidBreakGap([iv(9, 12), iv(16, 17)], HALF_HOUR, [iv(12, 12.75)])).toEqual({
            start: `${DAY}T12:45:00Z`,
            end: `${DAY}T13:15:00Z`,
        });
    });

    it('does not fabricate a gap from an entry contained in a longer one', () => {
        // 10-11 sits inside 09-17; the only real gap is 17:00-18:00 → centered there.
        expect(findValidBreakGap([iv(9, 17), iv(10, 11), iv(18, 19)], HALF_HOUR)).toEqual({
            start: `${DAY}T17:15:00Z`,
            end: `${DAY}T17:45:00Z`,
        });
    });

    it('accepts a gap exactly at duration + 2*tolerance', () => {
        const gapEnd = 12 + (HALF_HOUR + 2 * BREAK_GAP_TOLERANCE_SECONDS) / HOUR;
        const gap = findValidBreakGap([iv(9, 12), iv(gapEnd, gapEnd + 1)], HALF_HOUR);
        expect(gap).not.toBeNull();
    });

    it('returns null when there is only one work entry', () => {
        expect(findValidBreakGap([iv(9, 17)], HALF_HOUR)).toBeNull();
    });

    it('skips a gap already occupied by another break', () => {
        // The only valid gap (12:15–12:45) is taken by an existing break → no auto placement
        expect(
            findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR, [
                { start: `${DAY}T12:15:00Z`, end: `${DAY}T12:45:00Z` },
            ])
        ).toBeNull();
    });

    it('ignores obstacles that fall outside the chosen gap', () => {
        expect(findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR, [iv(20, 21)])).toEqual({
            start: `${DAY}T12:15:00Z`,
            end: `${DAY}T12:45:00Z`,
        });
    });
});

describe('planSplitEntry', () => {
    const workSeconds = (plan: NonNullable<ReturnType<typeof planSplitEntry>>) =>
        dayjs.utc(plan.firstHalf.end).diff(dayjs.utc(plan.firstHalf.start), 'second') +
        dayjs.utc(plan.secondHalf.end).diff(dayjs.utc(plan.secondHalf.start), 'second');

    it('splits a single entry in the middle and keeps the work length', () => {
        const plan = planSplitEntry(iv(9, 17), HALF_HOUR);
        expect(plan).not.toBeNull();
        expect(plan!.firstHalf.start).toBe(`${DAY}T09:00:00Z`);
        expect(plan!.breakSlot.start).toBe(plan!.firstHalf.end);
        expect(plan!.secondHalf.start).toBe(plan!.breakSlot.end);
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T13:00:00Z`, end: `${DAY}T13:30:00Z` });
        expect(plan!.secondHalf.end).toBe(`${DAY}T17:30:00Z`);
        expect(workSeconds(plan!)).toBe(8 * HOUR);
    });

    it('accepts a break far longer than the work entry', () => {
        const plan = planSplitEntry(iv(9, 9.5), 4 * HOUR);
        expect(plan).not.toBeNull();
        expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:15:00Z` });
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T09:15:00Z`, end: `${DAY}T13:15:00Z` });
        expect(plan!.secondHalf).toEqual({ start: `${DAY}T13:15:00Z`, end: `${DAY}T13:30:00Z` });
        expect(workSeconds(plan!)).toBe(HALF_HOUR);
    });

    it('honors an explicit break start', () => {
        const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T10:00:00Z`);
        expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T10:00:00Z` });
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T10:30:00Z` });
        expect(plan!.secondHalf).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T17:30:00Z` });
        expect(workSeconds(plan!)).toBe(8 * HOUR);
    });

    it('returns null when the entry is too short to leave work on both sides', () => {
        expect(
            planSplitEntry({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:30Z` }, HALF_HOUR)
        ).toBeNull();
    });

    it('rejects an explicit break start before the entry instead of clamping it', () => {
        expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T07:00:00Z`)).toBeNull();
    });

    it('rejects an explicit break start at or after the end of the entry', () => {
        expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T17:00:00Z`)).toBeNull();
        expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T18:00:00Z`)).toBeNull();
    });

    it('rejects an explicit break start that leaves less than the minimum fragment', () => {
        expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T09:00:30Z`)).toBeNull();
        expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:59:30Z`)).toBeNull();
    });

    it('accepts an explicit break start leaving exactly the minimum fragment on each side', () => {
        const plan = planSplitEntry(iv(9, 9 + 2 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
        expect(plan).not.toBeNull();
        expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:00Z` });
        expect(plan!.secondHalf).toEqual({ start: `${DAY}T09:31:00Z`, end: `${DAY}T09:32:00Z` });
    });

    it('leaves later entries alone when the extended work does not reach them', () => {
        const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
            otherEntries: [
                { id: 'early', ...iv(8, 8.5) },
                { id: 'later', ...iv(18, 19) },
            ],
        });
        expect(plan!.secondHalf).toEqual({ start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` });
        expect(plan!.shifted).toEqual([]);
    });

    it('pushes only the collision chain and preserves later entry times', () => {
        const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
            otherEntries: [
                { id: 'first', ...iv(17.25, 17.5) },
                { id: 'second', ...iv(17.6, 17.9) },
                { id: 'distant', ...iv(20, 21) },
            ],
        });

        // Moving the first break also collides with the second.
        expect(plan!.shifted).toEqual([
            { id: 'first', ...iv(17.5, 17.75) },
            { id: 'second', ...iv(17.75, 18.05) },
        ]);
    });

    it('starts the work earlier when pushing it later would leave the day', () => {
        const plan = planSplitEntry(iv(22, 23.5), HOUR, undefined, { dayStart, dayEnd });
        expect(plan).not.toBeNull();
        expect(plan!.firstHalf).toEqual({ start: `${DAY}T21:30:00Z`, end: `${DAY}T22:15:00Z` });
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T22:15:00Z`, end: `${DAY}T23:15:00Z` });
        expect(plan!.secondHalf).toEqual({ start: `${DAY}T23:15:00Z`, end: MIDNIGHT });
        expect(workSeconds(plan!)).toBe(1.5 * HOUR);
    });

    it('reproduces its own plan when re-planned from the break start it chose', () => {
        const options = { dayStart, dayEnd };
        const suggested = planSplitEntry(iv(22, 23.5), HOUR, undefined, options);
        const replanned = planSplitEntry(iv(22, 23.5), HOUR, suggested!.breakSlot.start, options);
        expect(replanned).toEqual(suggested);
    });

    it('returns null when the block cannot fit in the day at all', () => {
        expect(planSplitEntry(iv(0, 23), 2 * HOUR, undefined, { dayStart, dayEnd })).toBeNull();
    });

    it('returns null when starting earlier would run into an entry that stays put', () => {
        expect(
            planSplitEntry(iv(22, 23.75), HOUR, undefined, {
                dayStart,
                dayEnd,
                otherEntries: [{ id: 'earlier', ...iv(21, 21.5) }],
            })
        ).toBeNull();
    });

    it('returns null when the actual collision chain would leave the day', () => {
        expect(
            planSplitEntry(iv(22, 23.5), HOUR, undefined, {
                dayStart,
                dayEnd,
                otherEntries: [{ id: 'late', ...iv(23.75, 24) }],
            })
        ).toBeNull();
    });

    it('does not reject a valid split because of an unrelated late entry', () => {
        const plan = planSplitEntry(iv(9, 10), HOUR, `${DAY}T09:30:00Z`, {
            dayStart,
            dayEnd,
            otherEntries: [{ id: 'late', ...iv(23.5, 24) }],
        });

        expect(plan).not.toBeNull();
        expect(plan!.secondHalf.end).toBe(`${DAY}T11:00:00Z`);
        expect(plan!.shifted).toEqual([]);
    });
});

describe('findBreakSlotNearInDay', () => {
    const anchor = `${DAY}T23:00:00Z`;

    it('keeps the break where it is when the new duration still fits', () => {
        expect(findBreakSlotNearInDay(dayStart, dayEnd, HALF_HOUR, anchor)).toEqual({
            start: anchor,
            end: `${DAY}T23:30:00Z`,
        });
    });

    it('slides the break earlier instead of growing past midnight', () => {
        expect(findBreakSlotNearInDay(dayStart, dayEnd, 2 * HOUR, anchor)).toEqual({
            start: `${DAY}T22:00:00Z`,
            end: MIDNIGHT,
        });
    });

    it('respects a day window shortened by a running entry', () => {
        expect(findBreakSlotNearInDay(dayStart, `${DAY}T12:00:00Z`, HOUR, anchor)).toEqual({
            start: `${DAY}T11:00:00Z`,
            end: `${DAY}T12:00:00Z`,
        });
    });

    it('returns null when the day has no free window big enough', () => {
        expect(findBreakSlotNearInDay(dayStart, dayEnd, 20 * HOUR, anchor, [iv(6, 18)])).toBeNull();
    });
});

describe('findAdjacentBreakSlot', () => {
    it('parks the break flush after the last work entry', () => {
        expect(findAdjacentBreakSlot([iv(9, 9.5)], dayStart, dayEnd, HALF_HOUR)).toEqual({
            start: `${DAY}T09:30:00Z`,
            end: `${DAY}T10:00:00Z`,
        });
    });

    it('falls back to flush before the first work entry', () => {
        expect(findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR)).toEqual({
            start: `${DAY}T08:30:00Z`,
            end: `${DAY}T09:00:00Z`,
        });
    });

    it('skips a candidate blocked by an existing break', () => {
        expect(
            findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR, [iv(8.75, 9)])
        ).toBeNull();
    });

    it('returns null when neither side fits inside the day', () => {
        expect(findAdjacentBreakSlot([iv(0, 24)], dayStart, dayEnd, HALF_HOUR)).toBeNull();
    });
});

describe('planMoveInsert', () => {
    const movable = (id: string, startH: number, endH: number): MovableInterval => ({
        id,
        ...iv(startH, endH),
    });

    it('pushes the right block later to open a slot for the break', () => {
        // Back-to-back 09-12 and 12-17. Insert 30m break at 12:00.
        const plan = planMoveInsert(
            [movable('a', 9, 12), movable('b', 12, 17)],
            dayStart,
            dayEnd,
            `${DAY}T12:00:00Z`,
            HALF_HOUR
        );
        expect(plan).not.toBeNull();
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
        // 'a' untouched (not in shifted), 'b' shifted +30m
        expect(plan!.shifted).toEqual([
            { id: 'b', start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` },
        ]);
    });

    it('shifts each side only as much as needed to clear the slot', () => {
        // Break 14:45-15:15 overlaps only the start of 'b' → 'b' pushed 15m later,
        // 'a' untouched.
        const plan = planMoveInsert(
            [movable('a', 9, 12), movable('b', 15, 17)],
            dayStart,
            dayEnd,
            `${DAY}T14:45:00Z`,
            HALF_HOUR
        );
        expect(plan!.shifted).toEqual([
            { id: 'b', start: `${DAY}T15:15:00Z`, end: `${DAY}T17:15:00Z` },
        ]);
    });

    it('pulls the left block earlier only when it overlaps the slot', () => {
        // Break 11:45-12:15 overlaps the end of 'a' → 'a' pulled 15m earlier;
        // 'b' (15-17) already clears the slot and stays put.
        const plan = planMoveInsert(
            [movable('a', 9, 12), movable('b', 15, 17)],
            dayStart,
            dayEnd,
            `${DAY}T11:45:00Z`,
            HALF_HOUR
        );
        expect(plan!.shifted).toEqual([
            { id: 'a', start: `${DAY}T08:45:00Z`, end: `${DAY}T11:45:00Z` },
        ]);
    });

    it('shifts the left block earlier when the right block cannot move within the day', () => {
        // Right entry ends at 23:50; pushing it later would cross midnight, so the
        // left block must move earlier instead.
        const plan = planMoveInsert(
            [movable('a', 9, 12), movable('b', 12, 23 + 50 / 60)],
            dayStart,
            dayEnd,
            `${DAY}T12:00:00Z`,
            HALF_HOUR
        );
        // Not feasible by pushing right; solver returns null (caller lets the user pick another spot)
        expect(plan).toBeNull();
    });

    it('returns null when the break itself would fall outside the day', () => {
        expect(
            planMoveInsert([movable('a', 9, 12)], dayStart, dayEnd, `${DAY}T23:50:00Z`, HALF_HOUR)
        ).toBeNull();
    });

    it('places a break between entries without shifting when they already have exactly the gap', () => {
        const plan = planMoveInsert(
            [movable('a', 9, 12), movable('b', 12.5, 17)],
            dayStart,
            dayEnd,
            `${DAY}T12:00:00Z`,
            HALF_HOUR
        );
        // gap is exactly 30m → 'b' already starts at break end, nothing to shift
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
        expect(plan!.shifted).toEqual([]);
    });
});

describe('suggestMovePlan', () => {
    const movable = (id: string, startH: number, endH: number): MovableInterval => ({
        id,
        ...iv(startH, endH),
    });

    it('finds a flush-after placement for back-to-back entries', () => {
        const plan = suggestMovePlan(
            [movable('a', 9, 12), movable('b', 12, 17)],
            dayStart,
            dayEnd,
            HALF_HOUR
        );
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
    });

    it('falls back to a flush-before placement when the day is nearly full at the end', () => {
        // 09-12 and 12-23:50: pushing right past midnight is impossible, so the break
        // is placed just before the second entry, pulling the first entry earlier.
        const plan = suggestMovePlan(
            [movable('a', 9, 12), movable('b', 12, 23 + 50 / 60)],
            dayStart,
            dayEnd,
            HALF_HOUR
        );
        expect(plan).not.toBeNull();
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T11:30:00Z`, end: `${DAY}T12:00:00Z` });
        // first entry pulled 30m earlier, second untouched
        expect(plan!.shifted.find((s) => s.id === 'a')).toEqual({
            id: 'a',
            start: `${DAY}T08:30:00Z`,
            end: `${DAY}T11:30:00Z`,
        });
    });

    it('returns null when the day is completely full', () => {
        const plan = suggestMovePlan([movable('a', 0, 24)], dayStart, dayEnd, HALF_HOUR);
        expect(plan).toBeNull();
    });

    it('moves existing breaks along with the surrounding work', () => {
        // Fully packed day: work 09-12, break 12-12:30, work 12:30-17. Opening a
        // slot after the morning work pushes the existing break and the afternoon
        // work later together — the plan never lands on top of the break.
        const plan = suggestMovePlan(
            [movable('a', 9, 12), movable('c', 12.5, 17)],
            dayStart,
            dayEnd,
            HALF_HOUR,
            [movable('x', 12, 12.5)]
        );
        expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
        expect([...plan!.shifted].sort((a, b) => a.id.localeCompare(b.id))).toEqual([
            { id: 'c', start: `${DAY}T13:00:00Z`, end: `${DAY}T17:30:00Z` },
            { id: 'x', start: `${DAY}T12:30:00Z`, end: `${DAY}T13:00:00Z` },
        ]);
    });
});

describe('buildDayPlacementContext', () => {
    const PREV_DAY = '2026-07-13';
    const entry = (id: string, start: string, end: string | null, type = 'work') => ({
        id,
        start,
        end,
        type,
    });

    it('separates movable work and breaks fully inside the day', () => {
        const ctx = buildDayPlacementContext(
            [
                entry('w1', `${DAY}T09:00:00Z`, `${DAY}T12:00:00Z`),
                entry('b1', `${DAY}T12:00:00Z`, `${DAY}T12:30:00Z`, 'break'),
                entry('w2', `${DAY}T13:00:00Z`, `${DAY}T17:00:00Z`),
            ],
            dayStart,
            dayEnd
        );
        expect(ctx.work.map((e) => e.id)).toEqual(['w1', 'w2']);
        expect(ctx.breaks.map((e) => e.id)).toEqual(['b1']);
        expect(ctx.dayStart).toBe(`${DAY}T00:00:00Z`);
        // `T24:00` normalizes to the next day's midnight
        expect(ctx.dayEnd).toBe(`2026-07-15T00:00:00Z`);
    });

    it('excludes the break being re-placed', () => {
        const ctx = buildDayPlacementContext(
            [entry('b1', `${DAY}T12:00:00Z`, `${DAY}T12:30:00Z`, 'break')],
            dayStart,
            dayEnd,
            'b1'
        );
        expect(ctx.breaks).toEqual([]);
    });

    it('turns entries crossing midnight into walls that shrink the day window', () => {
        // 22:00 (prev day) - 02:00 spills in; 23:00 - 01:00 (next day) spills out.
        const ctx = buildDayPlacementContext(
            [
                entry('overnight', `${PREV_DAY}T22:00:00Z`, `${DAY}T02:00:00Z`),
                entry('w1', `${DAY}T09:00:00Z`, `${DAY}T17:00:00Z`),
                entry('late', `${DAY}T23:00:00Z`, `2026-07-15T01:00:00Z`),
            ],
            dayStart,
            dayEnd
        );
        // Boundary-crossers are not movable...
        expect(ctx.work.map((e) => e.id)).toEqual(['w1']);
        // ...but clamp the usable window so nothing can be shifted into them.
        expect(ctx.dayStart).toBe(`${DAY}T02:00:00Z`);
        expect(ctx.dayEnd).toBe(`${DAY}T23:00:00Z`);
    });

    it('ignores entries on other days', () => {
        const ctx = buildDayPlacementContext(
            [entry('other-day', `${PREV_DAY}T09:00:00Z`, `${PREV_DAY}T10:00:00Z`)],
            dayStart,
            dayEnd
        );
        expect(ctx.work).toEqual([]);
        expect(ctx.breaks).toEqual([]);
        expect(ctx.blocked).toEqual([]);
    });

    it('turns a running entry into a blocker that caps the day window', () => {
        const ctx = buildDayPlacementContext(
            [
                entry('w1', `${DAY}T06:00:00Z`, `${DAY}T08:00:00Z`),
                entry('running', `${DAY}T09:00:00Z`, null),
            ],
            dayStart,
            dayEnd
        );
        // The running entry is not movable, blocks the day from its start on,
        // and nothing can be shifted to or past it.
        expect(ctx.work.map((e) => e.id)).toEqual(['w1']);
        expect(ctx.blocked).toEqual([{ start: `${DAY}T09:00:00Z`, end: dayEnd }]);
        expect(ctx.dayEnd).toBe(`${DAY}T09:00:00Z`);
    });
});

describe('findValidBreakGapNear', () => {
    // 09-10 and 11:30-12:30 → a 90-min gap (10:00-11:30). A 1h break has a valid
    // start window of 10:00-10:30; findValidBreakGap would center it at 10:15.
    const work = [iv(9, 10), iv(11.5, 12.5)];

    it('keeps the break at its current start instead of recentering', () => {
        const gap = findValidBreakGapNear(work, HOUR, `${DAY}T10:00:00Z`);
        expect(gap).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T11:00:00Z` });
    });

    it('keeps the break in place inside an oversized gap', () => {
        // 09-10 and 14-15 → 4h gap. The break stays exactly where the user left it;
        // its distance from work is a soft hint, not a reason to move it.
        const wideWork = [iv(9, 10), iv(14, 15)];
        expect(findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T11:00:00Z`)).toEqual({
            start: `${DAY}T11:00:00Z`,
            end: `${DAY}T11:30:00Z`,
        });
    });

    it('clamps the anchor so the break stays inside the gap', () => {
        // Anchored at 13:50 in the 10:00-14:00 gap → a 30m break would spill into
        // the next work entry, so it is clamped back to 13:30-14:00.
        const wideWork = [iv(9, 10), iv(14, 15)];
        expect(findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T13:50:00Z`)).toEqual({
            start: `${DAY}T13:30:00Z`,
            end: `${DAY}T14:00:00Z`,
        });
    });

    it("returns null when the anchor's gap can't hold the new duration", () => {
        // 09-10 and 10:30-12:30 → only a 30-min gap; a 1h break no longer fits.
        const tightWork = [iv(9, 10), iv(10.5, 12.5)];
        expect(findValidBreakGapNear(tightWork, HOUR, `${DAY}T10:00:00Z`)).toBeNull();
    });

    it('returns null when the anchor sits outside every inter-work gap', () => {
        // Anchor before the first work entry — a genuinely misplaced break, which the
        // caller then re-places via findValidBreakGap instead.
        expect(findValidBreakGapNear(work, HOUR, `${DAY}T08:00:00Z`)).toBeNull();
    });

    it('returns null when no free window in the gap can hold the break', () => {
        // Another break occupies 10:00-11:00; the leftover windows (none before,
        // 30m after) can't hold a 1h break → fall back to findValidBreakGap.
        expect(findValidBreakGapNear(work, HOUR, `${DAY}T10:00:00Z`, [iv(10, 11)])).toBeNull();
    });

    it('slides past a neighboring break inside the same gap instead of bailing', () => {
        // Gap 10:00-12:00 between work; another break sits at 10:30-11:00. Growing
        // the 10:00 break to 45m no longer fits before it, so it settles right
        // after the neighbor (11:00) — not in a different gap across the day.
        const wideWork = [iv(9, 10), iv(12, 13)];
        expect(findValidBreakGapNear(wideWork, 2700, `${DAY}T10:00:00Z`, [iv(10.5, 11)])).toEqual({
            start: `${DAY}T11:00:00Z`,
            end: `${DAY}T11:45:00Z`,
        });
    });

    it('settles in the free window closest to the anchor', () => {
        // Gap 10:00-13:00 with obstacles 10:45-11:00 and 11:15-12:30. For a 30m
        // break anchored at 10:50 the candidates are 10:15 (35m away) and 12:30
        // (100m away); the middle window is too small.
        const wideWork = [iv(9, 10), iv(13, 14)];
        expect(
            findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T10:50:00Z`, [
                iv(10.75, 11),
                iv(11.25, 12.5),
            ])
        ).toEqual({ start: `${DAY}T10:15:00Z`, end: `${DAY}T10:45:00Z` });
    });
});
