//! Native extraction and race-safe containment of write-tool targets.

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

use serde_json::Value;

use crate::DispatchServiceError;

const WRITE_TOOLS: &[&str] = &["Write", "Edit", "apply_patch"];

pub(crate) fn derive_write_paths(
    workspace_root: &Path,
    tool_name: Option<&str>,
    tool_input: Option<&Value>,
    exact_root: bool,
) -> Result<Vec<String>, DispatchServiceError> {
    let Some(tool_name) = tool_name else {
        if tool_input.is_some() {
            return Err(invalid("tool_input requires tool_name"));
        }
        return Ok(Vec::new());
    };
    if tool_name == "Bash" {
        if exact_root {
            return Ok(Vec::new());
        }
        return Err(invalid(
            "opaque Bash effects cannot receive native write-path authority without shell-text inference",
        ));
    }
    if !WRITE_TOOLS.contains(&tool_name) {
        if tool_input.and_then(Value::as_object).is_some_and(|input| {
            ["file_path", "path", "command"]
                .iter()
                .any(|key| input.contains_key(*key))
        }) {
            return Err(invalid(format!(
                "cannot classify write targets for unknown tool `{tool_name}`"
            )));
        }
        return Ok(Vec::new());
    }
    let input = tool_input.ok_or_else(|| invalid("write tool input is required"))?;
    let freeform_patch = if tool_name == "apply_patch" {
        input.as_str()
    } else {
        None
    };
    let input = match input.as_object() {
        Some(input) => {
            if tool_name == "apply_patch" {
                const APPLY_PATCH_FIELDS: &[&str] =
                    &["file_path", "path", "patch", "input", "operation"];
                if let Some(field) = input
                    .keys()
                    .find(|field| !APPLY_PATCH_FIELDS.contains(&field.as_str()))
                {
                    return Err(invalid(format!(
                        "apply_patch input contains unknown field `{field}`"
                    )));
                }
                if input.contains_key("patch") && input.contains_key("input") {
                    return Err(invalid(
                        "apply_patch input must not contain both `patch` and `input`",
                    ));
                }
            }
            Some(input)
        }
        None if freeform_patch.is_some() => None,
        None => return Err(invalid("write tool input must be an object")),
    };
    let mut raw = Vec::new();
    if let Some(input) = input {
        for key in ["file_path", "path"] {
            if let Some(value) = input.get(key) {
                raw.push(
                    value
                        .as_str()
                        .filter(|value| !value.is_empty())
                        .ok_or_else(|| invalid(format!("tool_input.{key} must be a string")))?,
                );
            }
        }
    }
    let object_patch = input
        .and_then(|input| input.get("patch").or_else(|| input.get("input")))
        .map(|value| {
            value
                .as_str()
                .ok_or_else(|| invalid("apply_patch input must be a string"))
        })
        .transpose()?;
    if let Some(patch) = freeform_patch.or(object_patch) {
        raw.extend(extract_patch_paths(patch)?);
    }
    raw.sort_unstable();
    raw.dedup();
    if raw.is_empty() {
        return Err(invalid(format!(
            "cannot derive native write path from `{tool_name}` input"
        )));
    }

    let mut paths = Vec::with_capacity(raw.len());
    for candidate in raw {
        let relative = normalize_relative(workspace_root, candidate)?;
        verify_nofollow(workspace_root, &relative)?;
        paths.push(relative);
    }
    paths.sort();
    paths.dedup();
    Ok(paths)
}

fn extract_patch_paths(patch: &str) -> Result<Vec<&str>, DispatchServiceError> {
    if patch.len() > 1_048_576 || patch.contains('\0') {
        return Err(invalid("apply_patch input is unsafe or too large"));
    }
    let mut paths = Vec::new();
    for line in patch.lines() {
        for prefix in [
            "*** Add File: ",
            "*** Update File: ",
            "*** Delete File: ",
            "*** Move to: ",
        ] {
            if let Some(path) = line.strip_prefix(prefix) {
                if path.is_empty() || path.trim() != path {
                    return Err(invalid("apply_patch contains an invalid path header"));
                }
                paths.push(path);
            }
        }
    }
    if paths.is_empty() {
        return Err(invalid("apply_patch contains no canonical file headers"));
    }
    Ok(paths)
}

