//! Path addressing for path-taking verbs (`status`, `locks`, `checkout`,
//! `add`, `delete`, `revert`, `submit`, `pull`, ...) — a straight port of
//! the command reference's "Addressing: two ways to name a file" rules into
//! code. Pure: no filesystem I/O (an argument may legitimately name a file
//! that doesn't exist locally — a `//depot/path` addresses server state, and
//! a filesystem path may point at a file already deleted from disk but still
//! pending in the changelist), so this never `canonicalize`s or `stat`s
//! anything. `cwd` is always a caller-supplied parameter, never
//! `std::env::current_dir()` read internally — that keeps this function
//! testable without touching the process's real working directory (the CLI
//! is the one place that reads the real cwd, once, and passes it in).
//!
//! Three forms, disambiguated by the leading marker:
//!   - `//x/y` — depot path. Stripped of its `//` and returned as-is: `x/y`.
//!   - `./x`, `../x`, or an absolute path — filesystem path, resolved
//!     lexically against `cwd`, then required to fall under `workspace`
//!     (checked lexically, no symlink resolution) and re-expressed relative
//!     to it. Outside the workspace is an error naming the workspace root.
//!   - a bare `x/y` (no marker) — filesystem path relative to `cwd`, same
//!     resolution as the `./x` form.
//!
//! A folder argument resolves to its own depot-relative path exactly like a
//! file argument does — `resolve_targets` has no way to know from the string
//! alone whether it names a file or a folder (that's the caller's job, once
//! it has the depot tree /workspace scan in hand). The "matches everything
//! under it" folder semantics is therefore the *caller's* filtering
//! responsibility: treat each returned string as a path-or-prefix and match
//! `path == target || path.starts_with(&format!("{target}/"))`.

use std::path::{Component, Path, PathBuf};

/// "//x/y" -> depot "x/y"; "./x", "../x", "/abs" -> filesystem, resolved
/// against cwd then made relative to the workspace root (error if outside
/// it); bare "x/y" -> filesystem relative to cwd (per the command reference
/// Addressing). Returns depot-relative paths/prefixes, one per input arg, in
/// the same order. A folder arg means every depot path under it — see the
/// module doc for why that's left to the caller.
pub fn resolve_targets(
    args: &[String],
    cwd: &Path,
    workspace: &Path,
) -> Result<Vec<String>, String> {
    args.iter().map(|arg| resolve_one(arg, cwd, workspace)).collect()
}

fn resolve_one(arg: &str, cwd: &Path, workspace: &Path) -> Result<String, String> {
    if let Some(rest) = arg.strip_prefix("//") {
        let depot_path = rest.trim_matches('/');
        if depot_path.is_empty() {
            return Err("'//' does not name a file or folder".to_string());
        }
        return normalize_depot_path(depot_path, arg);
    }

    let base = if Path::new(arg).is_absolute() { PathBuf::from(arg) } else { cwd.join(arg) };
    let normalized = normalize_lexically(&base);
    let ws_normalized = normalize_lexically(workspace);

    match normalized.strip_prefix(&ws_normalized) {
        Ok(rel) if rel.as_os_str().is_empty() => Ok(String::new()),
        Ok(rel) => Ok(components_to_depot_path(rel)),
        Err(_) => Err(format!(
            "'{arg}' resolves to {} which is outside the workspace root ({})",
            normalized.display(),
            ws_normalized.display(),
        )),
    }
}

/// Collapses `.`/`..` components without touching the filesystem (no
/// `canonicalize`, which would require the path to exist and would follow
/// symlinks — neither is wanted here). A `..` past the root is a no-op
/// (`PathBuf::pop` on an empty/root path leaves it unchanged), matching
/// ordinary shell lexical `..` handling at a filesystem root.
fn normalize_lexically(path: &Path) -> PathBuf {
    let mut out = PathBuf::new();
    for comp in path.components() {
        match comp {
            Component::ParentDir => {
                out.pop();
            }
            Component::CurDir => {}
            other => out.push(other.as_os_str()),
        }
    }
    out
}

/// Lexically collapses `.`/`..` segments in a depot-marker path (`//x/../y`),
/// the depot-form twin of `normalize_lexically` above. Depot paths are never
/// filesystem paths at this layer (no drive letters, no symlinks to worry
/// about), but they DO get mapped onto the workspace root by mutating verbs
/// (checkout/add/delete/revert, via `workspace::resolve_workspace_file`), so
/// a `..` that walks past the depot root must be refused here rather than
/// silently handed to a caller as a literal `"../etc/passwd"` string — that
/// string, naively joined onto the workspace root elsewhere, is exactly the
/// escape this closes. Mirrors shell/`normalize_lexically` semantics for the
/// in-bounds case: `a/../b` -> `b`.
fn normalize_depot_path(depot_path: &str, original_arg: &str) -> Result<String, String> {
    let mut stack: Vec<&str> = Vec::new();
    for seg in depot_path.split('/') {
        match seg {
            "" | "." => {}
            ".." => {
                if stack.pop().is_none() {
                    return Err(format!(
                        "'{original_arg}' resolves outside the depot root"
                    ));
                }
            }
            other => stack.push(other),
        }
    }
    if stack.is_empty() {
        return Err(format!("'{original_arg}' does not name a file or folder"));
    }
    Ok(stack.join("/"))
}

/// Depot paths always use `/`, regardless of platform — join the relative
/// path's components with it rather than relying on `Path::display()`
/// (which would print `\` on Windows).
fn components_to_depot_path(rel: &Path) -> String {
    rel.components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join("/")
}

