use std::fmt::Write as _;

#[cfg(feature = "docker")]
use bollard::errors::Error as BollardError;
use fabro_util::error::{collect_causes, render_with_causes};

use crate::ExecResult;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("{0}")]
    Message(String),

    #[error("{message}")]
    Context {
        message: String,
        #[source]
        source:  Box<dyn std::error::Error + Send + Sync + 'static>,
    },

    #[error("{message}")]
    AnyhowContext {
        message: String,
        #[source]
        source:  anyhow::Error,
    },

    #[cfg(feature = "docker")]
    #[error("Failed to connect to Docker daemon")]
    DockerConnect {
        #[source]
        source: BollardError,
    },

    #[cfg(feature = "docker")]
    #[error("Failed to inspect Docker image {image}")]
    DockerImageInspect {
        image:  String,
        #[source]
        source: BollardError,
    },

    #[cfg(feature = "docker")]
    #[error("Failed to pull Docker image {image}")]
    DockerImagePull {
        image:  String,
        #[source]
        source: BollardError,
    },

    #[error(
        "{label} failed (exit {exit}, termination={termination}, duration_ms={duration_ms}) - hint: {hint}",
        exit = format_exit_code(result.exit_code),
        termination = result.termination,
        duration_ms = result.duration_ms,
        hint = classify_exec_failure(&result.stderr)
            .or_else(|| classify_exec_failure(&result.stdout))
            .unwrap_or("unclassified")
    )]
    Exec { label: String, result: ExecResult },
}

impl Error {
    pub fn message(message: impl Into<String>) -> Self {
        Self::Message(message.into())
    }

    pub fn context(
        message: impl Into<String>,
        source: impl std::error::Error + Send + Sync + 'static,
    ) -> Self {
        Self::Context {
            message: message.into(),
            source:  Box::new(source),
        }
    }

    pub fn context_anyhow(message: impl Into<String>, source: anyhow::Error) -> Self {
        Self::AnyhowContext {
            message: message.into(),
            source,
        }
    }

    pub fn exec(label: impl Into<String>, result: ExecResult) -> Self {
        Self::Exec {
            label: label.into(),
            result,
        }
    }

    pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {
        default_redacted_output_tail(self)
    }

    #[cfg(feature = "docker")]
    pub fn docker_connect(source: BollardError) -> Self {
        Self::DockerConnect { source }
    }

    #[cfg(feature = "docker")]
    pub fn docker_image_inspect(image: impl Into<String>, source: BollardError) -> Self {
        Self::DockerImageInspect {
            image: image.into(),
            source,
        }
    }

    #[cfg(feature = "docker")]
    pub fn docker_image_pull(image: impl Into<String>, source: BollardError) -> Self {
        Self::DockerImagePull {
            image: image.into(),
            source,
        }
    }

    pub fn causes(&self) -> Vec<String> {
        collect_causes(self)
    }

    pub fn display_with_causes(&self) -> String {
        render_with_causes(&self.to_string(), &self.causes())
    }
}

impl From<String> for Error {
    fn from(value: String) -> Self {
        Self::Message(value)
    }
}

impl From<&str> for Error {
    fn from(value: &str) -> Self {
        Self::Message(value.to_string())
    }
}

pub(crate) fn classify_exec_failure(stderr: &str) -> Option<&'static str> {
    let lower = stderr.to_ascii_lowercase();
    if lower.contains("could not read username") || lower.contains("terminal prompts disabled") {
        Some(
            "no credentials in origin URL - check that the sandbox forwarded \
             GITHUB_APP_PRIVATE_KEY (or GITHUB_TOKEN) and that refresh_push_credentials succeeded",
        )
    } else if lower.contains("permission to") && lower.contains("denied") {
        Some(
            "github denied the push - installation token lacks contents:write \
             on this repo, or a branch protection / push ruleset is rejecting the ref",
        )
    } else if lower.contains("protected branch")
        || lower.contains("ruleset")
        || lower.contains("rejected")
    {
        Some("github rejected the ref - likely a branch protection rule or push ruleset")
    } else if lower.contains("authentication failed") || lower.contains("invalid username") {
        Some("github authentication failed - installation token may be expired or wrong scope")
    } else if lower.contains("could not resolve host") || lower.contains("network is unreachable") {
        Some("network failure inside sandbox - check DNS / egress from the run container")
    } else if lower.contains("repository not found") {
        Some("github 404 - repository is unavailable to the current credentials")
    } else if lower.contains("no such remote") && lower.contains("origin") {
        Some("origin remote missing - push credentials could not be installed")
    } else if lower.contains("not a git repository")
        || lower.contains("does not appear to be a git repository")
    {
        Some("git repository unavailable in sandbox working directory")
    } else {
        None
    }
}

