/**
 * The phone's progress line (#58).
 *
 * **What this tier can and cannot see.** It can see the whole of what
 * the issue asks for that is not a pixel: that the line exists only on
 * a phone and only with a track, that it renders the position the
 * backend reported rather than a count of its own, and that it is
 * neither announced nor touchable. It cannot see where it sits — that
 * is the shell's grid, and it is asserted in
 * `e2e/specs/phone-progress-line.spec.ts` where there is a real bar
 * with a real tab bar under it.
 */
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';

import '@components/audio-player/progress-line/progress-line';
import { Events } from '../../src/events';
import { emit, flush } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';

const TRACK = {
    fileName: 'song.mp3',
    filePath: '/music/song.mp3',
    trackLength: 90,
    seekPosition: 0,
    state: 'playing',
    title: 'Song',
    artist: 'Artist',
    album: 'Album',
    coverArt: '',
    coverArtSmall: '',
    coverArtMedium: '',
    coverArtLarge: '',
    trackChangeId: 1,
    artistMbid: '',
    releaseGroupMbid: '',
    recordingMbid: '',
};

/**
 * Answer `matchMedia` for the phone query, since the runner's own
 * window is whatever size the browser provider gives it. Stubbed rather
 * than resized for `transport-context.test.ts`'s reason: what is under
 * test is the component's reaction to the answer.
 */
const realMatchMedia = window.matchMedia;

function pretendPhone(phone: boolean): void {
    window.matchMedia = ((query: string) => ({
        matches: phone && query.includes('599'),
        media: query,
        addEventListener: () => {},
        removeEventListener: () => {},
    })) as unknown as typeof window.matchMedia;
}

/** The horizontal scale of the fill, or null if there is no line. */
function scale(el: Element): number | null {
    const fill = shadow<HTMLElement>(el, '.fill');

    if (!fill) return null;

    const match = /scaleX\(([^)]+)\)/.exec(fill.style.transform);

    return match ? Number(match[1]) : null;
}

describe('<player-progress-line>', () => {
    beforeEach(() => {
        emit(Events.TrackChanged, null);
        emit(Events.PlaybackStateChanged, { state: 'stopped' });
    });

    afterEach(() => {
        window.matchMedia = realMatchMedia;
        vi.useRealTimers();
    });

    it('draws nothing above the phone breakpoint', async () => {
        pretendPhone(false);

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 2 });
        emit(Events.PlaybackPositionChanged, {
            positionSeconds: 45,
            trackLength: 90,
            trackChangeId: 2,
            seq: 1,
            playing: true,
        });
        await flush();
        await el.updateComplete;

        // The desktop bar carries a real seek bar, and there is no tab
        // bar for this to sit on the border of.
        expect(el.shadowRoot!.querySelector('.track')).toBeNull();
    });

    it('draws nothing until there is a track', async () => {
        pretendPhone(true);

        const el = await fixture('player-progress-line');

        expect(el.shadowRoot!.querySelector('.track')).toBeNull();
    });

    it('renders the fraction the backend reported', async () => {
        pretendPhone(true);

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 3 });
        emit(Events.PlaybackPositionChanged, {
            positionSeconds: 45,
            trackLength: 90,
            trackChangeId: 3,
            seq: 1,
            playing: true,
        });
        await flush();
        await el.updateComplete;

        expect(scale(el)).toBeCloseTo(0.5, 3);
    });

    it('resumes mid-track at the position the track arrived with', async () => {
        pretendPhone(true);

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, {
            ...TRACK,
            seekPosition: 30,
            trackChangeId: 4,
        });
        await flush();
        await el.updateComplete;

        expect(scale(el)).toBeCloseTo(1 / 3, 3);
    });

    it('interpolates between reports, and every report resets it', async () => {
        pretendPhone(true);
        vi.useFakeTimers();

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
        emit(Events.PlaybackStateChanged, { state: 'playing' });
        await vi.advanceTimersByTimeAsync(3000);
        await el.updateComplete;

        expect(scale(el)).toBeCloseTo(3 / 90, 3);

        // The user seeks; the backend lands somewhere else and says so.
        // The local count is discarded, never added to -- the seek
        // bar's rule, and the reason it has it.
        emit(Events.PlaybackPositionChanged, {
            positionSeconds: 40,
            trackLength: 90,
            trackChangeId: 5,
            seq: 2,
            playing: true,
        });
        await vi.advanceTimersByTimeAsync(1000);
        await el.updateComplete;

        expect(scale(el)).toBeCloseTo(41 / 90, 3);
    });

    /*
     * The reason this component asks `matchMedia` instead of letting a
     * stylesheet hide it: a media query cannot stop a 1 Hz interval
     * running for the life of every desktop session. That claim is
     * load-bearing in CLAUDE.md, so it is asserted rather than
     * described — the timer count, because a desktop render is empty
     * either way and so cannot tell the two apart.
     */
    it('runs no interpolation timer above the breakpoint', async () => {
        pretendPhone(false);
        vi.useFakeTimers();

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 9 });
        emit(Events.PlaybackStateChanged, { state: 'playing' });
        emit(Events.PlaybackPositionChanged, {
            positionSeconds: 3,
            trackLength: 90,
            trackChangeId: 9,
            seq: 9,
            playing: true,
        });
        await vi.advanceTimersByTimeAsync(5000);
        await el.updateComplete;

        expect(scale(el)).toBeNull();
        expect(vi.getTimerCount()).toBe(0);
    });

    it('ignores a report about a track that is no longer loaded', async () => {
        pretendPhone(true);

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
        emit(Events.PlaybackPositionChanged, {
            positionSeconds: 60,
            trackLength: 90,
            trackChangeId: 5,
            seq: 3,
            playing: true,
        });
        await flush();
        await el.updateComplete;

        // The store is a singleton, so a line mounting late must not
        // adopt a report about the previous track.
        expect(scale(el)).toBe(0);
    });

    it('counts nothing while the player is paused', async () => {
        pretendPhone(true);
        vi.useFakeTimers();

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
        emit(Events.PlaybackPositionChanged, {
            positionSeconds: 10,
            trackLength: 90,
            trackChangeId: 7,
            seq: 1,
            playing: false,
        });
        emit(Events.PlaybackStateChanged, { state: 'paused' });
        await vi.advanceTimersByTimeAsync(5000);
        await el.updateComplete;

        expect(scale(el)).toBeCloseTo(10 / 90, 3);
    });

    it('is decorative and cannot be touched', async () => {
        pretendPhone(true);

        const el = await fixture('player-progress-line');

        emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
        await flush();
        await el.updateComplete;

        // The seek bar on Now Playing is what announces the position;
        // this says the same thing with no name and no way to act on
        // it, and it sits exactly where a thumb aiming at a tab lands.
        expect(el.getAttribute('aria-hidden')).toBe('true');
        expect(getComputedStyle(el).pointerEvents).toBe('none');
    });
});
