// ---------------------------------------------------------------------------
// What is imported here is what is parsed and evaluated before first
// paint.  Everything else is a chunk fetched on the navigation that
// needs it (VIEW_LOADERS / DETAIL_LOADERS below) and warmed in the
// background once the app is idle, so only the *first* second of the
// session pays for the views the user has not asked for.
//
// Three things stay eager that look like candidates:
//
//   notification-host, inline-notice, confirm-dialog — the app's only
//   failure surface.  A message that has to fetch a chunk before it can
//   be shown is not a failure surface; the moment it is most needed is
//   exactly the moment loading one may not work.
//
//   first-run-wizard — it covers the app until a library exists, so on
//   the one launch it matters it is on the critical path anyway.
//
//   track-list — index.html renders one, so it is the first paint.
// ---------------------------------------------------------------------------
import '@components/audio-player/audio-player.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts';
import '@components/notifications/inline-notice.ts';
import '@components/confirm-dialog/confirm-dialog.ts';
// The `?` overlay: help, so it is eager for the same reason the failure
// surface is — the moment it is asked for is the moment the user does
// not know what is going on. It costs a dialog and a table.
import '@components/shortcuts-overlay/shortcuts-overlay.ts';
import '@components/jobs/job-indicator.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store';
import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js';
// Importing the theme store triggers initialization: it fetches the saved
// theme from the backend and applies CSS custom properties to :root.
import '@store/theme-store';
// Importing the keyboard shortcut service triggers initialization:
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press';
import {
    hasTrackPayload,
    getDragPayload,
} from '@utils/drag-controller';
import type { DragActiveDetail } from '@utils/drag-controller';

setBasePath('/dist/webawesome');

// Before any component renders: an icon resolved by the default
// (remote) library is a request to fontawesome.com, and the module
// caches by URL, so one early render would pin the remote answer for
// the session.
registerBundledIcons();

// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();

// ---------------------------------------------------------------------------
// View caching navigation system
// ---------------------------------------------------------------------------
// Primary views (tracks, albums, artists, genres, playlists, settings) are
// created once and kept alive in the DOM.  Navigation toggles visibility
// (display: none ↔ display: '') instead of destroying/recreating via
// innerHTML.  Detail views (artist-details, playlist-details, genre-details)
// are ephemeral — created fresh each navigation because they depend on
// specific entity IDs that change.
//
// Because a cached view is never disconnected, `disconnectedCallback` is
// not where it stops listening.  Navigation calls viewDeactivated() on
// the outgoing element and viewActivated() on the incoming one; views
// hang their document listeners, timers and subscriptions off that pair
// (see utils/view-lifecycle.ts).  Skipping either call leaves a view
// listening from a page it is not on, which is finding H-1.
// ---------------------------------------------------------------------------

const VIEW_TAGS: Record<string, string> = {
    home: 'home-view',
    tracks: 'track-list',
    albums: 'cover-grid',
    artists: 'artists-view',
    genres: 'genres-view',
    playlists: 'playlist-view',
    explore: 'explore-view',
    autotag: 'autotag-view',
    downloads: 'downloads-view',
    jobs: 'jobs-view',
    settings: 'config-page',
};

// The module that defines each view's custom element.  `createElement`
// on an undefined tag silently produces an inert HTMLElement rather
// than throwing, so a navigation has to await its loader before it
// builds anything — a missing entry here is a blank page, not an error.
const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
    home: () => import('@components/home-view/home-view.ts'),
    tracks: () => Promise.resolve(),
    albums: () => import('@components/cover-grid/cover-grid.ts'),
    artists: () => import('@components/artists-view/artists-view.ts'),
    genres: () => import('@components/genres-view/genres-view.ts'),
    playlists: () => import('@components/playlist-view/playlist-view.ts'),
    explore: () => import('@components/explore-view/explore-view.ts'),
    autotag: () => import('@components/autotag-view/autotag-view.ts'),
    downloads: () => import('@components/downloads-view/downloads-view.ts'),
    jobs: () => import('@components/jobs/jobs-view.ts'),
    settings: () => import('@components/config-page/config-page.ts'),
};