fn normalize_relative(
    workspace_root: &Path,
    candidate: &str,
) -> Result<String, DispatchServiceError> {
    // A backslash is a LITERAL filename character on unix, so smuggling one
    // into a write path is a real attempt to confuse a downstream consumer and
    // is refused. On Windows it is THE separator, so refusing it rejected every
    // absolute path the platform produces. Normalizing first keeps one rule.
    let normalized;
    let candidate = if cfg!(windows) {
        normalized = candidate.replace('\\', "/");
        normalized.as_str()
    } else {
        candidate
    };
    if candidate.len() > 4_096
        || (!cfg!(windows) && candidate.contains('\\'))
        || candidate.contains('\0')
        || candidate.chars().any(char::is_control)
    {
        return Err(invalid("write path is unsafe"));
    }
    let candidate = Path::new(candidate);
    let resolved;
    let relative = if candidate.is_absolute() {
        // Compare by identity, not by spelling. One side of this comparison
        // arrives canonicalized by `ExecutionContext` and the other arrives as
        // the caller typed it, and on Windows those are routinely different
        // spellings of the same directory -- verbatim vs plain, long name vs
        // 8.3 short name -- so a containment check on the raw strings refused
        // paths that were plainly inside the repository.
        resolved = crate::interface::canonical_identity(candidate);
        let root = crate::interface::canonical_identity(workspace_root);
        resolved
            .strip_prefix(&root)
            .map_err(|_| invalid("absolute write path escapes the bound workspace"))?
    } else {
        candidate
    };
    let mut parts = Vec::new();
    for component in relative.components() {
        match component {
            Component::Normal(part) => {
                let value = part
                    .to_str()
                    .ok_or_else(|| invalid("write path must be UTF-8"))?;
                if value.is_empty() || value == "." || value == ".." {
                    return Err(invalid("write path has an unsafe component"));
                }
                parts.push(value);
            }
            _ => {
                return Err(invalid(
                    "write path must be normalized and repository-relative",
                ));
            }
        }
    }
    if parts.is_empty() {
        return Err(invalid("write path cannot name the repository root"));
    }
    Ok(parts.join("/"))
}

#[cfg(unix)]
fn verify_nofollow(workspace_root: &Path, relative: &str) -> Result<(), DispatchServiceError> {
    use rustix::fs::{FileType, Mode, OFlags, open, openat};

    let mut directory = open(
        workspace_root,
        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
        Mode::empty(),
    )
    .map_err(|error| {
        invalid(format!(
            "cannot open bound workspace without following links: {error}"
        ))
    })?;
    let parts: Vec<&str> = relative.split('/').collect();
    for (index, part) in parts.iter().enumerate() {
        let final_component = index + 1 == parts.len();
        let flags = if final_component {
            OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW
        } else {
            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW
        };
        match openat(&directory, *part, flags, Mode::empty()) {
            Ok(next) if final_component => {
                let stat = rustix::fs::fstat(&next)
                    .map_err(|error| invalid(format!("cannot inspect write target: {error}")))?;
                if !FileType::from_raw_mode(stat.st_mode).is_file() {
                    return Err(invalid("existing write target is not a regular file"));
                }
            }
            Ok(next) => directory = next,
            Err(error) if final_component && error == rustix::io::Errno::NOENT => return Ok(()),
            Err(error) => {
                return Err(invalid(format!(
                    "write target is not safely contained without following links: {error}"
                )));
            }
        }
    }
    Ok(())
}