fn format_exit_code(exit_code: Option<i32>) -> String {
    exit_code.map_or_else(|| "none".to_string(), |code| code.to_string())
}

pub type Result<T> = std::result::Result<T, Error>;

pub fn default_redacted_output_tail(
    err: &(dyn std::error::Error + 'static),
) -> Option<fabro_types::ExecOutputTail> {
    let mut current = Some(err);
    while let Some(err) = current {
        if let Some(Error::Exec { result, .. }) = err.downcast_ref::<Error>() {
            return result.default_redacted_output_tail();
        }
        current = err.source();
    }
    None
}

pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String {
    let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err));
    if let Some(tail) = default_redacted_output_tail(err) {
        append_tail_for_log(
            &mut rendered,
            "stderr",
            tail.stderr.as_deref(),
            tail.stderr_truncated,
        );
        append_tail_for_log(
            &mut rendered,
            "stdout",
            tail.stdout.as_deref(),
            tail.stdout_truncated,
        );
    }
    rendered
}

fn append_tail_for_log(rendered: &mut String, stream: &str, tail: Option<&str>, truncated: bool) {
    let tail = tail.unwrap_or("");
    let _ = write!(
        rendered,
        "\n--- {stream} (truncated={truncated}, bytes={}) ---\n{tail}",
        tail.len()
    );
}

#[cfg(test)]
mod tests {
    use fabro_types::CommandTermination;

    use super::*;

