//! aibodh, a local bookshelf for aibodh ebooks.
//!
//!   aibodh                 # open the library (upload books via the UI)
//!   aibodh book.aibodh     # import that book, then open the library
//!
//! Books live in a per-user library and persist across runs. The same book can
//! be held in several versions; the reader lets you switch between them. Chapter
//! HTML and images are read straight from the book DB per request; the app chrome
//! (CSS/JS/fonts/Bulma) is embedded in this binary.

use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};

use anyhow::{anyhow, Context, Result};
use axum::{
    body::Body,
    extract::{DefaultBodyLimit, Multipart, Path as AxPath, State},
    http::{header, StatusCode},
    response::{Html, IntoResponse, Redirect, Response},
    routing::{get, post},
    Router,
};
use clap::Parser;
use minijinja::{context, Environment};
use rusqlite::Connection;

/// App-chrome assets baked into the binary (CSS, JS, fonts, Bulma, logos).
#[derive(rust_embed::Embed)]
#[folder = "assets/"]
struct Chrome;

#[derive(Parser, Debug)]
#[command(about = "Serve a local bookshelf of aibodh books")]
struct Args {
    /// Optional book `.aibodh` to import into the library before serving.
    db: Option<PathBuf>,

    /// Port to bind (default: an OS-assigned free port).
    #[arg(long, default_value_t = 0)]
    port: u16,

    /// Do not auto-open the browser.
    #[arg(long)]
    no_open: bool,
}

/// One loaded book version: its own SQLite connection plus cached metadata.
struct Book {
    id: String,
    version: String,
    packer_version: Option<String>,
    title: String,
    subtitle: Option<String>,
    cover: Option<String>,
    chapters: Vec<aibodh::ChapterRow>,
    conn: Mutex<Connection>,
    path: PathBuf,
}

/// A version that can't be opened because it was packed by a newer aibodh.
#[derive(Clone)]
struct TooNew {
    version: String,
    packed_with: Option<String>,
    path: PathBuf,
}

/// One version of a book: either loaded, or too new for this reader.
enum Slot {
    Book(Arc<Book>),
    TooNew(TooNew),
}

impl Slot {
    fn version(&self) -> &str {
        match self {
            Slot::Book(b) => &b.version,
            Slot::TooNew(t) => &t.version,
        }
    }
    fn book(&self) -> Option<&Arc<Book>> {
        match self {
            Slot::Book(b) => Some(b),
            Slot::TooNew(_) => None,
        }
    }
    fn path(&self) -> &Path {
        match self {
            Slot::Book(b) => &b.path,
            Slot::TooNew(t) => &t.path,
        }
    }
}

/// All versions of one book, newest first, a single card. Some versions may be
/// too new to open; display metadata comes from the newest *readable* version.
struct BookGroup {
    id: String,
    title: String,
    subtitle: Option<String>,
    cover: Option<String>,
    versions: Vec<Slot>,
}

impl BookGroup {
    fn latest_readable(&self) -> Option<&Arc<Book>> {
        self.versions.iter().find_map(|s| s.book())
    }
    fn get_readable(&self, version: &str) -> Option<&Arc<Book>> {
        self.versions
            .iter()
            .find_map(|s| s.book().filter(|b| b.version == version))
    }
    fn has_too_new(&self) -> bool {
        self.versions.iter().any(|s| s.book().is_none())
    }
    /// Keep versions newest-first; refresh display metadata from the newest
    /// readable version (a too-new-only group keeps its creation-time metadata).
    fn resort(&mut self) {
        self.versions
            .sort_by(|a, b| aibodh::version_cmp(b.version(), a.version()));
        let display = self
            .latest_readable()
            .map(|b| (b.title.clone(), b.subtitle.clone(), b.cover.clone()));
        if let Some((title, subtitle, cover)) = display {
            self.title = title;
            self.subtitle = subtitle;
            self.cover = cover;
        }
    }
    /// Template value list of every version, flagged readable / too-new.
    fn version_values(&self, current: Option<&str>) -> Vec<minijinja::Value> {
        self.versions
            .iter()
            .map(|s| {
                let (packed_with, readable) = match s {
                    Slot::Book(b) => (b.packer_version.clone(), true),
                    Slot::TooNew(t) => (t.packed_with.clone(), false),
                };
                context! {
                    version => s.version(),
                    readable => readable,
                    packed_with => packed_with,
                    is_current => Some(s.version()) == current,
                }
            })
            .collect()
    }
}

