//! The per-sandbox view onto the shared org filesystem.
//!
//! The org's volumes are mounted ONCE per organization under
//! `<app_root>/orgs/<org_slug>/`, not once per sandbox: they are org-wide, and
//! every sandbox is the same user on the same machine, so mounting per sandbox
//! would run N copies of rclone serving identical content. Each sandbox then
//! gets a cheap symlink view:
//!
//! ```text
//! <app_root>/orgs/<slug>/{home,public,uploads,outputs}   ← the real mounts
//! <app_root>/worktrees/<handle>/org/home    -> ../../../orgs/<slug>/home
//! <app_root>/worktrees/<handle>/org/public  -> ../../../orgs/<slug>/public
//! <app_root>/worktrees/<handle>/org/uploads -> ../../../orgs/<slug>/uploads
//! <app_root>/worktrees/<handle>/org/outputs -> ../../../orgs/<slug>/outputs
//! ```
//!
//! ## Why `org` is a SIBLING of `repo`, never inside it
//!
//! The agent's cwd is `<sandbox>/repo` (the git worktree). Putting the org
//! tree inside it would place a network mount under the directory that
//! `bun install`, the dev server, and every git command walk — so a stalled
//! mount could wedge the whole setup pipeline. As a sibling it cannot: the
//! agent reaches it by absolute path (supplied in the harness prompt), and
//! nothing enters the worktree, so unlike the old `decocms link` design there
//! is no `.git/info/exclude` entry to maintain either.
//!
//! ## Why the targets are created even when nothing is mounted
//!
//! A symlink to a missing directory is worse than an empty one: `ls` fails
//! instead of returning nothing, and the agent reports an error rather than
//! "no files". Creating the target directories keeps a mountless sandbox
//! readable-but-empty, which is the agreed degradation, and costs nothing
//! when the mount later lands on top of the same path.

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

/// The volume directories a sandbox sees under `org/`.
///
/// Named for the volumes themselves rather than the old daemon's per-run
/// `upload`/`output` symlinks: narrowing to one thread now happens in the
/// harness prompt (which names `uploads/<threadId>/` directly), so there is no
/// per-dispatch relinking and no race between two runs sharing a sandbox.
pub const ORG_VOLUMES: [&str; 4] = ["home", "public", "uploads", "outputs"];

/// Volumes the desktop must never write to: the org's curated `public`
/// volume and the `public-<set>` volumes generated by the org's public-set
/// sync (`file-storage/public-sets.ts`) — a write there would be silently
/// clobbered by the next sync.
///
/// ONE predicate for both enforcement layers on purpose: the WebDAV surface
/// rejects the write verb (never trusting rclone's `--read-only` flag), and
/// the mount manager additionally mounts read-only. The two layers once
/// carried separate copies that drifted — the mount side matched the bare
/// prefix `public`, which also captures a writable volume named
/// `publications`. Exactly `public` or a `public-` set, nothing looser.
pub(crate) fn is_read_only_volume(volume: &str) -> bool {
    volume == "public" || volume.starts_with("public-")
}

/// `<app_root>/orgs/<org_slug>` — where an organization's volumes mount.
///
/// Returns `None` for a slug that could escape the store or collapse two orgs
/// onto one directory, mirroring `repo_store::canonical_repo_dir`'s refusal to
/// guess.
pub fn org_mount_root(app_root: &Path, org_slug: &str) -> Option<PathBuf> {
    Some(app_root.join("orgs").join(safe_segment(org_slug)?))
}

/// `<app_root>/worktrees/<handle>/org` — the sandbox's view directory.
pub fn sandbox_org_dir(app_root: &Path, handle: &str) -> Option<PathBuf> {
    Some(crate::sandbox::worktree_root(app_root, safe_handle(handle)?).join("org"))
}