const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
    'artist-details': () =>
        import('@components/artist-details/artist-details.ts'),
    'playlist-details': () =>
        import('@components/playlist-details/playlist-details.ts'),
    'smart-playlist-details': () =>
        import('@components/smart-playlist-details/smart-playlist-details.ts'),
    'genre-details': () =>
        import('@components/genre-details/genre-details.ts'),
    'explore-artist-details': () =>
        import('@components/explore-artist-details/explore-artist-details.js'),
    'explore-album-details': () =>
        import('@components/explore-album-details/explore-album-details.js'),
    // A detail view rather than a primary one on purpose: it is
    // somewhere you go and come back from, so the nav stack carries
    // the way out (016 B2 phase 2).
    'now-playing': () =>
        import('@components/now-playing-view/now-playing-view.ts'),
};

// Opened from a menu rather than by navigating, so they have no entry
// above; warmed with everything else below.
//
// `track-details` is loaded at the point of use by
// `utils/lazy-track-details.ts`, from the five components that open it
// (`track-list`, `cover-grid`, `queue-panel` and both playlist detail
// views). It used to be imported statically by all five, so its 42 kB
// rode in the startup chunk whatever this file said. Warming it here
// means the first open still does not wait for it.
const EXTRA_LOADERS: Array<() => Promise<unknown>> = [
    () => import('@components/smart-playlist-editor/smart-playlist-editor.ts'),
    // Same specifier as `utils/lazy-track-details.ts` uses, so this is
    // the same chunk rather than a second copy of it.
    () => import('@components/track-details/track-details.js'),
];

const viewCache = new Map<string, HTMLElement>();
let currentViewEl: HTMLElement | null = null;
let currentDetailEl: HTMLElement | null = null;


const mainContent = document.getElementById('main-content');

// Seed the cache with the default track-list rendered in index.html —
// otherwise the very first navigation (to whatever GetDefaultPage
// resolves to) creates and shows a second view while this one, never
// tracked as currentViewEl, is never hidden: two visible primary views
// splitting the main panel between them regardless of which is
// selected.
if (mainContent) {
    const initialTrackList = mainContent.querySelector('track-list');

    if (initialTrackList) {
        viewCache.set('tracks', initialTrackList as HTMLElement);
        currentViewEl = initialTrackList as HTMLElement;
        activateView(currentViewEl);
    }
}

/**
 * Navigations are numbered, because loading a view's chunk is
 * asynchronous and a user can click twice. Anything after the `await`
 * checks that it is still the newest navigation before touching the
 * DOM; otherwise a slow chunk would land on top of a faster one and
 * show the page the user navigated *away* from.
 */
let navSeq = 0;

document.addEventListener('navigate', (e: Event) => {
    void handleNavigate((e as CustomEvent).detail);
});

// ---------------------------------------------------------------------------
// The platform's back gesture
// ---------------------------------------------------------------------------
// Android's back button is not a keystroke the page can bind: the
// scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and
// otherwise finishes the activity.  This app never touched `history`, so
// that was always false and back quit the app from any depth -- reported
// from a device as "back does not navigate back".
//
// So a navigation is a history entry, and back is `popstate`.  It hooks
// the platform's own mechanism rather than a JNI callback of our own,
// which is the same reason `events.ts` hooks the runtime's transport:
// the Java half needs no change, and the behaviour is testable in a
// browser (`page.goBack()`) instead of only on a phone.
//
// Two rules keep the two stacks from disagreeing.  A navigation that
// *came from* history pushes nothing (`_isBack`), or going back would
// deepen the stack it is unwinding.  And the in-app back buttons --
// `navigate-back`, which the detail views and `now-playing-view` fire --
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.

/** The navigation an entry stands for. `undefined` on the entry that
 *  predates the app's own routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any } };

/** Whether the app's first navigation has been recorded. It *replaces*
 *  the launch entry rather than pushing, or every launch would cost one
 *  back press before the app would exit. */