struct AppState {
    env: Environment<'static>,
    library_dir: PathBuf,
    books: RwLock<HashMap<String, BookGroup>>,
}

#[tokio::main]
async fn main() -> Result<()> {
    let args = Args::parse();
    let library_dir = aibodh::library_dir()?;

    if let Some(db) = &args.db {
        let (id, version) = import_book(&library_dir, db)
            .with_context(|| format!("importing {}", db.display()))?;
        println!("  imported \"{id}\" v{version} into the library");
    }

    let mut env = Environment::new();
    env.add_template("shell.html", include_str!("../templates/shell.html"))?;
    env.add_template(
        "library_content.html",
        include_str!("../templates/library_content.html"),
    )?;
    env.add_template(
        "book_content.html",
        include_str!("../templates/book_content.html"),
    )?;
    env.add_template(
        "chapter_content.html",
        include_str!("../templates/chapter_content.html"),
    )?;

    let books = load_library(&library_dir);
    let total: usize = books.values().map(|g| g.versions.len()).sum();
    let needs_update = books.values().filter(|g| g.has_too_new()).count();
    println!(
        "  library: {} ({} books, {total} version files)",
        library_dir.display(),
        books.len()
    );
    if needs_update > 0 {
        println!(
            "  ! {needs_update} book(s) have a version that needs a newer aibodh, cargo install aibodh --force"
        );
    }

    let state = Arc::new(AppState {
        env,
        library_dir,
        books: RwLock::new(books),
    });

    let app = Router::new()
        .route("/", get(library))
        .route("/upload", post(upload))
        .route("/book/:id", get(book_redirect))
        .route("/book/:id/delete", post(delete_book))
        .route("/book/:id/:version", get(book_contents))
        .route("/book/:id/:version/delete", post(delete_version))
        .route("/book/:id/:version/chapter/:slug", get(chapter))
        .route("/book/:id/:version/assets/*path", get(book_asset))
        .route("/assets/*path", get(chrome_asset))
        .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)) // 1 GiB uploads
        .with_state(state.clone());

    let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, args.port));
    let listener = tokio::net::TcpListener::bind(addr).await?;
    let url = format!("http://127.0.0.1:{}/", listener.local_addr()?.port());

    println!("\n  📚  aibodh bookshelf");
    println!("  →  {url}");
    println!("  →  press Ctrl-C to stop\n");
    if !args.no_open {
        let _ = open::that(&url);
    }

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;
    Ok(())
}

async fn shutdown_signal() {
    let _ = tokio::signal::ctrl_c().await;
    println!("\n  bye.");
}

// ---- handlers -------------------------------------------------------------

async fn library(State(st): State<Arc<AppState>>) -> Response {
    let mut list: Vec<minijinja::Value> = {
        let books = st.books.read().unwrap();
        books
            .values()
            .map(|g| {
                let readable = g.latest_readable();
                // Versions that are too new for this reader (for the warning modal).
                let too_new: Vec<minijinja::Value> = g
                    .versions
                    .iter()
                    .filter_map(|s| match s {
                        Slot::TooNew(t) => {
                            Some(context! { version => t.version, packed_with => t.packed_with })
                        }
                        Slot::Book(_) => None,
                    })
                    .collect();
                context! {
                    id => g.id,
                    title => g.title,
                    subtitle => g.subtitle,
                    cover => g.cover,
                    has_readable => readable.is_some(),
                    chapters => readable.map(|b| b.chapters.len()),
                    latest => readable.map(|b| b.version.clone()),
                    version_count => g.versions.len(),
                    too_new => too_new,
                }
            })
            .collect()
    };
    list.sort_by_key(|v| v.get_attr("title").map(|t| t.to_string()).unwrap_or_default());

    let inner = match st.env.get_template("library_content.html").and_then(|t| {
        t.render(context! {
            books => list,
            reader_version => aibodh::VERSION,
            update_cmd => "cargo install aibodh --force",
        })
    }) {
        Ok(s) => s,
        Err(e) => return internal(e),
    };
    render_shell(&st, "Library", None, &inner)
}

