//! Typed, immutable `file:` URIs with cross-platform path inspection.
//!
//! See [`PathUri`] for scheme, normalization, and serialization behavior.

use base64::Engine;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use std::fmt;
use std::io;
use std::path::Path;
use std::str::FromStr;
use thiserror::Error;
use ts_rs::TS;
use url::Url;

pub const FILE_SCHEME: &str = "file";
const BAD_PATH_URI_PREFIX: &str = "file:///%00/bad/path/";

/// An immutable, cross-platform representation of a `file:` URI.
///
/// Only the `file:` scheme is currently accepted. Construction validates the
/// URL, and the URI cannot be mutated after construction. [`Self::basename`],
/// [`Self::parent`], and [`Self::join`] operate on URI path segments without
/// interpreting them using the operating system running Codex. Fallback URIs
/// created by [`Self::from_abs_path`] are opaque to these lexical operations.
///
/// `file:` paths retain their URI spelling so they can be parsed independently
/// of the current host. In particular, `/C:/src` remains ambiguous between a
/// Windows drive path and a valid POSIX path until [`Self::to_abs_path`]
/// applies the current host's rules. A local POSIX `file:` URI can also retain
/// percent-encoded non-UTF-8 bytes for lossless native round trips.
///
/// Like [VS Code resources], path operations use `/` URI separators on every
/// host. They preserve a URL authority but do not infer Windows drive or UNC
/// roots from path text. Native path normalization, filesystem aliases,
/// symlinks, case sensitivity, and Unicode normalization are not resolved.
///
/// Serde represents a `PathUri` as its canonical URI string. Deserialization
/// also accepts an absolute native path for compatibility with fields that
/// previously used [`AbsolutePathBuf`]; relative paths are rejected. Valid
/// `file:` strings round-trip through their canonical URL form, including
/// encoded non-UTF-8 path bytes, but conversion to a native path remains
/// host-dependent as described by [RFC 8089].
///
/// [RFC 8089]: https://www.rfc-editor.org/rfc/rfc8089.html
/// [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts
#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)]
#[ts(type = "string")]
pub struct PathUri(Url);

impl PathUri {
    /// Parses and validates a `file:` URI.
    pub fn parse(uri: &str) -> Result<Self, PathUriParseError> {
        Url::parse(uri)?.try_into()
    }