    #[test]
    fn exec_display_is_log_safe() {
        let stderr = "fatal: unable to access \
                      'https://x-access-token:ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA@github.com/owner/repo/':\n\
                      remote: Permission to owner/repo.git denied\n\
                      identity ~/.ssh/id_rsa_work";
        let error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
            stdout:      String::new(),
            stderr:      stderr.to_string(),
            exit_code:   Some(128),
            termination: CommandTermination::Exited,
            duration_ms: 210,
        });
        let rendered = error.to_string();

        assert_exec_rendering_is_safe(&rendered);
        assert!(rendered.contains("git push origin refs/heads/run"));
        assert!(rendered.contains("exit 128"));
        assert!(rendered.contains("termination=exited"));
        assert!(rendered.contains("duration_ms=210"));
        assert!(rendered.contains("hint:"));
    }

    #[test]
    fn display_with_causes_does_not_reintroduce_raw_exec_output() {
        let stderr = "fatal: unable to access \
                      'https://x-access-token:ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA@github.com/owner/repo/':\n\
                      remote: Permission to owner/repo.git denied\n\
                      identity ~/.ssh/id_rsa_work";
        let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
            stdout:      "stdout secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(),
            stderr:      stderr.to_string(),
            exit_code:   Some(128),
            termination: CommandTermination::Exited,
            duration_ms: 210,
        });
        let error = Error::context("metadata push failed", exec_error);
        let rendered = error.display_with_causes();

        assert_exec_rendering_is_safe(&rendered);
        assert!(rendered.contains("metadata push failed"));
        assert!(rendered.contains("git push origin refs/heads/run"));
        assert!(rendered.contains("hint:"));
    }

    #[test]
    fn display_for_log_walks_context_chain_and_emits_tail() {
        let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
            stdout:      "last stdout line".to_string(),
            stderr:      "last stderr line".to_string(),
            exit_code:   Some(128),
            termination: CommandTermination::Exited,
            duration_ms: 210,
        });
        let error = Error::context("metadata push failed", exec_error);

        let rendered = display_for_log(&error);

        assert!(rendered.contains("metadata push failed"));
        assert!(rendered.contains("git push origin refs/heads/run"));
        assert!(rendered.contains("--- stderr (truncated=false, bytes=16) ---"));
        assert!(rendered.contains("last stderr line"));
        assert!(rendered.contains("--- stdout (truncated=false, bytes=16) ---"));
        assert!(rendered.contains("last stdout line"));
    }

    #[test]
    fn display_for_log_redacts_secrets() {
        let error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
            stdout:      "stdout secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(),
            stderr:      "stderr secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA".to_string(),
            exit_code:   Some(128),
            termination: CommandTermination::Exited,
            duration_ms: 210,
        });

        let rendered = display_for_log(&error);

        assert!(
            !rendered.contains("ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"),
            "log rendering leaked raw secret: {rendered}"
        );
        assert!(rendered.contains("REDACTED"));
    }

    #[test]
    fn display_for_log_for_non_exec_error_returns_chain_only() {
        let error = Error::context("outer failure", std::io::Error::other("leaf failure"));

        let rendered = display_for_log(&error);

        assert_eq!(rendered, "outer failure\n  caused by: leaf failure");
        assert!(!rendered.contains("--- stderr"));
        assert!(!rendered.contains("--- stdout"));
    }

    fn assert_exec_rendering_is_safe(rendered: &str) {
        for forbidden in [
            "fatal:",
            "remote:",
            "x-access-token",
            "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA",
            "~/.ssh",
            "id_rsa_work",
        ] {
            assert!(
                !rendered.contains(forbidden),
                "Display leaked {forbidden:?}: {rendered}"
            );
        }
    }

    #[test]
    fn exec_error_exposes_default_redacted_output_tail() {
        let stderr = "stderr secret ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
        let error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
            stdout:      "last stdout line".to_string(),
            stderr:      stderr.to_string(),
            exit_code:   Some(128),
            termination: CommandTermination::Exited,
            duration_ms: 210,
        });

        let tail = error.default_redacted_output_tail().expect("tail present");
        assert_eq!(tail.stdout.as_deref(), Some("last stdout line"));
        assert!(
            tail.stderr
                .as_deref()
                .expect("stderr tail")
                .contains("REDACTED")
        );
    }

    #[test]
    fn free_tail_helper_walks_context_chain() {
        let exec_error = Error::exec("git push origin refs/heads/run", crate::ExecResult {
            stdout:      "last stdout line".to_string(),
            stderr:      "last stderr line".to_string(),
            exit_code:   Some(128),
            termination: CommandTermination::Exited,
            duration_ms: 210,
        });
        let error = Error::context("metadata push failed", exec_error);

        let tail = default_redacted_output_tail(&error).expect("tail present");

        assert_eq!(tail.stdout.as_deref(), Some("last stdout line"));
        assert_eq!(tail.stderr.as_deref(), Some("last stderr line"));
    }

    #[test]
    fn classify_exec_failure_documents_known_branches() {
        let cases = [
            (
                "fatal: could not read Username for 'https://github.com'",
                "no credentials in origin URL",
            ),
            (
                "remote: Permission to owner/repo.git denied to fabro-app[bot].",
                "github denied the push",
            ),
            (
                "remote: error: GH013: Repository rule violations found due to ruleset",
                "github rejected the ref",
            ),
            (
                "fatal: Authentication failed for 'https://github.com/owner/repo'",
                "github authentication failed",
            ),
            (
                "fatal: could not resolve host: github.com",
                "network failure",
            ),
            ("remote: Repository not found.", "github 404"),
            ("error: No such remote 'origin'", "origin remote missing"),
            ("fatal: not a git repository", "git repository unavailable"),
        ];

        for (stderr, expected) in cases {
            let hint = classify_exec_failure(stderr).expect(stderr);
            assert!(
                hint.contains(expected),
                "expected {hint:?} to contain {expected:?}"
            );
        }
        assert_eq!(classify_exec_failure("weird new git error"), None);
    }
}