async fn upload(State(st): State<Arc<AppState>>, mut multipart: Multipart) -> Response {
    let mut data: Option<Vec<u8>> = None;
    while let Ok(Some(field)) = multipart.next_field().await {
        if field.name() == Some("book") {
            match field.bytes().await {
                Ok(b) => data = Some(b.to_vec()),
                Err(e) => return bad_request(format!("could not read upload: {e}")),
            }
            break;
        }
    }
    let Some(bytes) = data else {
        return bad_request("no file was uploaded");
    };

    match import_bytes(&st.library_dir, &bytes) {
        Ok((id, version)) => {
            let path = st
                .library_dir
                .join(&id)
                .join(format!("{version}.{}", aibodh::FILE_EXT));
            match open_book(&path) {
                Ok(book) => {
                    add_readable(&mut st.books.write().unwrap(), Arc::new(book));
                    Redirect::to(&format!("/book/{id}/{version}")).into_response()
                }
                // A newer version of a book you may already own, group it with
                // the book so it appears on the same card, flagged "needs update".
                Err(OpenErr::TooNew(info)) => {
                    add_too_new(&mut st.books.write().unwrap(), info);
                    Redirect::to(&format!("/book/{id}")).into_response()
                }
                Err(OpenErr::Other(e)) => internal(e),
            }
        }
        Err(e) => bad_request(format!("that file isn't a valid book: {e}")),
    }
}

async fn book_redirect(State(st): State<Arc<AppState>>, AxPath(id): AxPath<String>) -> Response {
    let books = st.books.read().unwrap();
    match books.get(&id).and_then(|g| g.latest_readable()) {
        Some(b) => Redirect::to(&format!("/book/{id}/{}", b.version)).into_response(),
        // No readable version (all too new), fall back to the library.
        None => Redirect::to("/").into_response(),
    }
}

async fn book_contents(
    State(st): State<Arc<AppState>>,
    AxPath((id, version)): AxPath<(String, String)>,
) -> Response {
    let (book, versions, has_too_new) = match resolve(&st, &id, &version) {
        Some(v) => v,
        None => return not_found("no such book version"),
    };
    let chapters: Vec<minijinja::Value> = book.chapters.iter().map(chapter_card).collect();
    let inner = match st.env.get_template("book_content.html").and_then(|t| {
        t.render(context! {
            book_id => book.id,
            version => book.version,
            versions => versions,
            has_too_new => has_too_new,
            packer_version => book.packer_version,
            book_title => book.title,
            subtitle => book.subtitle,
            chapters => chapters,
            reader_version => aibodh::VERSION,
            update_cmd => "cargo install aibodh --force",
        })
    }) {
        Ok(s) => s,
        Err(e) => return internal(e),
    };
    render_shell(&st, &book.title, Some(&book_url(&book)), &inner)
}

async fn chapter(
    State(st): State<Arc<AppState>>,
    AxPath((id, version, slug)): AxPath<(String, String, String)>,
) -> Response {
    let (book, _versions, _too) = match resolve(&st, &id, &version) {
        Some(v) => v,
        None => return not_found("no such book version"),
    };
    let Some(pos) = book.chapters.iter().position(|c| c.slug == slug) else {
        return not_found("no such chapter");
    };

    let body_html = {
        let conn = book.conn.lock().unwrap();
        match read_chapter_html(&conn, &slug) {
            Ok(h) => h,
            Err(e) => return internal(e),
        }
    };

    let cur = &book.chapters[pos];
    let prev = pos.checked_sub(1).map(|i| chapter_nav(&book.chapters[i]));
    let next = book.chapters.get(pos + 1).map(chapter_nav);

    let inner = match st.env.get_template("chapter_content.html").and_then(|t| {
        t.render(context! {
            book_id => book.id,
            version => book.version,
            book_title => book.title,
            chapter_title => cur.title,
            chapter_number => cur.number,
            body_html => body_html,
            prev => prev,
            next => next,
        })
    }) {
        Ok(s) => s,
        Err(e) => return internal(e),
    };
    render_shell(&st, &cur.title, Some(&book_url(&book)), &inner)
}

async fn book_asset(
    State(st): State<Arc<AppState>>,
    AxPath((id, version, path)): AxPath<(String, String, String)>,
) -> Response {
    let (book, _versions, _too) = match resolve(&st, &id, &version) {
        Some(v) => v,
        None => return not_found("no such book version"),
    };
    let full = format!("/assets/{}", path.trim_start_matches('/'));
    let conn = book.conn.lock().unwrap();
    match read_asset(&conn, &full) {
        Ok(Some((mime, bytes))) => {
            ([(header::CONTENT_TYPE, mime)], Body::from(bytes)).into_response()
        }
        Ok(None) => not_found("asset not found"),
        Err(e) => internal(e),
    }
}