/// `path == target || path` is inside the folder `target` names. Shared by
/// `status`/`locks` (and, later, every other path-taking verb) to apply a
/// resolved target list against a full path set: a folder target matches
/// everything under it, a file target matches only itself.
pub fn matches_target(path: &str, target: &str) -> bool {
    if target.is_empty() {
        return true; // "" = the workspace root: matches everything.
    }
    path == target || path.starts_with(&format!("{target}/"))
}

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

    #[test]
    fn depot_marker_strips_leading_slashes() {
        let cwd = PathBuf::from("/irrelevant");
        let ws = PathBuf::from("/irrelevant");
        let got = resolve_targets(&["//a/b".to_string()], &cwd, &ws).unwrap();
        assert_eq!(got, vec!["a/b".to_string()]);
    }

    #[test]
    fn dot_slash_resolves_against_cwd_inside_workspace() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let got = resolve_targets(&["./rel".to_string()], &cwd, ws.path()).unwrap();
        assert_eq!(got, vec!["rel".to_string()]);
    }

    #[test]
    fn dot_dot_slash_resolves_relative_to_a_workspace_subdir_cwd() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().join("sub");
        std::fs::create_dir_all(&cwd).unwrap();
        let got = resolve_targets(&["../other.txt".to_string()], &cwd, ws.path()).unwrap();
        assert_eq!(got, vec!["other.txt".to_string()]);
    }

    #[test]
    fn absolute_path_inside_workspace_is_made_relative() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let abs = ws.path().join("Assets/rig.blend");
        let got = resolve_targets(&[abs.to_str().unwrap().to_string()], &cwd, ws.path()).unwrap();
        assert_eq!(got, vec!["Assets/rig.blend".to_string()]);
    }

    #[test]
    fn absolute_path_outside_workspace_is_an_error_naming_the_root() {
        let ws = tempfile::tempdir().unwrap();
        let elsewhere = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let outside = elsewhere.path().join("secret.txt");
        let err =
            resolve_targets(&[outside.to_str().unwrap().to_string()], &cwd, ws.path()).unwrap_err();
        assert!(
            err.contains(&ws.path().display().to_string()),
            "expected the workspace root in the error, got: {err}"
        );
    }

    #[test]
    fn bare_path_is_filesystem_relative_to_cwd() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let got = resolve_targets(&["Assets/thing.txt".to_string()], &cwd, ws.path()).unwrap();
        assert_eq!(got, vec!["Assets/thing.txt".to_string()]);
    }

    #[test]
    fn folder_argument_resolves_like_any_other_path() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let got = resolve_targets(&["Assets".to_string()], &cwd, ws.path()).unwrap();
        assert_eq!(got, vec!["Assets".to_string()]);
        // Folder-prefix semantics live in matches_target, not here.
        assert!(matches_target("Assets/rig.blend", &got[0]));
        assert!(matches_target("Assets", &got[0]));
        assert!(!matches_target("AssetsOther/rig.blend", &got[0]));
    }

    #[test]
    fn resolver_never_reads_the_process_cwd() {
        // Same arg + different (explicit) cwds must resolve differently —
        // proof this is a pure function of its parameters, not
        // std::env::current_dir().
        let ws = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(ws.path().join("sub")).unwrap();
        let a = resolve_one("rel.txt", ws.path(), ws.path()).unwrap();
        let b = resolve_one("rel.txt", &ws.path().join("sub"), ws.path()).unwrap();
        assert_eq!(a, "rel.txt");
        assert_eq!(b, "sub/rel.txt");
    }

    #[test]
    fn bare_dot_resolves_to_the_workspace_root() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let got = resolve_targets(&[".".to_string()], &cwd, ws.path()).unwrap();
        assert_eq!(got, vec![String::new()]);
    }

    #[test]
    fn depot_marker_collapses_dot_dot_segments_lexically() {
        let cwd = PathBuf::from("/irrelevant");
        let ws = PathBuf::from("/irrelevant");
        let got = resolve_targets(&["//Assets/../Other/x".to_string()], &cwd, &ws).unwrap();
        assert_eq!(got, vec!["Other/x".to_string()]);
    }

    #[test]
    fn depot_marker_dot_dot_past_the_root_is_an_error_not_an_escape() {
        // A mutating verb (checkout/add/delete/revert) maps a depot path onto
        // the workspace via workspace::resolve_workspace_file; this is the
        // paths.rs-side half of that defense (see the module's mutating-verb
        // callers) — a `//../x` must never silently resolve to something
        // that, once mapped onto the workspace root, would fall outside it.
        let cwd = PathBuf::from("/irrelevant");
        let ws = PathBuf::from("/irrelevant");
        let err = resolve_targets(&["//../etc/passwd".to_string()], &cwd, &ws).unwrap_err();
        assert!(
            err.contains("//../etc/passwd"),
            "expected the offending arg in the error, got: {err}"
        );

        // Also past-root after a partial descent: one real segment, two ups.
        let err2 = resolve_targets(&["//a/../../x".to_string()], &cwd, &ws).unwrap_err();
        assert!(err2.contains("//a/../../x"), "got: {err2}");
    }

    #[test]
    fn multiple_args_resolve_independently_in_order() {
        let ws = tempfile::tempdir().unwrap();
        let cwd = ws.path().to_path_buf();
        let got = resolve_targets(
            &["//Assets/rig.blend".to_string(), "extra/path".to_string()],
            &cwd,
            ws.path(),
        )
        .unwrap();
        assert_eq!(got, vec!["Assets/rig.blend".to_string(), "extra/path".to_string()]);
    }
}