/// Create (or repair) the sandbox's `org/` view for `org_slug`.
///
/// Idempotent: re-running replaces links that point somewhere else and leaves
/// correct ones alone, so a sandbox that changes org — or one whose view was
/// partially created before a crash — converges rather than failing.
///
/// Best-effort by design. A sandbox whose org view cannot be built still runs;
/// the agent simply has no org filesystem, which is the same outcome as a
/// failed mount. Returns whether the full view is in place, so the caller can
/// report it.
pub async fn ensure_org_view(app_root: &Path, handle: &str, org_slug: &str) -> bool {
    let (Some(mount_root), Some(view)) = (
        org_mount_root(app_root, org_slug),
        sandbox_org_dir(app_root, handle),
    ) else {
        tracing::warn!(
            handle,
            org_slug,
            "refusing to build an unsafe org view path"
        );
        return false;
    };

    if let Err(error) = tokio::fs::create_dir_all(&view).await {
        tracing::warn!(%error, ?view, "failed to create the sandbox org view dir");
        return false;
    }

    let mut complete = true;
    for volume in ORG_VOLUMES {
        let target = mount_root.join(volume);
        // Create the mount point BEFORE linking so the link resolves to an
        // empty directory rather than dangling (see this module's doc).
        if let Err(error) = tokio::fs::create_dir_all(&target).await {
            tracing::warn!(%error, ?target, "failed to create the org mount point");
            complete = false;
            continue;
        }
        if !link_volume(app_root, &view.join(volume), &target).await {
            complete = false;
        }
    }
    complete
}

/// Point `link` at `target`, replacing whatever is there if it disagrees.
///
/// The link is RELATIVE so the whole tree survives being moved (an app-support
/// directory can be migrated between machines or restored from a backup).
async fn link_volume(app_root: &Path, link: &Path, target: &Path) -> bool {
    let Some(relative) = relative_link(app_root, link, target) else {
        tracing::warn!(?link, ?target, "org view link is not under the app root");
        return false;
    };
    // `read_link` (not `metadata`) so a link pointing at a not-yet-mounted
    // path still compares equal instead of looking broken.
    if tokio::fs::read_link(link).await.ok().as_deref() == Some(relative.as_path()) {
        return true;
    }
    // Anything else occupying the path — a stale link, or a real directory
    // left by an older layout — is replaced rather than worked around.
    if tokio::fs::symlink_metadata(link).await.is_ok() {
        let removed = match tokio::fs::remove_file(link).await {
            Ok(()) => true,
            Err(_) => tokio::fs::remove_dir_all(link).await.is_ok(),
        };
        if !removed {
            tracing::warn!(?link, "could not clear the existing org view entry");
            return false;
        }
    }
    match tokio::fs::symlink(&relative, link).await {
        Ok(()) => true,
        Err(error) => {
            tracing::warn!(%error, ?link, ?relative, "failed to link an org volume");
            false
        }
    }
}

/// The `../`-relative path from `link`'s parent to `target`.
///
/// Both are built from the same `app_root`, so the common prefix IS the app
/// root: climb out of the link's parent back to it, then descend to the
/// target. Expressed that way rather than by counting components, because the
/// component counts of two absolute paths say nothing about their relationship
/// — an earlier version subtracted depths and produced a link one level short.
fn relative_link(app_root: &Path, link: &Path, target: &Path) -> Option<PathBuf> {
    let from = link.parent()?.strip_prefix(app_root).ok()?;
    let to = target.strip_prefix(app_root).ok()?;
    let mut out = PathBuf::new();
    for _ in from.components() {
        out.push("..");
    }
    out.push(to);
    Some(out)
}

/// One path segment that cannot escape its parent.
fn safe_segment(segment: &str) -> Option<&str> {
    if segment.is_empty()
        || segment == "."
        || segment == ".."
        || segment.contains('/')
        || segment.contains('\\')
    {
        return None;
    }
    Some(segment)
}