async fn delete_book(State(st): State<Arc<AppState>>, AxPath(id): AxPath<String>) -> Response {
    st.books.write().unwrap().remove(&id);
    // Remove the whole book directory, every version.
    let dir = st.library_dir.join(&id);
    if std::fs::remove_dir_all(&dir).is_err() {
        let _ = std::fs::remove_dir(&dir);
    }
    Redirect::to("/").into_response()
}

async fn delete_version(
    State(st): State<Arc<AppState>>,
    AxPath((id, version)): AxPath<(String, String)>,
) -> Response {
    let redirect;
    {
        let mut books = st.books.write().unwrap();
        if let Some(group) = books.get_mut(&id) {
            // Remove only the one version's file, never the others.
            if let Some(idx) = group.versions.iter().position(|s| s.version() == version) {
                let removed = group.versions.remove(idx);
                let _ = std::fs::remove_file(removed.path());
            }
            if group.versions.is_empty() {
                books.remove(&id);
                let _ = std::fs::remove_dir(st.library_dir.join(&id));
                redirect = "/".to_string();
            } else {
                group.resort();
                // Back to a readable version's page if there is one, else home.
                redirect = match group.latest_readable() {
                    Some(b) => format!("/book/{id}/{}", b.version),
                    None => "/".to_string(),
                };
            }
        } else {
            redirect = "/".to_string();
        }
    }
    Redirect::to(&redirect).into_response()
}

async fn chrome_asset(AxPath(path): AxPath<String>) -> Response {
    let rel = path.trim_start_matches('/');
    match get_chrome(rel) {
        Some(file) => {
            let mime = mime_guess::from_path(rel).first_or_octet_stream();
            ([(header::CONTENT_TYPE, mime.as_ref())], file.data.into_owned()).into_response()
        }
        None => not_found("asset not found"),
    }
}

// ---- library / book loading ----------------------------------------------

/// Resolve (id, version) → (that readable book version, the full version list
/// with readable / too-new flags, whether any version is too new).
fn resolve(st: &AppState, id: &str, version: &str) -> Option<(Arc<Book>, Vec<minijinja::Value>, bool)> {
    let books = st.books.read().unwrap();
    let group = books.get(id)?;
    let book = group.get_readable(version)?.clone();
    let versions = group.version_values(Some(version));
    Some((book, versions, group.has_too_new()))
}

fn book_url(b: &Book) -> String {
    format!("/book/{}/{}", b.id, b.version)
}

fn group_for<'a>(
    map: &'a mut HashMap<String, BookGroup>,
    id: &str,
    title: &str,
    subtitle: &Option<String>,
    cover: Option<String>,
) -> &'a mut BookGroup {
    map.entry(id.to_string()).or_insert_with(|| BookGroup {
        id: id.to_string(),
        title: title.to_string(),
        subtitle: subtitle.clone(),
        cover,
        versions: Vec::new(),
    })
}

/// Insert or replace a readable version inside its group.
fn add_readable(map: &mut HashMap<String, BookGroup>, book: Arc<Book>) {
    let group = group_for(map, &book.id, &book.title, &book.subtitle, book.cover.clone());
    group.versions.retain(|s| s.version() != book.version);
    group.versions.push(Slot::Book(book));
    group.resort();
}

/// Insert or replace a too-new version inside its group (same card as the book).
fn add_too_new(map: &mut HashMap<String, BookGroup>, info: TooNewInfo) {
    let group = group_for(map, &info.id, &info.title, &info.subtitle, None);
    group.versions.retain(|s| s.version() != info.version);
    group.versions.push(Slot::TooNew(TooNew {
        version: info.version,
        packed_with: info.packed_with,
        path: info.path,
    }));
    group.resort();
}

/// Copy a book file into the library, returning (id, version).
fn import_book(library_dir: &Path, src: &Path) -> Result<(String, String)> {
    let bytes = std::fs::read(src).with_context(|| format!("reading {}", src.display()))?;
    import_bytes(library_dir, &bytes)
}

