use crate::manifest::RoleManifest;
use crate::paths::JackinPaths;
use crate::repo_contract::{ValidatedDockerfile, validate_agent_dockerfile};
use crate::selector::RoleSelector;
use std::path::{Component, Path, PathBuf};

#[derive(Debug, Clone)]
pub struct CachedRepo {
    pub key: String,
    pub repo_dir: PathBuf,
}

impl CachedRepo {
    fn role_cache_root(paths: &JackinPaths, selector: &RoleSelector) -> PathBuf {
        selector.namespace.as_ref().map_or_else(
            || paths.roles_dir.join(&selector.name),
            |namespace| paths.roles_dir.join(namespace).join(&selector.name),
        )
    }

    pub fn new(paths: &JackinPaths, selector: &RoleSelector) -> Self {
        Self {
            key: selector.key(),
            repo_dir: Self::role_cache_root(paths, selector).join("default"),
        }
    }

    /// Cache directory isolated to a specific branch, leaving the default-branch
    /// cache entry untouched. The branch name is used directly as a path
    /// component so `feat/my-pr` lives at `…/branches/feat/my-pr` — a branch
    /// named `feat-my-pr` (with a dash) would live at `…/branches/feat-my-pr`,
    /// which is a different path, eliminating any ambiguity.
    pub fn for_branch(paths: &JackinPaths, selector: &RoleSelector, branch: &str) -> Self {
        Self {
            key: format!("{}@{branch}", selector.key()),
            // Path::join handles forward slashes as directory separators, so
            // "feat/my-pr" naturally becomes …/branches/feat/my-pr on disk.
            repo_dir: Self::role_cache_root(paths, selector)
                .join("branches")
                .join(branch),
        }
    }
}

#[derive(Debug, Clone)]
pub struct ValidatedRoleRepo {
    pub manifest: RoleManifest,
    pub dockerfile: ValidatedDockerfile,
}

