//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree
//! plus an in-memory augmented manifest and a disk-mounted `cph-render` package.
//!
//! ## Model (ADR-0011)
//!
//! The `main` file is the target's **template** (e.g. `<root>/exports/student.typ`),
//! a *real* file under the lesson root — no framework-generated driver. The
//! template `toml()`-reads an injected manifest and `include`s each part's
//! content via computed root-relative paths. The engine injects the manifest as
//! a virtual file (see below) and sets `sys.inputs.manifest` to its vpath.
//!
//! ## File resolution
//!
//! - **The template** (`main()`): a `FileId` with `VirtualRoot::Project` whose
//!   vpath is the template's path under the lesson root (e.g. `/exports/student.typ`).
//!   It is read from disk like any other project file; its `include "/<part>/…"`
//!   paths anchor at the lesson root (`--root`).
//! - **The augmented manifest**: served in-memory at `VirtualRoot::Project` +
//!   vpath [`MANIFEST_VPATH`] (`/.cph/manifest.toml`). It is **never** written to
//!   disk — the engine builds it (original manifest + a per-part `fields` array)
//!   and the template reads it via `toml(sys.inputs.manifest)`.
//! - **Project files**: any other `FileId` with `VirtualRoot::Project` is read
//!   from `root.join(vpath.realize)`. Used for content `.typ`, images,
//!   `element.toml` `toml()`, etc.
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec is
//!   read from `render_dir.join(vpath.realize)`, avoiding installing the package
//!   into the typst local-package directory.
//! - **`@preview/<name>:<version>`**: resolved offline from the in-repo vendored
//!   preview tree at `<render_dir>/vendor/typst-packages/preview/<name>/<version>/`.
//!   The render package imports `@preview/numbly:0.1.0`; reading it from the
//!   vendored dir keeps compilation network-free.
//! - Any *other* `@package` namespace yields a `NotFound` error.
//!
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated accesses
//! during one compilation are cheap, as the `World` contract expects.

use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};

use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Dict, Duration, Value};
use typst::syntax::package::{PackageSpec, PackageVersion};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore;

/// Root-relative vpath the augmented manifest is served at (in-memory only).
///
/// A **leading slash** is essential: the template lives under `exports/`, and
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
/// own directory — a bare name would miss. A root-relative absolute path anchors
/// at `--root` (the lesson root) regardless of where the template sits.
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";

/// The package spec the template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec {
    PackageSpec {
        namespace: "local".into(),
        name: "cph-render".into(),
        version: PackageVersion {
            major: 0,
            minor: 1,
            patch: 0,
        },
    }
}

/// A typst `World` for one lesson + target compilation.
pub struct LessonWorld {
    /// Engineering-file root (the typst project root).
    root: PathBuf,
    /// On-disk location of the `cph-render` package.
    render_dir: PathBuf,
    /// The render package spec (`@local/cph-render:0.1.0`).
    render_spec: PackageSpec,
    /// FileId of the template entrypoint (a real file under `root`).
    main: FileId,
    /// FileId of the in-memory augmented manifest.
    manifest_id: FileId,
    /// The augmented-manifest source (in-memory; never on disk).
    manifest_source: Source,
    /// Standard library, with `sys.inputs.manifest` set.
    library: LazyHash<Library>,
    /// Shared font store (book + lazily-loaded fonts).
    fonts: Arc<FontStore>,
    /// Source cache for on-disk files.
    sources: Mutex<HashMap<FileId, Source>>,
}

impl LessonWorld {
    /// Build a world whose `main` is the template at `<root>/<template>` and
    /// whose injected manifest is `manifest_src` (served virtually at
    /// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
    ///
    /// `template` is the lesson-root-relative template path (e.g.
    /// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
    pub fn new(
        root: PathBuf,
        render_dir: PathBuf,
        template: &Path,
        manifest_src: String,
        fonts: Arc<FontStore>,
    ) -> Self {
        let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
            .expect("template vpath is a valid virtual path");
        let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));

        let manifest_vpath =
            VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
        let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
        let manifest_source = Source::new(manifest_id, manifest_src);

        // Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's
        // `toml(sys.inputs.manifest)` reads the augmented manifest.
        let mut inputs = Dict::new();
        inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into()));
        let library = Library::builder().with_inputs(inputs).build();

        Self {
            root,
            render_dir,
            render_spec: render_package_spec(),
            main,
            manifest_id,
            manifest_source,
            library: LazyHash::new(library),
            fonts,
            sources: Mutex::new(HashMap::new()),
        }
    }

    /// Map a `FileId` to its on-disk path, honoring the project / render-package
    /// / vendored-`@preview` roots. Returns a `FileError` for any other package
    /// root.
    fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
        let vpath = id.vpath();
        match id.root() {
            VirtualRoot::Project => vpath.realize(&self.root).map_err(Into::into),
            VirtualRoot::Package(spec) if *spec == self.render_spec => {
                vpath.realize(&self.render_dir).map_err(Into::into)
            }
            // `@preview/<name>:<version>` → vendored preview tree under the
            // render dir. Offline, no download: mirrors typst's preview-cache
            // layout so e.g. `@preview/numbly:0.1.0` (imported by the render
            // package) reads from
            // `<render_dir>/vendor/typst-packages/preview/numbly/0.1.0/`.
            VirtualRoot::Package(spec) if spec.namespace == "preview" => {
                let pkg_root = self
                    .render_dir
                    .join("vendor")
                    .join("typst-packages")
                    .join("preview")
                    .join(spec.name.as_str())
                    .join(spec.version.to_string());
                vpath.realize(&pkg_root).map_err(Into::into)
            }
            VirtualRoot::Package(spec) => Err(FileError::Package(
                typst::diag::PackageError::NotFound(spec.clone()),
            )),
        }
    }

    /// Read raw bytes for `id` from disk (virtual files excluded — handled by
    /// callers).
    fn read_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
        let path = self.resolve(id)?;
        std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))
    }
}

impl World for LessonWorld {
    fn library(&self) -> &LazyHash<Library> {
        &self.library
    }

    fn book(&self) -> &LazyHash<FontBook> {
        self.fonts.book()
    }

    fn main(&self) -> FileId {
        self.main
    }

    fn source(&self, id: FileId) -> FileResult<Source> {
        if id == self.manifest_id {
            return Ok(self.manifest_source.clone());
        }
        // Cache hit?
        if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
            return Ok(src.clone());
        }
        let bytes = self.read_bytes(id)?;
        let text = String::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?;
        let source = Source::new(id, text);
        self.sources
            .lock()
            .expect("sources mutex")
            .insert(id, source.clone());
        Ok(source)
    }

    fn file(&self, id: FileId) -> FileResult<Bytes> {
        if id == self.manifest_id {
            return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
        }
        let bytes = self.read_bytes(id)?;
        Ok(Bytes::new(bytes))
    }

    fn font(&self, index: usize) -> Option<Font> {
        self.fonts.font(index)
    }

    fn today(&self, _offset: Option<Duration>) -> Option<Datetime> {
        // No clock dependence needed; lessons are reproducible.
        None
    }
}

/// Render a relative `Path` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String {
    let mut out = String::new();
    for comp in path.components() {
        if let Component::Normal(s) = comp {
            if !out.is_empty() {
                out.push('/');
            }
            out.push_str(&s.to_string_lossy());
        }
    }
    out
}