/// Validate raw book bytes, then store them as `<id>/<version>.<ext>`.
fn import_bytes(library_dir: &Path, bytes: &[u8]) -> Result<(String, String)> {
    let staging = library_dir.join(".import.tmp");
    std::fs::write(&staging, bytes)?;

    let ids = (|| -> Result<(String, String)> {
        let conn = Connection::open_with_flags(&staging, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
        verify_magic(&conn)?;
        let id = meta(&conn, "book_id").ok_or_else(|| anyhow!("book is missing a book_id"))?;
        let version = meta(&conn, "version").unwrap_or_else(|| "1".to_string());
        Ok((id, version))
    })();

    match ids {
        Ok((id, version)) => {
            let book_dir = library_dir.join(&id);
            std::fs::create_dir_all(&book_dir)?;
            let dest = book_dir.join(format!("{version}.{}", aibodh::FILE_EXT));
            std::fs::rename(&staging, &dest)
                .with_context(|| format!("installing to {}", dest.display()))?;
            Ok((id, version))
        }
        Err(e) => {
            let _ = std::fs::remove_file(&staging);
            Err(e)
        }
    }
}

fn load_library(library_dir: &Path) -> HashMap<String, BookGroup> {
    let mut books = HashMap::new();
    for path in find_sqlite_files(library_dir) {
        match open_book(&path) {
            Ok(book) => add_readable(&mut books, Arc::new(book)),
            Err(OpenErr::TooNew(info)) => add_too_new(&mut books, info),
            Err(OpenErr::Other(e)) => eprintln!("  ! skipping {}: {e}", path.display()),
        }
    }
    books
}

/// Every book file directly under the library or one level down (per-book dirs).
fn find_sqlite_files(library_dir: &Path) -> Vec<PathBuf> {
    let ext = Some(aibodh::FILE_EXT);
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(library_dir) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            if let Ok(inner) = std::fs::read_dir(&path) {
                for e in inner.flatten() {
                    let p = e.path();
                    if p.extension().and_then(|x| x.to_str()) == ext {
                        out.push(p);
                    }
                }
            }
        } else if path.extension().and_then(|x| x.to_str()) == ext {
            out.push(path);
        }
    }
    out
}

/// A version that can't be opened because it was packed by a newer aibodh.
struct TooNewInfo {
    id: String,
    title: String,
    subtitle: Option<String>,
    version: String,
    packed_with: Option<String>,
    path: PathBuf,
}

enum OpenErr {
    /// Format is newer than this reader understands, the buyer should update.
    TooNew(TooNewInfo),
    /// Not a valid book, corrupt, or a format too old to read.
    Other(anyhow::Error),
}

impl From<anyhow::Error> for OpenErr {
    fn from(e: anyhow::Error) -> Self {
        OpenErr::Other(e)
    }
}

fn open_book(path: &Path) -> Result<Book, OpenErr> {
    let conn = Connection::open_with_flags(
        path,
        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
    )
    .with_context(|| format!("opening {}", path.display()))?;

    if meta(&conn, "magic").as_deref() != Some(aibodh::MAGIC) {
        return Err(OpenErr::Other(anyhow!("not an aibodh book (bad or missing magic)")));
    }

    // Compatibility gate is the on-disk `format`, not the tool version.
    let book_format = meta(&conn, "format").unwrap_or_default();
    let fmt: u32 = book_format.parse().unwrap_or(u32::MAX);
    let packed_with = meta(&conn, "packer_version");
    let version = meta(&conn, "version").unwrap_or_else(|| "1".to_string());
    if fmt > aibodh::format_num() {
        // meta is a stable key/value table, so title/subtitle are readable even
        // when the rest of the schema is newer than we understand.
        let id = meta(&conn, "book_id").unwrap_or_else(|| "unknown".to_string());
        let title = meta(&conn, "title").unwrap_or_else(|| id.clone());
        let subtitle = meta(&conn, "subtitle");
        return Err(OpenErr::TooNew(TooNewInfo {
            id,
            title,
            subtitle,
            version,
            packed_with,
            path: path.to_path_buf(),
        }));
    }
    if fmt < aibodh::MIN_READABLE_FORMAT {
        return Err(OpenErr::Other(anyhow!(
            "format {book_format} is too old for this reader; repack the book"
        )));
    }

    let id = meta(&conn, "book_id").ok_or_else(|| anyhow!("missing book_id"))?;
    let title = meta(&conn, "title").unwrap_or_else(|| id.clone());
    let subtitle = meta(&conn, "subtitle");
    let cover = meta(&conn, "cover");
    let chapters = load_chapters(&conn)?;
    if chapters.is_empty() {
        return Err(OpenErr::Other(anyhow!("book has no chapters")));
    }
    Ok(Book {
        id,
        version,
        packer_version: packed_with,
        title,
        subtitle,
        cover,
        chapters,
        conn: Mutex::new(conn),
        path: path.to_path_buf(),
    })
}

