pub use crate::welcome::ShowWelcome;
use crate::{multibuffer_hint::MultibufferHint, welcome::WelcomePage};
use client::{Client, UserStore, zed_urls};
use db::kvp::KEY_VALUE_STORE;
use fs::Fs;
use gpui::{
    Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter,
    FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, SharedString, Subscription,
    Task, WeakEntity, Window, actions,
};
use notifications::status_toast::{StatusToast, ToastIcon};
use schemars::JsonSchema;
use serde::Deserialize;
use settings::{SettingsStore, VsCodeSettingsSource};
use std::sync::Arc;
use ui::{
    Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _,
    StatefulInteractiveElement, Vector, VectorName, prelude::*, rems_from_px,
};
use workspace::{
    AppState, Workspace, WorkspaceId,
    dock::DockPosition,
    item::{Item, ItemEvent},
    notifications::NotifyResultExt as _,
    open_new, register_serializable_item, with_active_or_new_workspace,
};

mod ai_setup_page;
mod base_keymap_picker;
mod basics_page;
mod editing_page;
pub mod multibuffer_hint;
mod theme_preview;
mod welcome;

/// Imports settings from Visual Studio Code.
#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = zed)]
#[serde(deny_unknown_fields)]
pub struct ImportVsCodeSettings {
    #[serde(default)]
    pub skip_prompt: bool,
}

/// Imports settings from Cursor editor.
#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = zed)]
#[serde(deny_unknown_fields)]
pub struct ImportCursorSettings {
    #[serde(default)]
    pub skip_prompt: bool,
}

pub const FIRST_OPEN: &str = "first_open";
pub const DOCS_URL: &str = "https://zed.dev/docs/";

actions!(
    zed,
    [
        /// Opens the onboarding view.
        OpenOnboarding
    ]
);

actions!(
    onboarding,
    [
        /// Activates the Basics page.
        ActivateBasicsPage,
        /// Activates the Editing page.
        ActivateEditingPage,
        /// Activates the AI Setup page.
        ActivateAISetupPage,
        /// Finish the onboarding process.
        Finish,
        /// Sign in while in the onboarding flow.
        SignIn,
        /// Open the user account in zed.dev while in the onboarding flow.
        OpenAccount,
        /// Resets the welcome screen hints to their initial state.
        ResetHints
    ]
);

pub fn init(cx: &mut App) {
    cx.observe_new(|workspace: &mut Workspace, _, _cx| {
        workspace
            .register_action(|_workspace, _: &ResetHints, _, cx| MultibufferHint::set_count(0, cx));
    })
    .detach();

    cx.on_action(|_: &OpenOnboarding, cx| {
        with_active_or_new_workspace(cx, |workspace, window, cx| {
            workspace
                .with_local_workspace(window, cx, |workspace, window, cx| {
                    let existing = workspace
                        .active_pane()
                        .read(cx)
                        .items()
                        .find_map(|item| item.downcast::<Onboarding>());

                    if let Some(existing) = existing {
                        workspace.activate_item(&existing, true, true, window, cx);
                    } else {
                        let settings_page = Onboarding::new(workspace, cx);
                        workspace.add_item_to_active_pane(
                            Box::new(settings_page),
                            None,
                            true,
                            window,
                            cx,
                        )
                    }
                })
                .detach();
        });
    });

    cx.on_action(|_: &ShowWelcome, cx| {
        with_active_or_new_workspace(cx, |workspace, window, cx| {
            workspace
                .with_local_workspace(window, cx, |workspace, window, cx| {
                    let existing = workspace
                        .active_pane()
                        .read(cx)
                        .items()
                        .find_map(|item| item.downcast::<WelcomePage>());

                    if let Some(existing) = existing {
                        workspace.activate_item(&existing, true, true, window, cx);
                    } else {
                        let settings_page = WelcomePage::new(window, cx);
                        workspace.add_item_to_active_pane(
                            Box::new(settings_page),
                            None,
                            true,
                            window,
                            cx,
                        )
                    }
                })
                .detach();
        });
    });

    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
        workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
            let fs = <dyn Fs>::global(cx);
            let action = *action;

            let workspace = cx.weak_entity();

            window
                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
                    handle_import_vscode_settings(
                        workspace,
                        VsCodeSettingsSource::VsCode,
                        action.skip_prompt,
                        fs,
                        cx,
                    )
                    .await
                })
                .detach();
        });

        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
            let fs = <dyn Fs>::global(cx);
            let action = *action;

            let workspace = cx.weak_entity();

            window
                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
                    handle_import_vscode_settings(
                        workspace,
                        VsCodeSettingsSource::Cursor,
                        action.skip_prompt,
                        fs,
                        cx,
                    )
                    .await
                })
                .detach();
        });
    })
    .detach();

    base_keymap_picker::init(cx);

    register_serializable_item::<Onboarding>(cx);
    register_serializable_item::<WelcomePage>(cx);
}

pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyhow::Result<()>> {
    telemetry::event!("Onboarding Page Opened");
    open_new(
        Default::default(),
        app_state,
        cx,
        |workspace, window, cx| {
            {
                workspace.toggle_dock(DockPosition::Left, window, cx);
                let onboarding_page = Onboarding::new(workspace, cx);
                workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);

                window.focus(&onboarding_page.focus_handle(cx));

                cx.notify();
            };
            db::write_and_log(cx, || {
                KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string())
            });
        },
    )
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectedPage {
    Basics,
    Editing,
    AiSetup,
}

impl SelectedPage {
    fn name(&self) -> &'static str {
        match self {
            SelectedPage::Basics => "Basics",
            SelectedPage::Editing => "Editing",
            SelectedPage::AiSetup => "AI Setup",
        }
    }
}

struct Onboarding {
    workspace: WeakEntity<Workspace>,
    focus_handle: FocusHandle,
    selected_page: SelectedPage,
    user_store: Entity<UserStore>,
    _settings_subscription: Subscription,
}

impl Onboarding {
    fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
        cx.new(|cx| Self {
            workspace: workspace.weak_handle(),
            focus_handle: cx.focus_handle(),
            selected_page: SelectedPage::Basics,
            user_store: workspace.user_store().clone(),
            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
        })
    }

    fn set_page(
        &mut self,
        page: SelectedPage,
        clicked: Option<&'static str>,
        cx: &mut Context<Self>,
    ) {
        if let Some(click) = clicked {
            telemetry::event!(
                "Welcome Tab Clicked",
                from = self.selected_page.name(),
                to = page.name(),
                clicked = click,
            );
        }

        self.selected_page = page;
        cx.notify();
        cx.emit(ItemEvent::UpdateTab);
    }

    fn render_nav_buttons(
        &mut self,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> [impl IntoElement; 3] {
        let pages = [
            SelectedPage::Basics,
            SelectedPage::Editing,
            SelectedPage::AiSetup,
        ];

        let text = ["Basics", "Editing", "AI Setup"];

        let actions: [&dyn Action; 3] = [
            &ActivateBasicsPage,
            &ActivateEditingPage,
            &ActivateAISetupPage,
        ];

        let mut binding = actions.map(|action| {
            KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
                .map(|kb| kb.size(rems_from_px(12.)))
        });

        pages.map(|page| {
            let i = page as usize;
            let selected = self.selected_page == page;
            h_flex()
                .id(text[i])
                .relative()
                .w_full()
                .gap_2()
                .px_2()
                .py_0p5()
                .justify_between()
                .rounded_sm()
                .when(selected, |this| {
                    this.child(
                        div()
                            .h_4()
                            .w_px()
                            .bg(cx.theme().colors().text_accent)
                            .absolute()
                            .left_0(),
                    )
                })
                .hover(|style| style.bg(cx.theme().colors().element_hover))
                .child(Label::new(text[i]).map(|this| {
                    if selected {
                        this.color(Color::Default)
                    } else {
                        this.color(Color::Muted)
                    }
                }))
                .child(binding[i].take().map_or(
                    gpui::Empty.into_any_element(),
                    IntoElement::into_any_element,
                ))
                .on_click(cx.listener(move |this, click_event, _, cx| {
                    let click = match click_event {
                        gpui::ClickEvent::Mouse(_) => "mouse",
                        gpui::ClickEvent::Keyboard(_) => "keyboard",
                    };

                    this.set_page(page, Some(click), cx);
                }))
        })
    }

    fn render_nav(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let ai_setup_page = matches!(self.selected_page, SelectedPage::AiSetup);

        v_flex()
            .h_full()
            .w(rems_from_px(220.))
            .flex_shrink_0()
            .gap_4()
            .justify_between()
            .child(
                v_flex()
                    .gap_6()
                    .child(
                        h_flex()
                            .px_2()
                            .gap_4()
                            .child(Vector::square(VectorName::ZedLogo, rems(2.5)))
                            .child(
                                v_flex()
                                    .child(
                                        Headline::new("Welcome to Zed").size(HeadlineSize::Small),
                                    )
                                    .child(
                                        Label::new("The editor for what's next")
                                            .color(Color::Muted)
                                            .size(LabelSize::Small)
                                            .italic(),
                                    ),
                            ),
                    )
                    .child(
                        v_flex()
                            .gap_4()
                            .child(
                                v_flex()
                                    .py_4()
                                    .border_y_1()
                                    .border_color(cx.theme().colors().border_variant.opacity(0.5))
                                    .gap_1()
                                    .children(self.render_nav_buttons(window, cx)),
                            )
                            .map(|this| {
                                let keybinding = KeyBinding::for_action_in(
                                    &Finish,
                                    &self.focus_handle,
                                    window,
                                    cx,
                                )
                                .map(|kb| kb.size(rems_from_px(12.)));

                                if ai_setup_page {
                                    this.child(
                                        ButtonLike::new("start_building")
                                            .style(ButtonStyle::Outlined)
                                            .size(ButtonSize::Medium)
                                            .child(
                                                h_flex()
                                                    .ml_1()
                                                    .w_full()
                                                    .justify_between()
                                                    .child(Label::new("Start Building"))
                                                    .children(keybinding),
                                            )
                                            .on_click(|_, window, cx| {
                                                window.dispatch_action(Finish.boxed_clone(), cx);
                                            }),
                                    )
                                } else {
                                    this.child(
                                        ButtonLike::new("skip_all")
                                            .size(ButtonSize::Medium)
                                            .child(
                                                h_flex()
                                                    .ml_1()
                                                    .w_full()
                                                    .justify_between()
                                                    .child(
                                                        Label::new("Skip All").color(Color::Muted),
                                                    )
                                                    .children(keybinding),
                                            )
                                            .on_click(|_, window, cx| {
                                                window.dispatch_action(Finish.boxed_clone(), cx);
                                            }),
                                    )
                                }
                            }),
                    ),
            )
            .child(
                if let Some(user) = self.user_store.read(cx).current_user() {
                    v_flex()
                        .gap_1()
                        .child(
                            h_flex()
                                .ml_2()
                                .gap_2()
                                .max_w_full()
                                .w_full()
                                .child(Avatar::new(user.avatar_uri.clone()))
                                .child(Label::new(user.github_login.clone()).truncate()),
                        )
                        .child(
                            ButtonLike::new("open_account")
                                .size(ButtonSize::Medium)
                                .child(
                                    h_flex()
                                        .ml_1()
                                        .w_full()
                                        .justify_between()
                                        .child(Label::new("Open Account"))
                                        .children(
                                            KeyBinding::for_action_in(
                                                &OpenAccount,
                                                &self.focus_handle,
                                                window,
                                                cx,
                                            )
                                            .map(|kb| kb.size(rems_from_px(12.))),
                                        ),
                                )
                                .on_click(|_, window, cx| {
                                    window.dispatch_action(OpenAccount.boxed_clone(), cx);
                                }),
                        )
                        .into_any_element()
                } else {
                    Button::new("sign_in", "Sign In")
                        .full_width()
                        .style(ButtonStyle::Outlined)
                        .size(ButtonSize::Medium)
                        .key_binding(
                            KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx)
                                .map(|kb| kb.size(rems_from_px(12.))),
                        )
                        .on_click(|_, window, cx| {
                            window.dispatch_action(SignIn.boxed_clone(), cx);
                        })
                        .into_any_element()
                },
            )
    }

    fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
        telemetry::event!("Welcome Skip Clicked");
        go_to_welcome_page(cx);
    }

    fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
        let client = Client::global(cx);

        window
            .spawn(cx, async move |cx| {
                client
                    .sign_in_with_optional_connect(true, cx)
                    .await
                    .notify_async_err(cx);
            })
            .detach();
    }

    fn handle_open_account(_: &OpenAccount, _: &mut Window, cx: &mut App) {
        cx.open_url(&zed_urls::account_url(cx))
    }

    fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
        let client = Client::global(cx);

        match self.selected_page {
            SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
            SelectedPage::Editing => {
                crate::editing_page::render_editing_page(window, cx).into_any_element()
            }
            SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
                self.workspace.clone(),
                self.user_store.clone(),
                client,
                window,
                cx,
            )
            .into_any_element(),
        }
    }
}

