//! LZSS string compression with base-62 safe text output.
//!
//! This crate compresses a string with an LZSS sliding-window dictionary, then
//! frames the result in a 62-character alphabet (`A-Za-z0-9`) so the output is
//! plain text. The format is unique to this crate and round-trips with itself.
//!
//! The algorithm works over UTF-16 code units, matching JavaScript string
//! semantics. Surrogate pairs such as emoji are processed as two units. A
//! [`compress`] call followed by [`decompress`] returns the original string for
//! any input.
//!
//! # Examples
//!
//! ```
//! let packed = lzbase62::compress("hello hello hello");
//! assert_eq!(packed, "tYVccfxGM");
//! assert_eq!(lzbase62::decompress(&packed), "hello hello hello");
//! ```
//!
//! Streaming delivers output through a callback as it is produced:
//!
//! ```
//! let mut chunks = String::new();
//! let returned = lzbase62::compress_with(
//!     "hello hello hello",
//!     lzbase62::Options::new().on_data(|c| chunks.push_str(c)),
//! );
//! assert_eq!(returned, "");
//! assert_eq!(lzbase62::decompress(&chunks), "hello hello hello");
//! ```

#![forbid(unsafe_code)]
#![warn(missing_docs)]

mod compressor;
mod config;
mod decompressor;

use compressor::Compressor;

/// Per-chunk callback type.
type DataCallback<'a> = Box<dyn FnMut(&str) + 'a>;
/// End-of-stream callback type.
type EndCallback<'a> = Box<dyn FnMut() + 'a>;

/// Streaming callbacks for [`compress_with`] and [`decompress_with`].
///
/// Without `on_data`, output is built and returned as a single string. With
/// `on_data`, each output chunk is passed to the callback. `compress_with` then
/// returns the empty string, while `decompress_with` still returns the full
/// result. `on_end` fires once after the last chunk.
#[derive(Default)]
pub struct Options<'a> {
    on_data: Option<DataCallback<'a>>,
    on_end: Option<EndCallback<'a>>,
}

impl std::fmt::Debug for Options<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // The callbacks are boxed closures with no useful representation, so
        // report whether each one is set.
        f.debug_struct("Options")
            .field("on_data", &self.on_data.is_some())
            .field("on_end", &self.on_end.is_some())
            .finish()
    }
}

impl<'a> Options<'a> {
    /// Create empty options.
    #[must_use]
    pub fn new() -> Self {
        Options {
            on_data: None,
            on_end: None,
        }
    }

    /// Set the per-chunk callback.
    #[must_use]
    pub fn on_data<F: FnMut(&str) + 'a>(mut self, f: F) -> Self {
        self.on_data = Some(Box::new(f));
        self
    }

    /// Set the end-of-stream callback.
    #[must_use]
    pub fn on_end<F: FnMut() + 'a>(mut self, f: F) -> Self {
        self.on_end = Some(Box::new(f));
        self
    }
}

/// Output target shared by the compressor and the streaming path.
///
/// When a data callback is present, chunks flow to it and `result` stays empty.
/// Otherwise chunks accumulate into `result`.
pub(crate) struct Sink<'a> {
    result: String,
    options: Options<'a>,
}

impl<'a> Sink<'a> {
    fn new(options: Options<'a>) -> Self {
        Sink {
            result: String::new(),
            options,
        }
    }

    /// Deliver one chunk of base-62 bytes. All bytes come from the base-62
    /// table, so the slice is always valid ASCII.
    pub(crate) fn push(&mut self, bytes: &[u8]) {
        debug_assert!(bytes.is_ascii(), "base-62 output must be ASCII");
        let chunk = std::str::from_utf8(bytes).expect("base-62 output is ASCII");
        match self.options.on_data.as_mut() {
            Some(cb) => cb(chunk),
            None => self.result.push_str(chunk),
        }
    }

    /// Fire the end callback once.
    pub(crate) fn end(&mut self) {
        if let Some(cb) = self.options.on_end.as_mut() {
            cb();
        }
    }
}