/// Specific structural rejections from `validate_role_repo`.
///
/// Variants carry the rejected path / label so the editor's friendly
/// translator can render rich messages without parsing free-form
/// strings; downstream `RepoError::InvalidRoleRepo` is matched against
/// the inner variant rather than substring-stripped.
///
/// Add a variant per new rejection rule; reach for `Other(anyhow::Error)`
/// only when the failure is a non-structural pass-through (manifest TOML
/// parse, IO error from the underlying filesystem, etc.).
#[derive(Debug, thiserror::Error)]
pub enum RoleRepoValidationError {
    #[error("missing {}", _0.display())]
    Missing(PathBuf),
    #[error("{label} path must be relative")]
    PathMustBeRelative { label: &'static str },
    #[error("{label} path must stay inside the repo")]
    PathOutsideRepo { label: &'static str },
    #[error("{label} path must not be a symlink")]
    PathIsSymlink { label: &'static str },
    #[error("{label} path escapes the repo boundary")]
    PathEscapesBoundary { label: &'static str },
    #[error("{label} is empty: {}", path.display())]
    EmptyHook { label: &'static str, path: PathBuf },
    #[error("unable to parse Dockerfile: {0}")]
    DockerfileParse(String),
    #[error("Dockerfile must contain at least one FROM instruction")]
    DockerfileMissingFrom,
    #[error("final Dockerfile stage must use literal FROM {expected}")]
    DockerfileNonConstruct { expected: String },
    #[error(
        "Dockerfile FROM {r}:{t} uses the floating tag — pin to a versioned release \
         like FROM {r}:<version>-{t}.\n\
         Configure Renovate with a regex versioning rule to track version bumps automatically.",
        r = crate::repo_contract::CONSTRUCT_REGISTRY_IMAGE,
        t = crate::repo_contract::CONSTRUCT_STABLE_TAG,
    )]
    DockerfileMissingVersionPin,
    /// Catch-all for non-structural failures (TOML parse, IO, manifest
    /// semantic validation). The friendly translator renders these as
    /// the generic "not a valid Jackin role" message.
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl From<std::io::Error> for RoleRepoValidationError {
    fn from(err: std::io::Error) -> Self {
        Self::Other(err.into())
    }
}

pub fn validate_role_repo(repo_dir: &Path) -> Result<ValidatedRoleRepo, RoleRepoValidationError> {
    let manifest_path = repo_dir.join("jackin.role.toml");

    if !manifest_path.is_file() {
        return Err(RoleRepoValidationError::Missing(manifest_path));
    }

    let manifest = RoleManifest::load(repo_dir)?;
    let dockerfile_path = validate_relative_path(repo_dir, &manifest.dockerfile, "dockerfile")?;
    let dockerfile = validate_agent_dockerfile(&dockerfile_path)?;

    if let Some(ref hooks) = manifest.hooks {
        for entry in hooks.entries() {
            let hook_path = validate_relative_path(repo_dir, entry.path, entry.label)?;
            // metadata().len() avoids slurping a potentially large hook
            // script into memory just to check emptiness.
            if std::fs::metadata(&hook_path)?.len() == 0 {
                return Err(RoleRepoValidationError::EmptyHook {
                    label: entry.label,
                    path: hook_path,
                });
            }
        }
    }

    let warnings = manifest.validate()?;
    for warning in &warnings {
        eprintln!("warning: {}", warning.message);
    }

    Ok(ValidatedRoleRepo {
        manifest,
        dockerfile,
    })
}

fn validate_relative_path(
    repo_dir: &Path,
    path_str: &str,
    label: &'static str,
) -> Result<PathBuf, RoleRepoValidationError> {
    let path = Path::new(path_str);

    if path.is_absolute() {
        return Err(RoleRepoValidationError::PathMustBeRelative { label });
    }

    for component in path.components() {
        if matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        ) {
            return Err(RoleRepoValidationError::PathOutsideRepo { label });
        }
    }

    let resolved = repo_dir.join(path);
    if !resolved.is_file() {
        return Err(RoleRepoValidationError::Missing(resolved));
    }
    if std::fs::symlink_metadata(&resolved)?
        .file_type()
        .is_symlink()
    {
        return Err(RoleRepoValidationError::PathIsSymlink { label });
    }

    let canonical_repo = repo_dir.canonicalize()?;
    let canonical_resolved = resolved.canonicalize()?;
    if !canonical_resolved.starts_with(&canonical_repo) {
        return Err(RoleRepoValidationError::PathEscapesBoundary { label });
    }

    Ok(canonical_resolved)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::paths::JackinPaths;
    use crate::selector::RoleSelector;
    use tempfile::tempdir;

    #[test]
    fn computes_cached_repo_path_for_namespaced_selector() {
        let temp = tempdir().unwrap();
        let paths = JackinPaths::for_tests(temp.path());
        let selector = RoleSelector::new(Some("chainargos"), "the-architect");

        let repo = CachedRepo::new(&paths, &selector);

        assert_eq!(
            repo.repo_dir,
            paths
                .roles_dir
                .join("chainargos")
                .join("the-architect")
                .join("default")
        );
    }

    #[test]
    fn computes_branch_cache_path_as_sibling_of_default() {
        let temp = tempdir().unwrap();
        let paths = JackinPaths::for_tests(temp.path());
        let selector = RoleSelector::new(None, "the-architect");

        let repo = CachedRepo::for_branch(&paths, &selector, "feat/caveman-all-install");

        assert_eq!(
            repo.repo_dir,
            paths
                .roles_dir
                .join("the-architect")
                .join("branches")
                .join("feat")
                .join("caveman-all-install")
        );
    }

    #[test]
    fn rejects_repo_without_required_files() {
        let temp = tempdir().unwrap();
        std::fs::create_dir_all(temp.path()).unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("jackin.role.toml"));
    }

    #[test]
    fn rejects_missing_manifest_dockerfile() {
        let temp = tempdir().unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "docker/role.Dockerfile"

[claude]
plugins = []
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("docker/role.Dockerfile"));
    }

    #[test]
    fn rejects_manifest_dockerfile_outside_repo() {
        let temp = tempdir().unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "../Dockerfile"

[claude]
plugins = []
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("must stay inside the repo"));
    }

