use ecow::eco_format;
use typst_syntax::Spanned;

use crate::diag::{At, LineCol, LoadError, LoadedWithin, SourceResult, bail};
use crate::engine::Engine;
use crate::foundations::{Str, Value, func, scope};
use crate::loading::{DataSource, Load};

/// Reads structured data from a JSON file.
///
/// The file must contain a valid JSON value, such as object or array. The JSON
/// values will be converted into corresponding Typst values as listed in the
/// @json:conversion[table below].
///
/// The function returns a dictionary, an array or, depending on the JSON file,
/// another JSON data type.
///
/// The JSON files in the example contain objects with the keys `temperature`,
/// `unit`, and `weather`.
///
/// = Example <example>
/// ```example
/// #let forecast(day) = block[
///   #box(square(
///     width: 2cm,
///     inset: 8pt,
///     fill: if day.weather == "sunny" {
///       yellow
///     } else {
///       aqua
///     },
///     align(
///       bottom + right,
///       strong(day.weather),
///     ),
///   ))
///   #h(6pt)
///   #set text(22pt, baseline: -8pt)
///   #day.temperature °#day.unit
/// ]
///
/// #forecast(json("monday.json"))
/// #forecast(json("tuesday.json"))
/// ```
///
/// = #short-or-long[Conversion][Conversion details] <conversion>
/// #docs-table(
///   table.header[JSON value][Converted into Typst],
///
///   [`null`],
///   [`{none}`],
///
///   [bool],
///   [@bool],
///
///   [number],
///   [@float or @int],
///
///   [string],
///   [@str],
///
///   [array],
///   [@array],
///
///   [object],
///   [@dictionary],
/// )
///
/// #docs-table(
///   table.header[Typst value][Converted into JSON],
///
///   [types that can be converted from JSON],
///   [corresponding JSON value],
///
///   [@bytes],
///   [string via @repr],
///
///   [@symbol],
///   [string],
///
///   [@content],
///   [an object describing the content],
///
///   [other types (@length, etc.)],
///   [string via @repr],
/// )
///
/// == Notes <notes>
/// - In most cases, JSON numbers will be converted to floats or integers
///   depending on whether they are whole numbers. However, be aware that
///   integers larger than 2#super[63]-1 or smaller than -2#super[63] will be
///   converted to floating-point numbers, which may result in an approximative
///   value.
///
/// - Bytes are not encoded as JSON arrays for performance and readability
///   reasons. Consider using @cbor.encode for binary data.
///
/// - The `repr` function is @repr:debugging-only[for debugging purposes only],
///   and its output is not guaranteed to be stable across Typst versions.
#[func(scope, title = "JSON")]
pub fn json(
    engine: &mut Engine,
    /// A path to a JSON file or raw JSON bytes.
    source: Spanned<DataSource>,
) -> SourceResult<Value> {
    let loaded = source.load(engine.world)?;
    let raw = loaded.data.as_slice();
    // If the file starts with a UTF-8 Byte Order Mark (BOM), return a
    // friendly error message.
    if raw.starts_with(b"\xef\xbb\xbf") {
        bail!(
            LoadError::text(
                LineCol::one_based(1, 1),
                "failed to parse JSON",
                "unexpected Byte Order Mark",
            )
            .within(&loaded)
            .with_hint("JSON requires UTF-8 without a BOM")
        );
    }

    serde_json::from_slice(raw)
        .map_err(|err| {
            let pos = LineCol::one_based(err.line(), err.column());
            LoadError::text(pos, "failed to parse JSON", err)
        })
        .within(&loaded)
}

#[scope]
impl json {
    /// Encodes structured data into a JSON string.
    #[func(title = "Encode JSON")]
    pub fn encode(
        /// Value to be encoded.
        value: Spanned<Value>,
        /// Whether to pretty print the JSON with newlines and indentation.
        #[named]
        #[default(true)]
        pretty: bool,
    ) -> SourceResult<Str> {
        let Spanned { v: value, span } = value;
        if pretty {
            serde_json::to_string_pretty(&value)
        } else {
            serde_json::to_string(&value)
        }
        .map(|v| v.into())
        .map_err(|err| eco_format!("failed to encode value as JSON ({err})"))
        .at(span)
    }
}