let historyStarted = false;

/** How many entries this session has pushed beyond that first one --
 *  i.e. how deep back can go while staying inside the app. */
let pushedEntries = 0;

function recordNavigation(detail: { view: string; [key: string]: any }): void {
    // `_isBack` is bookkeeping, not destination: keeping it in the entry
    // would make a replayed navigation claim to be a back-navigation.
    const { _isBack: _ignored, ...nav } = detail;
    const state: NavState = { yjNav: nav };

    // Same URL, deliberately: the app has no routes, and a path a
    // reload cannot resolve is worse than no path at all.
    if (historyStarted) {
        history.pushState(state, '');
        pushedEntries += 1;
    } else {
        history.replaceState(state, '');
        historyStarted = true;
    }
}

window.addEventListener('popstate', (e: PopStateEvent) => {
    const nav = (e.state as NavState | null)?.yjNav;

    // Before the app's first navigation, or an entry somebody else
    // pushed: nothing to restore, and the activity should be free to
    // finish.
    if (!nav) return;

    pushedEntries = Math.max(0, pushedEntries - 1);

    void handleNavigate({ ...nav, _isBack: true });
});

async function handleNavigate(
    detail: { view: string; [key: string]: any },
): Promise<void> {
    const view: string = detail.view;

    if (!mainContent) return;

    const seq = ++navSeq;

    if (!detail._isBack) recordNavigation(detail);

    // Bookkeeping stays synchronous with the click: the search box's
    // scope and the active-view attribute describe the navigation that
    // was *asked for*, and are what the rest of the app and the e2e
    // selectors read.
    searchStore.setCurrentView(view);

    // Which view is showing is otherwise only inferable from which of
    // the cached children lacks .view-hidden.  Publishing it as an
    // attribute keeps e2e selectors semantic instead of structural.
    mainContent.dataset.activeView = view;

    // --- Primary (cacheable) views ----------------------------------------
    if (view in VIEW_TAGS) {
        // Remove any active detail view first
        if (currentDetailEl) {
            deactivateView(currentDetailEl);
            currentDetailEl.remove();
            currentDetailEl = null;
        }

        let target = viewCache.get(view);

        if (!target) {
            await (VIEW_LOADERS[view]?.() ?? Promise.resolve());
            if (seq !== navSeq) return;

            target = document.createElement(VIEW_TAGS[view]);
            viewCache.set(view, target);
            // Start hidden — we'll un-hide below
            target.classList.add('view-hidden');
            mainContent.appendChild(target);
        }

        // Hide current, show target.  Uses CSS class instead of
        // display:none so scroll containers preserve scrollTop.
        if (currentViewEl && currentViewEl !== target) {
            currentViewEl.classList.add('view-hidden');
            deactivateView(currentViewEl);
        }
        target.classList.remove('view-hidden');
        // A freshly created view was appended hidden, so it did not
        // self-activate on connection; a cached one was deactivated on
        // the way out.  Either way this is the call that starts it.
        activateView(target);
        currentViewEl = target;

        return;
    }

    await (DETAIL_LOADERS[view]?.() ?? Promise.resolve());
    if (seq !== navSeq) return;

    // --- Detail (ephemeral) views -----------------------------------------
    // Hide the current primary view
    if (currentViewEl) {
        currentViewEl.classList.add('view-hidden');
        deactivateView(currentViewEl);
    }
    // Remove any prior detail element
    if (currentDetailEl) {
        deactivateView(currentDetailEl);
        currentDetailEl.remove();
        currentDetailEl = null;
    }

    switch (view) {
        case 'artist-details': {
            const { artistId, artistName } = detail;
            const el = document.createElement('artist-details');

            el.setAttribute('artist-id', String(artistId));
            el.setAttribute('artist-name', artistName);
            mainContent.appendChild(el);
            currentDetailEl = el;
            break;
        }
        case 'playlist-details': {
            const { playlistId, playlistName } = detail;
            const plEl = document.createElement('playlist-details');

            plEl.setAttribute('playlist-id', String(playlistId));
            plEl.setAttribute('playlist-name', playlistName);
            mainContent.appendChild(plEl);
            currentDetailEl = plEl;
            break;
        }
        case 'smart-playlist-details': {
            const { playlistId, playlistName } = detail;
            const spEl = document.createElement('smart-playlist-details');

            spEl.setAttribute('playlist-id', String(playlistId));
            spEl.setAttribute('playlist-name', playlistName);
            if (detail.autoEdit) {
                spEl.setAttribute('auto-edit', '');
            }
            mainContent.appendChild(spEl);
            currentDetailEl = spEl;
            break;
        }
        case 'now-playing': {
            const npEl = document.createElement('now-playing-view');

            mainContent.appendChild(npEl);
            currentDetailEl = npEl;
            break;
        }
        case 'genre-details': {
            const { genreName } = detail;
            const genreEl = document.createElement('genre-details');

            genreEl.setAttribute('genre-name', genreName);
            mainContent.appendChild(genreEl);
            currentDetailEl = genreEl;
            break;
        }
        case 'explore-artist-details': {
            const { artistMBID, artistName, localArtistId } = detail;
            const el = document.createElement('explore-artist-details');

            if (artistMBID) el.setAttribute('artist-mbid', artistMBID);
            el.setAttribute('artist-name', artistName);
            if (localArtistId) el.setAttribute('local-artist-id', String(localArtistId));
            mainContent.appendChild(el);
            currentDetailEl = el;
            break;
        }
        case 'explore-album-details': {
            const {
                releaseGroupMBID,
                albumName,
                artistName,
                highlightTrackMBID,
                highlightTrackTitle,
                localAlbumId,
            } = detail;
            const el = document.createElement('explore-album-details');

            if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID);
            el.setAttribute('album-name', albumName);
            if (artistName) el.setAttribute('artist-name', artistName);
            if (highlightTrackMBID) {
                el.setAttribute('highlight-track-mbid', highlightTrackMBID);
            }
            if (highlightTrackTitle) {
                el.setAttribute('highlight-track-title', highlightTrackTitle);
            }
            if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId));
            mainContent.appendChild(el);
            currentDetailEl = el;
            break;
        }
        default: {
            const fallback = document.createElement('div');

            fallback.style.padding = '1em';
            fallback.style.color = 'var(--yj-text-secondary, #b3b3b3)';
            fallback.innerHTML = `<p>Coming soon: ${view}</p>`;
            mainContent.appendChild(fallback);
            currentDetailEl = fallback;
        }
    }
}