impl Render for Onboarding {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        h_flex()
            .image_cache(gpui::retain_all("onboarding-page"))
            .key_context({
                let mut ctx = KeyContext::new_with_defaults();
                ctx.add("Onboarding");
                ctx.add("menu");
                ctx
            })
            .track_focus(&self.focus_handle)
            .size_full()
            .bg(cx.theme().colors().editor_background)
            .on_action(Self::on_finish)
            .on_action(Self::handle_sign_in)
            .on_action(Self::handle_open_account)
            .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
                this.set_page(SelectedPage::Basics, Some("action"), cx);
            }))
            .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| {
                this.set_page(SelectedPage::Editing, Some("action"), cx);
            }))
            .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| {
                this.set_page(SelectedPage::AiSetup, Some("action"), cx);
            }))
            .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
                window.focus_next();
                cx.notify();
            }))
            .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
                window.focus_prev();
                cx.notify();
            }))
            .child(
                h_flex()
                    .max_w(rems_from_px(1100.))
                    .max_h(rems_from_px(850.))
                    .size_full()
                    .m_auto()
                    .py_20()
                    .px_12()
                    .items_start()
                    .gap_12()
                    .child(self.render_nav(window, cx))
                    .child(
                        v_flex()
                            .id("page-content")
                            .size_full()
                            .max_w_full()
                            .min_w_0()
                            .pl_12()
                            .border_l_1()
                            .border_color(cx.theme().colors().border_variant.opacity(0.5))
                            .overflow_y_scroll()
                            .child(self.render_page(window, cx)),
                    ),
            )
    }
}