    #[cfg(unix)]
    #[test]
    fn rejects_symlink_escaping_repo_boundary() {
        let temp = tempdir().unwrap();
        let outside = tempdir().unwrap();
        std::fs::write(outside.path().join("Dockerfile"), "FROM debian:trixie\n").unwrap();
        std::os::unix::fs::symlink(outside.path(), temp.path().join("escape")).unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "escape/Dockerfile"

[claude]
plugins = []
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("escapes the repo boundary"));
    }

    #[test]
    fn accepts_manifest_dockerfile_in_subdirectory() {
        let temp = tempdir().unwrap();
        std::fs::create_dir_all(temp.path().join("docker")).unwrap();
        std::fs::write(
            temp.path().join("docker/role.Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "docker/role.Dockerfile"

[claude]
plugins = []
"#,
        )
        .unwrap();

        let validated = validate_role_repo(temp.path()).unwrap();

        assert_eq!(
            validated.dockerfile.dockerfile_path,
            temp.path()
                .canonicalize()
                .unwrap()
                .join("docker/role.Dockerfile")
        );
    }

    #[test]
    fn accepts_manifest_with_valid_preflight_hook() {
        let temp = tempdir().unwrap();
        std::fs::create_dir_all(temp.path().join("hooks")).unwrap();
        std::fs::write(
            temp.path().join("hooks/preflight.sh"),
            r"#!/bin/bash
echo hello
",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
preflight = "hooks/preflight.sh"
"#,
        )
        .unwrap();

        let validated = validate_role_repo(temp.path()).unwrap();

        assert!(
            validated
                .manifest
                .hooks
                .as_ref()
                .unwrap()
                .preflight
                .is_some()
        );
    }

    #[test]
    fn accepts_manifest_with_runtime_hooks() {
        let temp = tempdir().unwrap();
        std::fs::create_dir_all(temp.path().join("hooks")).unwrap();
        for name in ["setup-once.sh", "source.sh", "preflight.sh"] {
            std::fs::write(
                temp.path().join("hooks").join(name),
                "#!/bin/bash\necho ok\n",
            )
            .unwrap();
        }
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
setup_once = "hooks/setup-once.sh"
source = "hooks/source.sh"
preflight = "hooks/preflight.sh"
"#,
        )
        .unwrap();

        let validated = validate_role_repo(temp.path()).unwrap();
        let hooks = validated.manifest.hooks.as_ref().unwrap();

        assert!(hooks.setup_once.is_some());
        assert!(hooks.source.is_some());
        assert!(hooks.preflight.is_some());
    }

    #[test]
    fn rejects_preflight_hook_outside_repo() {
        let temp = tempdir().unwrap();
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
preflight = "../escape.sh"
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("must stay inside the repo"));
    }

    #[test]
    fn rejects_preflight_hook_that_does_not_exist() {
        let temp = tempdir().unwrap();
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
preflight = "hooks/missing.sh"
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("missing"));
    }

    #[test]
    fn rejects_absolute_preflight_hook_path() {
        let temp = tempdir().unwrap();
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
preflight = "/etc/evil.sh"
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("must be relative"));
    }

    #[test]
    fn rejects_empty_preflight_hook() {
        let error = empty_hook_error("preflight", "hooks/preflight.sh");
        assert!(error.contains("preflight hook is empty"));
    }

    #[test]
    fn rejects_empty_setup_once_hook() {
        let error = empty_hook_error("setup_once", "hooks/setup-once.sh");
        assert!(error.contains("setup_once hook is empty"));
    }

    #[test]
    fn rejects_empty_source_hook() {
        let error = empty_hook_error("source", "hooks/source.sh");
        assert!(error.contains("source hook is empty"));
    }

    fn empty_hook_error(field: &str, path: &str) -> String {
        let temp = tempdir().unwrap();
        std::fs::create_dir_all(temp.path().join("hooks")).unwrap();
        std::fs::write(temp.path().join(path), "").unwrap();
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            format!(
                r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
{field} = "{path}"
"#
            ),
        )
        .unwrap();

        validate_role_repo(temp.path()).unwrap_err().to_string()
    }

    #[cfg(unix)]
    #[test]
    fn rejects_symlinked_preflight_hook_inside_repo() {
        let temp = tempdir().unwrap();
        std::fs::create_dir_all(temp.path().join("hooks")).unwrap();
        std::fs::write(temp.path().join("real-hook.sh"), "#!/bin/bash\necho hi\n").unwrap();
        std::os::unix::fs::symlink(
            temp.path().join("real-hook.sh"),
            temp.path().join("hooks/preflight.sh"),
        )
        .unwrap();
        std::fs::write(
            temp.path().join("Dockerfile"),
            "FROM projectjackin/construct:0.1-trixie\n",
        )
        .unwrap();
        std::fs::write(
            temp.path().join("jackin.role.toml"),
            r#"version = "v1alpha3"
dockerfile = "Dockerfile"

[claude]
plugins = []

[hooks]
preflight = "hooks/preflight.sh"
"#,
        )
        .unwrap();

        let error = validate_role_repo(temp.path()).unwrap_err();

        assert!(error.to_string().contains("symlink"));
        assert!(error.to_string().contains("preflight"));
    }
}