    /// Converts an absolute path on the current host to a `file:` URI.
    ///
    /// Paths without a valid URI representation are replaced by
    /// `file:///%00/bad/path/<base64>`, where `<base64>` is the URL-safe, unpadded
    /// encoding of the original path (Unix bytes or Windows UTF-16LE). This
    /// includes paths containing nulls and, on Windows, unsupported prefix
    /// kinds such as device and generic verbatim namespaces, non-Unicode path
    /// or UNC components, and UNC server names that are not valid URL hosts.
    /// The encoded null reserves a URI namespace that cannot collide with a
    /// real path on Unix or Windows.
    pub fn from_abs_path(path: &AbsolutePathBuf) -> Self {
        if let Ok(url) = Url::from_file_path(path.as_path())
            && let Ok(uri) = Self::try_from(url)
        {
            return uri;
        }

        #[cfg(unix)]
        let encoded_path = {
            use std::os::unix::ffi::OsStrExt;
            base64::engine::general_purpose::URL_SAFE_NO_PAD
                .encode(path.as_path().as_os_str().as_bytes())
        };
        #[cfg(windows)]
        let encoded_path = {
            use std::os::windows::ffi::OsStrExt;
            let path_bytes = path
                .as_path()
                .as_os_str()
                .encode_wide()
                .flat_map(u16::to_le_bytes)
                .collect::<Vec<_>>();
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path_bytes)
        };
        let Ok(uri) = Self::parse(&format!("{BAD_PATH_URI_PREFIX}{encoded_path}")) else {
            unreachable!("URL-safe base64 always produces a valid fallback path URI");
        };
        uri
    }

    /// Converts a path on the current host to a `file:` URI.
    ///
    /// Relative paths are reported as invalid input. Absolute paths without a
    /// valid URI representation use the fallback documented on
    /// [`Self::from_abs_path`].
    pub fn from_path(path: impl AsRef<Path>) -> io::Result<Self> {
        let path = AbsolutePathBuf::from_absolute_path_checked(path)
            .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
        Ok(Self::from_abs_path(&path))
    }

    /// Returns the percent-encoded URI path.
    ///
    /// The URL authority is not included. For example,
    /// `file://server/share/file.rs` has the path `/share/file.rs`.
    pub fn encoded_path(&self) -> &str {
        self.0.path()
    }

    /// Returns the decoded final URI path segment, or `None` for the URI root
    /// or an opaque fallback URI created by [`Self::from_abs_path`].
    ///
    /// If the segment contains non-UTF-8 encoded bytes, its percent-encoded
    /// spelling is returned instead.
    pub fn basename(&self) -> Option<String> {
        if decode_bad_path_uri(&self.0).is_some() {
            return None;
        }

        self.0
            .path_segments()?
            .rfind(|segment| !segment.is_empty())
            .map(decode_uri_path)
    }

    /// Returns the parent URI, or `None` for the URI root or an opaque fallback
    /// URI created by [`Self::from_abs_path`].
    pub fn parent(&self) -> Option<Self> {
        if self.encoded_path() == "/" || decode_bad_path_uri(&self.0).is_some() {
            return None;
        }

        let mut url = self.0.clone();
        {
            let mut segments = match url.path_segments_mut() {
                Ok(segments) => segments,
                Err(()) => unreachable!("validated file URLs support hierarchical path segments"),
            };
            segments.pop_if_empty().pop();
        }
        Some(Self(url))
    }

    /// Lexically joins a relative URI path onto this URI.
    ///
    /// Empty and `.` segments are ignored, while `..` removes one segment
    /// without escaping the URI root. Literal `%`, `?`, and `#` characters are
    /// percent-encoded as filename text. Paths containing a null character are
    /// rejected because they cannot be safely converted to native paths.
    /// Opaque fallback URIs created by [`Self::from_abs_path`] reject non-empty
    /// joins.
    pub fn join(&self, path: &str) -> Result<Self, PathUriParseError> {
        if path.starts_with('/') {
            return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string()));
        }
        if path.contains('\0') {
            return Err(PathUriParseError::InvalidFileUriPath);
        }
        if path.is_empty() {
            return Ok(self.clone());
        }
        if decode_bad_path_uri(&self.0).is_some() {
            return Err(PathUriParseError::InvalidFileUriPath);
        }

        let mut url = self.0.clone();
        {
            let Ok(mut segments) = url.path_segments_mut() else {
                unreachable!("validated file URLs support hierarchical path segments");
            };
            segments.pop_if_empty();
            for component in path.split('/') {
                match component {
                    "" | "." => {}
                    ".." => {
                        segments.pop();
                    }
                    component => {
                        segments.push(component);
                    }
                }
            }
        }
        Self::try_from(url)
    }

    /// Converts this file URI to a path using the current host's path rules.
    ///
    /// Conversion should succeed when the URI was created from an
    /// [`AbsolutePathBuf`] on the current host, including fallback URIs created
    /// by [`Self::from_abs_path`]. It may fail when the URI came from a different
    /// operating system and its `file:` URI form cannot be represented using
    /// the current host's path rules, such as a UNC authority on POSIX or a
    /// POSIX root on Windows. Because a `file:` URI does not record its source
    /// operating system, callers should only use this method when the URI is
    /// known to identify a path on the current host.
    pub fn to_abs_path(&self) -> io::Result<AbsolutePathBuf> {
        if let Some(path_bytes) = decode_bad_path_uri(&self.0) {
            #[cfg(unix)]
            let decoded_path = {
                use std::os::unix::ffi::OsStringExt;
                Some(std::path::PathBuf::from(std::ffi::OsString::from_vec(
                    path_bytes,
                )))
            };
            #[cfg(windows)]
            let decoded_path = {
                use std::os::windows::ffi::OsStringExt;
                path_bytes.len().is_multiple_of(2).then(|| {
                    let path_wide = path_bytes
                        .chunks_exact(2)
                        .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]))
                        .collect::<Vec<_>>();
                    std::path::PathBuf::from(std::ffi::OsString::from_wide(&path_wide))
                })
            };
            if let Some(decoded_path) = decoded_path
                && let Ok(path) = AbsolutePathBuf::from_absolute_path_checked(decoded_path)
                && Self::from_abs_path(&path).eq(self)
            {
                return Ok(path);
            }

            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                PathUriParseError::InvalidFileUriPath,
            ));
        }

        let path = self.0.to_file_path().map_err(|()| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                PathUriParseError::InvalidFileUriPath,
            )
        })?;
        AbsolutePathBuf::from_absolute_path_checked(path)
            .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))
    }

    /// Returns a clone of the canonical URL.
    pub fn to_url(&self) -> Url {
        self.0.clone()
    }
}

impl TryFrom<Url> for PathUri {
    type Error = PathUriParseError;

    fn try_from(url: Url) -> Result<Self, Self::Error> {
        if url.scheme() != FILE_SCHEME {
            return Err(PathUriParseError::UnsupportedScheme(
                url.scheme().to_string(),
            ));
        }
        validate_file_url(&url)?;
        let url = without_localhost_authority(url);
        Ok(Self(url))
    }
}

impl TryFrom<String> for PathUri {
    type Error = PathUriParseError;