/// Compress a string into a base-62 text string.
///
/// Returns an empty string for empty input.
///
/// # Examples
///
/// ```
/// assert_eq!(lzbase62::compress("a"), "tR");
/// assert_eq!(lzbase62::compress(""), "");
/// ```
#[must_use]
pub fn compress(data: &str) -> String {
    compress_with(data, Options::new())
}

/// Compress a string with streaming options.
///
/// With `on_data` set, output is delivered through the callback and the return
/// value is the empty string. Without it, the full compressed string is
/// returned. Empty input returns an empty string and fires no callbacks.
pub fn compress_with(data: &str, options: Options<'_>) -> String {
    if data.is_empty() {
        return String::new();
    }
    let units: Vec<u16> = data.encode_utf16().collect();
    let mut sink = Sink::new(options);
    Compressor::run(&units, &mut sink);
    sink.result
}

/// Decompress a base-62 text string back to the original.
///
/// Returns an empty string for empty input. Characters outside the alphabet are
/// skipped. Malformed input never panics.
///
/// The result is a `String`, so a decoded lone surrogate (a code unit in
/// `U+D800..=U+DFFF` with no pair) becomes the replacement character `U+FFFD`.
/// Compressing a `&str` cannot produce a lone surrogate, so a
/// `decompress(compress(s))` round-trip is exact. Use [`decompress_units`] to
/// recover raw code units from a crafted or independently produced stream.
///
/// # Examples
///
/// ```
/// assert_eq!(lzbase62::decompress("tR"), "a");
/// assert_eq!(lzbase62::decompress(""), "");
/// ```
#[must_use]
pub fn decompress(data: &str) -> String {
    decompress_with(data, Options::new())
}

/// Decompress a base-62 text string into raw UTF-16 code units.
///
/// This preserves every decoded code unit, including a lone surrogate that
/// [`decompress`] would flatten to `U+FFFD`. Returns an empty vector for empty
/// input. Characters outside the alphabet are skipped. Malformed input never
/// panics.
///
/// # Examples
///
/// ```
/// assert_eq!(lzbase62::decompress_units("tR"), vec![0x61]);
/// assert_eq!(lzbase62::decompress_units("wmle"), vec![0xD83D]);
/// ```
#[must_use]
pub fn decompress_units(data: &str) -> Vec<u16> {
    if data.is_empty() {
        return Vec::new();
    }
    let units: Vec<u16> = data.encode_utf16().collect();
    decompressor::decompress(&units)
}

/// Decompress a base-62 text string with streaming options.
///
/// With `on_data` set, the input is decoded in full, then the result is
/// delivered to the callback in chunks and also returned. Empty input returns
/// an empty string and fires no callbacks.
pub fn decompress_with(data: &str, mut options: Options<'_>) -> String {
    if data.is_empty() {
        return String::new();
    }
    let units: Vec<u16> = data.encode_utf16().collect();
    let out = decompressor::decompress(&units);

    if let Some(cb) = options.on_data.as_mut() {
        stream_decompressed(&out, cb);
    }
    if let Some(cb) = options.on_end.as_mut() {
        cb();
    }
    String::from_utf16_lossy(&out)
}

/// Deliver decompressed output to a data callback in the same chunk sizes the
/// streaming decoder uses.
fn stream_decompressed(out: &[u16], cb: &mut (dyn FnMut(&str) + '_)) {
    let chunk = config::DECOMPRESS_CHUNK_SIZE - config::WINDOW_MAX;
    let mut i = 0;
    while i < out.len() {
        let mut end = (i + chunk).min(out.len());
        // Do not split a surrogate pair across the boundary. A trailing high
        // surrogate carries into the next chunk so each chunk converts cleanly.
        if end < out.len() && (0xD800..=0xDBFF).contains(&out[end - 1]) {
            end -= 1;
        }
        cb(&String::from_utf16_lossy(&out[i..end]));
        i = end;
    }
}
