//! Shared types + on-disk format for the aibodh book tool.
//!
//! A book is a plain (unencrypted) SQLite database: chapter HTML, images, and
//! metadata are stored as-is. It carries a branded `.aibodh` extension so buyers
//! don't confuse it with a PDF or a raw `.sqlite`, but the content is not
//! protected, anyone with the file can read it.

use std::path::PathBuf;

use anyhow::{anyhow, Context, Result};

/// Magic + format version stored in the `meta` table so the server can refuse
/// a DB it doesn't understand.
pub const FORMAT_VERSION: &str = "4";
pub const MAGIC: &str = "aibodh-book";

/// File extension for a packed book. The file is SQLite inside, but it carries a
/// distinct, branded extension so buyers don't confuse it with a PDF or a raw DB.
pub const FILE_EXT: &str = "aibodh";

/// This build's version, the aibodh release. Also stamped into every packed book
/// as `packer_version` (the packer shares this workspace version).
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

/// Oldest on-disk `format` this reader can still parse. A book is readable when
/// its `format` is in `[MIN_READABLE_FORMAT, FORMAT_VERSION]`. A higher number
/// means it was packed by a newer aibodh and the buyer should update the tool.
/// Raise this only if you drop support for an old format; widen the range (keep
/// this low) when you add a new format but keep reading the old one.
pub const MIN_READABLE_FORMAT: u32 = 4;

/// The current write format as a number.
pub fn format_num() -> u32 {
    FORMAT_VERSION.parse().unwrap_or(0)
}

/// One chapter's metadata (the HTML body is fetched separately).
/// The `card_*`, `number`, `poster`, `video` fields power the blog-style
/// contents grid; they're `None` for books packed without a book page.
#[derive(Debug, Clone)]
pub struct ChapterRow {
    pub slug: String,
    pub ordinal: i64,
    pub number: Option<i64>,
    pub title: String,
    pub card_title: Option<String>,
    pub excerpt: Option<String>,
    pub poster: Option<String>,
    pub video: Option<String>,
    pub access: Option<String>,
}

/// Per-user library directory where imported book DBs live, e.g.
/// `~/Library/Application Support/aibodh/library` on macOS. Created on demand.
pub fn library_dir() -> Result<PathBuf> {
    let dirs = directories::ProjectDirs::from("com", "aibodh", "aibodh")
        .ok_or_else(|| anyhow!("could not determine a home directory for the library"))?;
    let dir = dirs.data_dir().join("library");
    std::fs::create_dir_all(&dir)
        .with_context(|| format!("creating library dir {}", dir.display()))?;
    Ok(dir)
}

/// Sanitize a version string so it is safe to use in both a file name and a
/// URL path segment (keeps `A-Za-z0-9 . _ -`, collapses anything else to `-`).
pub fn sanitize_version(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for ch in text.chars() {
        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
            out.push(ch);
        } else {
            out.push('-');
        }
    }
    let trimmed = out.trim_matches(['-', '.']).to_string();
    if trimmed.is_empty() {
        "1".to_string()
    } else {
        trimmed
    }
}

/// Compare two version strings numerically component-by-component (so
/// "0.10.0" > "0.9.0"), falling back to lexical order for non-numeric parts.
pub fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
    fn parts(v: &str) -> Vec<(u64, String)> {
        v.split(|c: char| c == '.' || c == '-' || c == '_')
            .map(|p| (p.parse::<u64>().unwrap_or(0), p.to_string()))
            .collect()
    }
    let (pa, pb) = (parts(a), parts(b));
    for i in 0..pa.len().max(pb.len()) {
        let x = pa.get(i).cloned().unwrap_or_default();
        let y = pb.get(i).cloned().unwrap_or_default();
        let ord = x.0.cmp(&y.0).then_with(|| x.1.cmp(&y.1));
        if ord != std::cmp::Ordering::Equal {
            return ord;
        }
    }
    std::cmp::Ordering::Equal
}

/// Turn a title into a stable, filesystem-safe book id.
pub fn slugify(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut prev_dash = false;
    for ch in text.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash && !out.is_empty() {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

/// The DDL for a fresh book DB.
pub const SCHEMA_SQL: &str = r#"
CREATE TABLE meta (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL
);
CREATE TABLE chapters (
    id         INTEGER PRIMARY KEY,
    slug       TEXT UNIQUE NOT NULL,
    ordinal    INTEGER NOT NULL,
    number     INTEGER,             -- "Chapter N" label; NULL if unknown
    title      TEXT NOT NULL,       -- full page-header title
    card_title TEXT,                -- short title for the contents card
    excerpt    TEXT,                -- one-line blurb on the card
    poster     TEXT,                -- book-scoped poster image URL
    video      TEXT,                -- book-scoped preview .mp4 URL
    access     TEXT,                -- "free" | "ebook" (informational)
    html       BLOB NOT NULL        -- inner HTML of the chapter body
);
CREATE TABLE assets (
    path  TEXT PRIMARY KEY,         -- e.g. /assets/book_assets/chapter8/foo.png
    mime  TEXT NOT NULL,
    bytes BLOB NOT NULL             -- raw file contents
);
CREATE INDEX idx_chapters_ordinal ON chapters(ordinal);
"#;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn version_ordering() {
        assert_eq!(version_cmp("0.10.0", "0.9.0"), std::cmp::Ordering::Greater);
        assert_eq!(version_cmp("1.0", "1.0"), std::cmp::Ordering::Equal);
    }

    #[test]
    fn sanitize() {
        assert_eq!(sanitize_version("0.12.0"), "0.12.0");
        assert_eq!(sanitize_version("v1/../x"), "v1-..-x");
        assert_eq!(sanitize_version(""), "1");
    }
}