impl EventEmitter<ItemEvent> for Onboarding {}

impl Focusable for Onboarding {
    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
        self.focus_handle.clone()
    }
}

impl Item for Onboarding {
    type Event = ItemEvent;

    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
        "Onboarding".into()
    }

    fn telemetry_event_text(&self) -> Option<&'static str> {
        Some("Onboarding Page Opened")
    }

    fn show_toolbar(&self) -> bool {
        false
    }

    fn clone_on_split(
        &self,
        _workspace_id: Option<WorkspaceId>,
        _: &mut Window,
        cx: &mut Context<Self>,
    ) -> Option<Entity<Self>> {
        Some(cx.new(|cx| Onboarding {
            workspace: self.workspace.clone(),
            user_store: self.user_store.clone(),
            selected_page: self.selected_page,
            focus_handle: cx.focus_handle(),
            _settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
        }))
    }

    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
        f(*event)
    }
}

fn go_to_welcome_page(cx: &mut App) {
    with_active_or_new_workspace(cx, |workspace, window, cx| {
        let Some((onboarding_id, onboarding_idx)) = workspace
            .active_pane()
            .read(cx)
            .items()
            .enumerate()
            .find_map(|(idx, item)| {
                let _ = item.downcast::<Onboarding>()?;
                Some((item.item_id(), idx))
            })
        else {
            return;
        };

        workspace.active_pane().update(cx, |pane, cx| {
            // Get the index here to get around the borrow checker
            let idx = pane.items().enumerate().find_map(|(idx, item)| {
                let _ = item.downcast::<WelcomePage>()?;
                Some(idx)
            });

            if let Some(idx) = idx {
                pane.activate_item(idx, true, true, window, cx);
            } else {
                let item = Box::new(WelcomePage::new(window, cx));
                pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
            }

            pane.remove_item(onboarding_id, false, false, window, cx);
        });
    });
}

pub async fn handle_import_vscode_settings(
    workspace: WeakEntity<Workspace>,
    source: VsCodeSettingsSource,
    skip_prompt: bool,
    fs: Arc<dyn Fs>,
    cx: &mut AsyncWindowContext,
) {
    use util::truncate_and_remove_front;

    let vscode_settings =
        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
            Ok(vscode_settings) => vscode_settings,
            Err(err) => {
                zlog::error!("{err}");
                let _ = cx.prompt(
                    gpui::PromptLevel::Info,
                    &format!("Could not find or load a {source} settings file"),
                    None,
                    &["Ok"],
                );
                return;
            }
        };

    if !skip_prompt {
        let prompt = cx.prompt(
            gpui::PromptLevel::Warning,
            &format!(
                "Importing {} settings may overwrite your existing settings. \
                Will import settings from {}",
                vscode_settings.source,
                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
            ),
            None,
            &["Ok", "Cancel"],
        );
        let result = cx.spawn(async move |_| prompt.await.ok()).await;
        if result != Some(0) {
            return;
        }
    };

    let Ok(result_channel) = cx.update(|_, cx| {
        let source = vscode_settings.source;
        let path = vscode_settings.path.clone();
        let result_channel = cx
            .global::<SettingsStore>()
            .import_vscode_settings(fs, vscode_settings);
        zlog::info!("Imported {source} settings from {}", path.display());
        result_channel
    }) else {
        return;
    };

    let result = result_channel.await;
    workspace
        .update_in(cx, |workspace, _, cx| match result {
            Ok(_) => {
                let confirmation_toast = StatusToast::new(
                    format!("Your {} settings were successfully imported.", source),
                    cx,
                    |this, _| {
                        this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
                            .dismiss_button(true)
                    },
                );
                SettingsImportState::update(cx, |state, _| match source {
                    VsCodeSettingsSource::VsCode => {
                        state.vscode = true;
                    }
                    VsCodeSettingsSource::Cursor => {
                        state.cursor = true;
                    }
                });
                workspace.toggle_status_toast(confirmation_toast, cx);
            }
            Err(_) => {
                let error_toast = StatusToast::new(
                    "Failed to import settings. See log for details",
                    cx,
                    |this, _| {
                        this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
                            .action("Open Log", |window, cx| {
                                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
                            })
                            .dismiss_button(true)
                    },
                );
                workspace.toggle_status_toast(error_toast, cx);
            }
        })
        .ok();
}