    fn try_from(uri: String) -> Result<Self, Self::Error> {
        Self::parse(&uri)
    }
}

impl<'de> Deserialize<'de> for PathUri {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        let unsupported_scheme = match Url::parse(&value) {
            Ok(url) => match Self::try_from(url) {
                Ok(uri) => return Ok(uri),
                // `Url` parses a Windows drive prefix such as `C:\` as the
                // scheme `c`. Give any unsupported URI one chance to satisfy
                // the native absolute-path invariant before reporting it.
                Err(error @ PathUriParseError::UnsupportedScheme(_)) => Some(error),
                Err(error) => return Err(serde::de::Error::custom(error)),
            },
            Err(url::ParseError::RelativeUrlWithoutBase) => None,
            Err(error) => {
                return Err(serde::de::Error::custom(PathUriParseError::InvalidUri(
                    error,
                )));
            }
        };

        let path = AbsolutePathBuf::from_absolute_path_checked(value).map_err(|path_error| {
            serde::de::Error::custom(
                unsupported_scheme
                    .map_or_else(|| path_error.to_string(), |error| error.to_string()),
            )
        })?;
        Ok(Self::from_abs_path(&path))
    }
}

impl FromStr for PathUri {
    type Err = PathUriParseError;

    fn from_str(uri: &str) -> Result<Self, Self::Err> {
        Self::parse(uri)
    }
}

impl fmt::Display for PathUri {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

impl Serialize for PathUri {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(self.0.as_str())
    }
}

impl JsonSchema for PathUri {
    fn schema_name() -> String {
        "PathUri".to_string()
    }

    fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
        String::json_schema(generator)
    }
}

/// Removes the local `localhost` alias while retaining non-local UNC authority.
fn without_localhost_authority(mut url: Url) -> Url {
    if url.host_str() == Some("localhost") {
        let Ok(()) = url.set_host(None) else {
            unreachable!("validated file URLs can remove a localhost authority");
        };
    }
    url
}

/// Percent-decodes a URI path when it is valid UTF-8.
///
/// `file:` URLs may contain encoded non-UTF-8 bytes. In that case the encoded
/// spelling remains available for lexical inspection while the original `Url`
/// is retained for lossless native conversion.
fn decode_uri_path(path: &str) -> String {
    urlencoding::decode(path)
        .map(std::borrow::Cow::into_owned)
        .unwrap_or_else(|_| path.to_string())
}

/// Returns the original platform path bytes from a canonical bad-path URI.
fn decode_bad_path_uri(url: &Url) -> Option<Vec<u8>> {
    let encoded_path = url.as_str().strip_prefix(BAD_PATH_URI_PREFIX)?;
    if encoded_path.is_empty() || encoded_path.contains('/') {
        return None;
    }

    let path_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(encoded_path)
        .ok()?;
    (base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&path_bytes) == encoded_path)
        .then_some(path_bytes)
}

/// Rejects URI metadata that has no defined meaning for `file:` URIs.
fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> {
    if !url.username().is_empty() || url.password().is_some() {
        return Err(PathUriParseError::CredentialsNotAllowed);
    }
    if url.port().is_some() {
        return Err(PathUriParseError::PortNotAllowed);
    }
    if url.query().is_some() {
        return Err(PathUriParseError::QueryNotAllowed);
    }
    if url.fragment().is_some() {
        return Err(PathUriParseError::FragmentNotAllowed);
    }
    Ok(())
}

/// Applies the common URI checks plus `file:` path-byte restrictions.
fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> {
    validate_common_known_uri(url)?;
    // `Url` accepts `%00`, but native path APIs use null as a terminator and
    // `Url::to_file_path` cannot represent a decoded null byte.
    if urlencoding::decode_binary(url.path().as_bytes()).contains(&0)
        && decode_bad_path_uri(url).is_none()
    {
        return Err(PathUriParseError::InvalidFileUriPath);
    }
    Ok(())
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum PathUriParseError {
    #[error("invalid URI: {0}")]
    InvalidUri(#[from] url::ParseError),
    #[error("unsupported path URI scheme `{0}`")]
    UnsupportedScheme(String),
    #[error("file URI contains an invalid absolute path")]
    InvalidFileUriPath,
    #[error("credentials are not allowed in path URIs")]
    CredentialsNotAllowed,
    #[error("ports are not allowed in path URIs")]
    PortNotAllowed,
    #[error("query parameters are not allowed in path URIs")]
    QueryNotAllowed,
    #[error("fragments are not allowed in path URIs")]
    FragmentNotAllowed,
    #[error("path `{0}` must be relative when joining a path URI")]
    JoinPathMustBeRelative(String),
}

#[cfg(test)]
#[path = "tests.rs"]
mod tests;
