use std::collections::HashMap;
use std::path::{Path, PathBuf};

use crate::Source;

/// Opaque identifier for a source file within a [`Session`](crate::Session).
///
/// `FileId` values are allocated monotonically (by `load_string`/`load_file`) and serve
/// as cheap, copy-friendly handles throughout the pipeline. They carry no meaning
/// outside the session that created them.
///
/// # Example
///
/// ```
/// use nexus_lang::Session;
///
/// let mut session = Session::new();
/// let id = session.load_string("let x = 1;", None);
///
/// // FileId is Copy and comparable.
/// let id2 = id;
/// assert_eq!(id, id2);
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FileId(u32);

struct SourceEntry {
    text: String,
    name: Option<String>,
    canonical: Option<PathBuf>,
}

/// Owns all source texts for a session and hands out [`FileId`] handles.
///
/// Files enter the map through two paths:
///
/// - [`load_string`](SourceMap::load_string); for inline sources (REPL, `Nexus::run`, tests).
///   Always allocates a new [`FileId`]; no deduplication.
/// - [`load_file`](SourceMap::load_file); for on-disk files. Canonicalizes the path and
///   returns the existing [`FileId`] if the file was already loaded.
///
/// Source text is retained for the lifetime of the map so that [`Source`] views
/// returned by [`get`](SourceMap::get) remain valid.
pub struct SourceMap {
    entries: Vec<SourceEntry>,
    by_path: HashMap<PathBuf, FileId>,
}

impl SourceMap {
    /// Create a new, empty source map.
    #[must_use]
    pub fn new() -> Self {
        Self {
            entries: Vec::new(),
            by_path: HashMap::new(),
        }
    }

    /// Intern an in-memory source string and return its [`FileId`].
    ///
    /// Each call allocates a new [`FileId`] regardless of content. The optional `name`
    /// is used as a display label in diagnostics.
    pub fn load_string(&mut self, code: &str, name: Option<&str>) -> FileId {
        let id = FileId(
            u32::try_from(self.entries.len()).expect("session exceeded u32::MAX source files"),
        );
        self.entries.push(SourceEntry {
            text: code.to_owned(),
            name: name.map(str::to_owned),
            canonical: None,
        });
        id
    }

    /// Read a file from disk and return its [`FileId`].
    ///
    /// The path is canonicalized before lookup. If the same file has been loaded
    /// before, the existing [`FileId`] is returned without re-reading the file.
    ///
    /// # Errors
    ///
    /// Returns an [`io::Error`](std::io::Error) if the path cannot be canonicalized
    /// or the file cannot be read.
    pub fn load_file(&mut self, path: impl AsRef<Path>) -> std::io::Result<FileId> {
        let canonical = std::fs::canonicalize(path.as_ref())?;

        if let Some(&id) = self.by_path.get(&canonical) {
            return Ok(id);
        }

        let text = std::fs::read_to_string(path.as_ref())?;
        let name = canonical.display().to_string();
        let id = FileId(
            u32::try_from(self.entries.len()).expect("session exceeded u32::MAX source files"),
        );

        self.entries.push(SourceEntry {
            text,
            name: Some(name),
            canonical: Some(canonical.clone()),
        });
        self.by_path.insert(canonical, id);

        Ok(id)
    }

    /// Return the canonical filesystem path for a file-backed source.
    ///
    /// Returns `None` for sources loaded via [`load_string`](SourceMap::load_string).
    #[must_use]
    pub(crate) fn canonical_path(&self, id: FileId) -> Option<&Path> {
        self.entries.get(id.0 as usize)?.canonical.as_deref()
    }

    /// Return a [`Source`] view for a previously loaded file.
    ///
    /// The returned view borrows directly from this map, so it is valid for as
    /// long as the map is not mutated or dropped.
    ///
    /// Returns `None` if `id` was not allocated by this map.
    #[must_use]
    pub fn get(&self, id: FileId) -> Option<Source<'_>> {
        let entry = self.entries.get(id.0 as usize)?;
        Some(match &entry.name {
            Some(name) => Source::new_with_name(&entry.text, name),
            None => Source::new(&entry.text),
        })
    }
}

impl Default for SourceMap {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn canonical_path_present_for_file() {
        let mut map = SourceMap::new();
        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
        let id = map.load_file(path).unwrap();
        assert!(map.canonical_path(id).is_some());
    }

    #[test]
    fn canonical_path_absent_for_string() {
        let mut map = SourceMap::new();
        let id = map.load_string("let x = 1;", None);
        assert!(map.canonical_path(id).is_none());
    }

    #[test]
    fn load_string_unique_ids() {
        let mut map = SourceMap::new();
        let id1 = map.load_string("a", None);
        let id2 = map.load_string("b", None);
        assert_ne!(id1, id2);
    }

    #[test]
    fn load_string_roundtrip_no_name() {
        let mut map = SourceMap::new();
        let id = map.load_string("let x = 1;", None);
        let source = map.get(id).unwrap();
        assert_eq!(source.code(), "let x = 1;");
        assert_eq!(source.name(), None);
    }

    #[test]
    fn load_string_roundtrip_with_name() {
        let mut map = SourceMap::new();
        let id = map.load_string("let x = 1;", Some("test.nxs"));
        let source = map.get(id).unwrap();
        assert_eq!(source.code(), "let x = 1;");
        assert_eq!(source.name(), Some("test.nxs"));
    }

    #[test]
    fn get_foreign_id_returns_none() {
        let mut map1 = SourceMap::new();
        let id = map1.load_string("let x = 1;", None);

        let map2 = SourceMap::new();
        assert!(map2.get(id).is_none());
    }

    #[test]
    fn load_file_roundtrip() {
        let mut map = SourceMap::new();
        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
        let id = map.load_file(path).unwrap();
        assert!(map.get(id).unwrap().code().contains("[package]"));
    }

    #[test]
    fn load_file_dedup() {
        let mut map = SourceMap::new();
        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
        let id1 = map.load_file(path).unwrap();
        let id2 = map.load_file(path).unwrap();
        assert_eq!(id1, id2);
    }

    #[test]
    fn load_file_missing() {
        let mut map = SourceMap::new();
        assert!(map.load_file("/nonexistent/path/file.nxs").is_err());
    }
}