#[derive(Default, Copy, Clone)]
pub struct SettingsImportState {
    pub cursor: bool,
    pub vscode: bool,
}

impl Global for SettingsImportState {}

impl SettingsImportState {
    pub fn global(cx: &App) -> Self {
        cx.try_global().cloned().unwrap_or_default()
    }
    pub fn update<R>(cx: &mut App, f: impl FnOnce(&mut Self, &mut App) -> R) -> R {
        cx.update_default_global(f)
    }
}

impl workspace::SerializableItem for Onboarding {
    fn serialized_item_kind() -> &'static str {
        "OnboardingPage"
    }

    fn cleanup(
        workspace_id: workspace::WorkspaceId,
        alive_items: Vec<workspace::ItemId>,
        _window: &mut Window,
        cx: &mut App,
    ) -> gpui::Task<gpui::Result<()>> {
        workspace::delete_unloaded_items(
            alive_items,
            workspace_id,
            "onboarding_pages",
            &persistence::ONBOARDING_PAGES,
            cx,
        )
    }

    fn deserialize(
        _project: Entity<project::Project>,
        workspace: WeakEntity<Workspace>,
        workspace_id: workspace::WorkspaceId,
        item_id: workspace::ItemId,
        window: &mut Window,
        cx: &mut App,
    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
        window.spawn(cx, async move |cx| {
            if let Some(page_number) =
                persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)?
            {
                let page = match page_number {
                    0 => Some(SelectedPage::Basics),
                    1 => Some(SelectedPage::Editing),
                    2 => Some(SelectedPage::AiSetup),
                    _ => None,
                };
                workspace.update(cx, |workspace, cx| {
                    let onboarding_page = Onboarding::new(workspace, cx);
                    if let Some(page) = page {
                        zlog::info!("Onboarding page {page:?} loaded");
                        onboarding_page.update(cx, |onboarding_page, cx| {
                            onboarding_page.set_page(page, None, cx);
                        })
                    }
                    onboarding_page
                })
            } else {
                Err(anyhow::anyhow!("No onboarding page to deserialize"))
            }
        })
    }

    fn serialize(
        &mut self,
        workspace: &mut Workspace,
        item_id: workspace::ItemId,
        _closing: bool,
        _window: &mut Window,
        cx: &mut ui::Context<Self>,
    ) -> Option<gpui::Task<gpui::Result<()>>> {
        let workspace_id = workspace.database_id()?;
        let page_number = self.selected_page as u16;
        Some(cx.background_spawn(async move {
            persistence::ONBOARDING_PAGES
                .save_onboarding_page(item_id, workspace_id, page_number)
                .await
        }))
    }

    fn should_serialize(&self, event: &Self::Event) -> bool {
        event == &ItemEvent::UpdateTab
    }
}

mod persistence {
    use db::{define_connection, query, sqlez_macros::sql};
    use workspace::WorkspaceDb;

    define_connection! {
        pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
            &[
                sql!(
                    CREATE TABLE onboarding_pages (
                        workspace_id INTEGER,
                        item_id INTEGER UNIQUE,
                        page_number INTEGER,

                        PRIMARY KEY(workspace_id, item_id),
                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
                        ON DELETE CASCADE
                    ) STRICT;
                ),
            ];
    }

    impl OnboardingPagesDb {
        query! {
            pub async fn save_onboarding_page(
                item_id: workspace::ItemId,
                workspace_id: workspace::WorkspaceId,
                page_number: u16
            ) -> Result<()> {
                INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number)
                VALUES (?, ?, ?)
            }
        }

        query! {
            pub fn get_onboarding_page(
                item_id: workspace::ItemId,
                workspace_id: workspace::WorkspaceId
            ) -> Result<Option<u16>> {
                SELECT page_number
                FROM onboarding_pages
                WHERE item_id = ? AND workspace_id = ?
            }
        }
    }
}