// Every view the user has not opened yet, fetched once the app has
// settled. Splitting keeps them off the path to first paint; warming
// them means the navigation that needs one almost never waits, which is
// the cost a naive split would have traded the startup win for.
function warmViewChunks(): void {
    const loaders = [
        ...Object.values(VIEW_LOADERS),
        ...Object.values(DETAIL_LOADERS),
        ...EXTRA_LOADERS,
    ];

    const warmNext = (i: number): void => {
        if (i >= loaders.length) return;

        void loaders[i]!()
            .catch(() => {
                // A chunk that will not preload is not a failure: the
                // navigation that needs it will ask again and report
                // for itself if it still cannot be had.
            })
            .finally(() => {
                schedule(() => warmNext(i + 1));
            });
    };

    schedule(() => warmNext(0));
}

/** requestIdleCallback where it exists; WebKit2GTK does not have it. */
function schedule(fn: () => void): void {
    const ric = (
        window as unknown as {
            requestIdleCallback?: (cb: () => void) => number;
        }
    ).requestIdleCallback;

    if (ric) {
        ric(fn);

        return;
    }

    setTimeout(fn, 200);
}

// Navigate-back: the in-app back buttons, which are the same press as
// the phone's.  It goes through the history rather than a stack of its
// own, so one press is one entry however it arrived -- two stacks is
// how a detail view's own button and the back gesture come to disagree.
//
// At the root there is nothing of ours to go back to, and going back
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
    if (pushedEntries > 0) history.back();
});