/// A handle is `<host>/<owner>/<repo>/<branch>` — several path segments, so it
/// cannot go through [`safe_segment`], which exists for single-segment org
/// slugs and rejects the `/` outright. Each segment is still validated on its
/// own, so `..` can never climb out of the worktrees root.
fn safe_handle(handle: &str) -> Option<&str> {
    crate::sandbox::handle_is_path_safe(handle).then_some(handle)
}

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

    /// Pins the shared read-only vocabulary for BOTH enforcement layers
    /// (WebDAV write refusal and the `--read-only` mount flag) — including
    /// the `publications` case the drifted mount-side prefix match got wrong.
    #[test]
    fn only_public_and_public_sets_are_read_only() {
        assert!(is_read_only_volume("public"));
        assert!(is_read_only_volume("public-skills"));
        assert!(is_read_only_volume("public-core"));
        for writable in ["home", "uploads", "outputs", "publications"] {
            assert!(
                !is_read_only_volume(writable),
                "{writable} must stay writable"
            );
        }
    }

    #[test]
    fn refuses_slugs_and_handles_that_would_escape() {
        let root = Path::new("/app");
        for bad in ["", ".", "..", "a/b", "a\\b"] {
            assert_eq!(org_mount_root(root, bad), None, "slug {bad:?}");
        }
        // A handle is multi-segment, so `/` is legal in one where it is not
        // legal in an org slug — but a segment that would climb out is not.
        for bad in ["", ".", "..", "a\\b", "a/../b", "a//b"] {
            assert_eq!(sandbox_org_dir(root, bad), None, "handle {bad:?}");
        }
        assert_eq!(
            sandbox_org_dir(root, "github.com/acme/repo/feat"),
            Some(PathBuf::from(
                "/app/worktrees/github.com/acme/repo/feat/org"
            ))
        );
        assert_eq!(
            org_mount_root(root, "acme"),
            Some(PathBuf::from("/app/orgs/acme"))
        );
    }

    #[tokio::test]
    async fn view_links_every_volume_to_the_shared_org_mount() {
        let root = tempfile::tempdir().expect("tempdir");
        let app_root = root.path();

        assert!(ensure_org_view(app_root, "h1", "acme").await);

        for volume in ORG_VOLUMES {
            let link = app_root.join("worktrees/h1/org").join(volume);
            let expected = app_root.join("orgs/acme").join(volume);
            // Resolves to the shared mount point, and the mount point exists
            // so the agent reads an empty dir rather than hitting ENOENT.
            assert_eq!(
                std::fs::canonicalize(&link).unwrap(),
                std::fs::canonicalize(&expected).unwrap(),
                "{volume} should resolve to the org mount"
            );
            assert!(
                std::fs::read_dir(&link).is_ok(),
                "{volume} should be listable"
            );
        }
    }

    /// The whole reason the link is relative: an app-support directory that
    /// gets moved or restored from a backup must not break every sandbox.
    /// Also pins the exact shape — an earlier version was one `..` short.
    #[tokio::test]
    async fn links_are_relative_so_the_tree_survives_a_move() {
        let root = tempfile::tempdir().expect("tempdir");
        assert!(ensure_org_view(root.path(), "h1", "acme").await);

        let target = std::fs::read_link(root.path().join("worktrees/h1/org/home")).unwrap();
        assert_eq!(target, PathBuf::from("../../../orgs/acme/home"));
    }

    #[tokio::test]
    async fn ensure_is_idempotent_and_repairs_a_wrong_link() {
        let root = tempfile::tempdir().expect("tempdir");
        let app_root = root.path();
        assert!(ensure_org_view(app_root, "h1", "acme").await);
        assert!(
            ensure_org_view(app_root, "h1", "acme").await,
            "re-run is a no-op"
        );

        // Repoint one volume at the wrong org, as a stale view would be.
        let link = app_root.join("worktrees/h1/org/home");
        std::fs::remove_file(&link).unwrap();
        std::os::unix::fs::symlink("../../../orgs/other/home", &link).unwrap();

        assert!(ensure_org_view(app_root, "h1", "acme").await);
        assert_eq!(
            std::fs::canonicalize(&link).unwrap(),
            std::fs::canonicalize(app_root.join("orgs/acme/home")).unwrap(),
            "a link pointing at another org must be repaired"
        );
    }
}