/// The non-unix twin. Same three verdicts as the unix walk: an absent final
/// component is allowed (the write is about to create it), an existing final
/// component must be a regular file, and a link anywhere in the chain is
/// refused.
#[cfg(not(unix))]
fn verify_nofollow(workspace_root: &Path, relative: &str) -> Result<(), DispatchServiceError> {
    if crate::safe_fs::is_link(workspace_root)
        .map_err(|error| invalid(format!("cannot inspect bound workspace: {error}")))?
    {
        return Err(invalid(
            "cannot open bound workspace without following links: it is a symlink",
        ));
    }
    let mut walked = workspace_root.to_path_buf();
    let parts: Vec<&str> = relative.split('/').collect();
    for (index, part) in parts.iter().enumerate() {
        walked.push(part);
        let final_component = index + 1 == parts.len();
        let metadata = match std::fs::symlink_metadata(&walked) {
            Ok(metadata) => metadata,
            Err(error) if final_component && error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(());
            }
            Err(error) => {
                return Err(invalid(format!(
                    "write target is not safely contained without following links at {}: {error}",
                    walked.display()
                )));
            }
        };
        if metadata.file_type().is_symlink() {
            return Err(invalid(
                "write target is not safely contained without following links: it traverses a symlink",
            ));
        }
        if final_component {
            if !metadata.is_file() {
                return Err(invalid("existing write target is not a regular file"));
            }
        } else if !metadata.is_dir() {
            return Err(invalid(
                "write target is not safely contained without following links: an intermediate component is not a directory",
            ));
        }
    }
    Ok(())
}

fn invalid(reason: impl Into<String>) -> DispatchServiceError {
    DispatchServiceError::InvalidRequest(reason.into())
}

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

    #[test]
    fn apply_patch_preserves_object_and_freeform_payloads_but_rejects_unknown_fields() {
        let root = std::env::temp_dir();
        let path = format!("shepherd-apply-patch-{}.md", std::process::id());
        let patch = format!("*** Begin Patch\n*** Update File: {path}\n*** End Patch");

        assert_eq!(
            derive_write_paths(
                &root,
                Some("apply_patch"),
                Some(&serde_json::json!({"patch": patch})),
                false,
            )
            .expect("object payload"),
            vec![path.clone()]
        );
        assert_eq!(
            derive_write_paths(
                &root,
                Some("apply_patch"),
                Some(&serde_json::Value::String(patch.clone())),
                false,
            )
            .expect("freeform payload"),
            vec![path]
        );
        assert!(
            derive_write_paths(
                &root,
                Some("apply_patch"),
                Some(&serde_json::json!({"patch": patch, "untrusted": true})),
                false,
            )
            .is_err(),
            "unknown apply_patch fields must fail closed"
        );
    }

    #[test]
    fn opaque_bash_and_unclassifiable_write_tools_fail_closed() {
        let root = std::env::temp_dir().join("shepherd-dispatch-scope-red");
        assert!(
            derive_write_paths(
                &root,
                Some("Bash"),
                Some(&serde_json::json!({
                    "command": "printf unsafe"
                })),
                false,
            )
            .is_err()
        );
        assert!(
            derive_write_paths(
                &root,
                Some("UnknownWrite"),
                Some(&serde_json::json!({
                    "path": "docs/report.md"
                })),
                false,
            )
            .is_err()
        );
    }

    #[test]
    fn exact_root_bash_has_no_derived_paths_and_unknown_tools_still_fail_closed() {
        let root = std::env::temp_dir().join("shepherd-dispatch-scope-root");
        assert_eq!(
            derive_write_paths(
                &root,
                Some("Bash"),
                Some(&serde_json::json!({
                    "command": "printf text > docs/report.md && git status --short --branch"
                })),
                true,
            )
            .expect("exact root Bash is authorized by native identity, not inferred shell paths"),
            Vec::<String>::new(),
        );
        assert!(
            derive_write_paths(
                &root,
                Some("UnknownWrite"),
                Some(&serde_json::json!({"command": "true"})),
                true,
            )
            .is_err(),
            "exact root authority must not classify unknown tools"
        );
    }
}