// Navigate to the user's configured launch page.  Falls back to 'home'
// if the backend call fails, matching the config's own default.
GetDefaultPage()
    .then((view) => {
        document.dispatchEvent(new CustomEvent('navigate', {
            bubbles: true,
            composed: true,
            detail: { view: view || 'home' },
        }));
    })
    .catch(() => {
        document.dispatchEvent(new CustomEvent('navigate', {
            bubbles: true,
            composed: true,
            detail: { view: 'home' },
        }));
    });

// Queue panel toggle
const queueButton = document.getElementById('queue-button');
const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;

if (queueButton && queuePanel) {
    queueButton.addEventListener('click', () => {
        const isOpen = queuePanel.hasAttribute('open');

        if (isOpen) {
            queuePanel.removeAttribute('open');
        } else {
            queuePanel.setAttribute('open', '');
        }
    });

    // The button says whether the panel is open, and it learns that
    // from the panel rather than from its own click handler.
    //
    // It is not the only thing that opens the queue -- `now-playing-view`
    // sets the same attribute, because it hides the bar this button
    // lives in -- so a state kept beside the click would be right until
    // something else opened the panel and then quietly wrong. The panel's
    // `open` attribute is the one fact; this reflects it.
    const reflectQueueState = () => {
        queueButton.setAttribute(
            'aria-expanded',
            String(queuePanel.hasAttribute('open')),
        );
    };

    new MutationObserver(reflectQueueState).observe(queuePanel, {
        attributes: true,
        attributeFilter: ['open'],
    });

    reflectQueueState();

    // ---------------------------------------------------------------
    // Queue button as drop target (when queue panel is closed)
    // ---------------------------------------------------------------

    queueButton.addEventListener('dragover', (e: DragEvent) => {
        if (!hasTrackPayload(e)) return;

        e.preventDefault();

        if (e.dataTransfer) {
            e.dataTransfer.dropEffect = 'copy';
        }

        queueButton.classList.add('drag-over');
    });

    queueButton.addEventListener('dragleave', () => {
        queueButton.classList.remove('drag-over');
    });

    queueButton.addEventListener('drop', (e: DragEvent) => {
        e.preventDefault();
        queueButton.classList.remove('drag-over');

        const payload = getDragPayload(e);

        if (!payload || payload.filePaths.length === 0) return;

        if (payload.source === 'queue') return;

        queueStore.addTracksToQueue(payload.filePaths);
    });

    // Show/hide drag-over styling globally.
    document.addEventListener(
        'yj-drag-active',
        ((e: CustomEvent<DragActiveDetail>) => {
            if (!e.detail.active) {
                queueButton.classList.remove('drag-over');
            }
        }) as EventListener,
    );
}

// ---------------------------------------------------------------
// Request current state from the backend
// ---------------------------------------------------------------
// All stores have registered their EventsOn listeners by now
// (module-level singletons are instantiated during import
// evaluation), so the state-push events emitted by these
// binding calls will be received deterministically — no sleep
// or timing assumptions needed.
void Player.EmitCurrentState();
void Queue.EmitCurrentState();

// ---------------------------------------------------------------
// Land on Home
// ---------------------------------------------------------------
// The app opened on Tracks — an alphabetical list of everything, which
// is the one entry point that is identical every time and therefore
// gives the user nothing to start from. Home is listed first in the nav
// and is the page built to answer "what should I play", and it was
// never what anybody saw (H-8).
//
// index.html still renders the track list eagerly and it is still what
// paints first: it is the cached 'tracks' view, so this navigation is a
// class toggle plus one chunk, not a second render of the shell. Doing
// it here rather than by changing the markup keeps the first paint
// exactly as Phase 4 left it.
document.dispatchEvent(
    new CustomEvent('navigate', {
        detail: { view: 'home' },
    }),
);

warmViewChunks();