// ---- rendering + DB helpers ----------------------------------------------

fn render_shell(
    st: &AppState,
    page_title: &str,
    contents_url: Option<&str>,
    content: &str,
) -> Response {
    match st.env.get_template("shell.html").and_then(|t| {
        t.render(context! {
            book_title => "AIBodh",
            page_title => page_title,
            contents_url => contents_url,
            content => content,
        })
    }) {
        Ok(html) => Html(html).into_response(),
        Err(e) => internal(e),
    }
}

/// Case-insensitive lookup into the embedded chrome (macOS collapses
/// Phosphor.woff2 → phosphor.woff2 when the assets are copied).
fn get_chrome(rel: &str) -> Option<rust_embed::EmbeddedFile> {
    if let Some(f) = Chrome::get(rel) {
        return Some(f);
    }
    let want = rel.to_ascii_lowercase();
    Chrome::iter()
        .find(|p| p.to_ascii_lowercase() == want)
        .and_then(|p| Chrome::get(&p))
}

/// Compact value for prev/next navigation: slug + a short display label.
fn chapter_nav(c: &aibodh::ChapterRow) -> minijinja::Value {
    let label = c.card_title.clone().unwrap_or_else(|| c.title.clone());
    context! { slug => c.slug, label => label }
}

/// Richer value for the contents grid (video card).
fn chapter_card(c: &aibodh::ChapterRow) -> minijinja::Value {
    let card_title = c.card_title.clone().unwrap_or_else(|| c.title.clone());
    context! {
        slug => c.slug,
        number => c.number,
        title => c.title,
        card_title => card_title,
        excerpt => c.excerpt,
        poster => c.poster,
        video => c.video,
        access => c.access,
    }
}

fn read_chapter_html(conn: &Connection, slug: &str) -> Result<String> {
    let html: Vec<u8> =
        conn.query_row("SELECT html FROM chapters WHERE slug = ?1", [slug], |r| r.get(0))?;
    Ok(String::from_utf8(html)?)
}

fn read_asset(conn: &Connection, path: &str) -> Result<Option<(String, Vec<u8>)>> {
    let row = conn
        .query_row(
            "SELECT mime, bytes FROM assets WHERE path = ?1",
            [path],
            |r| Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?)),
        )
        .ok();
    Ok(row)
}

fn load_chapters(conn: &Connection) -> Result<Vec<aibodh::ChapterRow>> {
    let mut stmt = conn.prepare(
        "SELECT slug, ordinal, number, title, card_title, excerpt, poster, video, access \
         FROM chapters ORDER BY ordinal ASC",
    )?;
    let rows = stmt
        .query_map([], |r| {
            Ok(aibodh::ChapterRow {
                slug: r.get(0)?,
                ordinal: r.get(1)?,
                number: r.get(2)?,
                title: r.get(3)?,
                card_title: r.get(4)?,
                excerpt: r.get(5)?,
                poster: r.get(6)?,
                video: r.get(7)?,
                access: r.get(8)?,
            })
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;
    Ok(rows)
}

/// Cheap check that a file is an aibodh book at all (used before importing).
/// Format compatibility is decided later, in `open_book`.
fn verify_magic(conn: &Connection) -> Result<()> {
    let magic = meta(conn, "magic").ok_or_else(|| anyhow!("missing magic"))?;
    if magic != aibodh::MAGIC {
        return Err(anyhow!("unexpected magic {magic:?}"));
    }
    Ok(())
}

fn meta(conn: &Connection, key: &str) -> Option<String> {
    conn.query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0))
        .ok()
}

fn internal(e: impl std::fmt::Display) -> Response {
    eprintln!("error: {e}");
    (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
}

fn not_found(msg: &'static str) -> Response {
    (StatusCode::NOT_FOUND, msg).into_response()
}

fn bad_request(msg: impl Into<String>) -> Response {
    (StatusCode::BAD_REQUEST, msg.into()).into_response()
}
