use serde::Serialize;
use thiserror::Error;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkErrorKind {
    Timeout,
    ConnectionRefused,
    DnsResolution,
    Tls,
    Other,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
    ConfigNotFound,
    ConfigInvalid,
    GitLabAuthFailed,
    GitLabNotFound,
    GitLabRateLimited,
    GitLabNetworkError,
    DatabaseLocked,
    DatabaseError,
    MigrationFailed,
    TokenNotSet,
    TransformError,
    IoError,
    InternalError,
    OllamaUnavailable,
    OllamaModelNotFound,
    EmbeddingFailed,
    EmbeddingsNotBuilt,
    NotFound,
    Ambiguous,
    HealthCheckFailed,
    UsageError,
    SurgicalPreflightFailed,
}

impl std::fmt::Display for ErrorCode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let code = match self {
            Self::ConfigNotFound => "CONFIG_NOT_FOUND",
            Self::ConfigInvalid => "CONFIG_INVALID",
            Self::GitLabAuthFailed => "GITLAB_AUTH_FAILED",
            Self::GitLabNotFound => "GITLAB_NOT_FOUND",
            Self::GitLabRateLimited => "GITLAB_RATE_LIMITED",
            Self::GitLabNetworkError => "GITLAB_NETWORK_ERROR",
            Self::DatabaseLocked => "DB_LOCKED",
            Self::DatabaseError => "DB_ERROR",
            Self::MigrationFailed => "MIGRATION_FAILED",
            Self::TokenNotSet => "TOKEN_NOT_SET",
            Self::TransformError => "TRANSFORM_ERROR",
            Self::IoError => "IO_ERROR",
            Self::InternalError => "INTERNAL_ERROR",
            Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE",
            Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND",
            Self::EmbeddingFailed => "EMBEDDING_FAILED",
            Self::EmbeddingsNotBuilt => "EMBEDDINGS_NOT_BUILT",
            Self::NotFound => "NOT_FOUND",
            Self::Ambiguous => "AMBIGUOUS",
            Self::HealthCheckFailed => "HEALTH_CHECK_FAILED",
            Self::UsageError => "USAGE_ERROR",
            Self::SurgicalPreflightFailed => "SURGICAL_PREFLIGHT_FAILED",
        };
        write!(f, "{code}")
    }
}

impl ErrorCode {
    pub fn exit_code(&self) -> i32 {
        match self {
            Self::InternalError => 1,
            Self::ConfigNotFound => 20,
            Self::ConfigInvalid => 3,
            Self::TokenNotSet => 4,
            Self::GitLabAuthFailed => 5,
            Self::GitLabNotFound => 6,
            Self::GitLabRateLimited => 7,
            Self::GitLabNetworkError => 8,
            Self::DatabaseLocked => 9,
            Self::DatabaseError => 10,
            Self::MigrationFailed => 11,
            Self::IoError => 12,
            Self::TransformError => 13,
            Self::OllamaUnavailable => 14,
            Self::OllamaModelNotFound => 15,
            Self::EmbeddingFailed => 16,
            Self::EmbeddingsNotBuilt => 21,
            Self::NotFound => 17,
            Self::Ambiguous => 18,
            Self::HealthCheckFailed => 19,
            Self::UsageError => 2,
            // Shares exit code 6 with GitLabNotFound — same semantic category (resource not found).
            // Robot consumers distinguish via ErrorCode string, not exit code.
            Self::SurgicalPreflightFailed => 6,
        }
    }
}

#[derive(Error, Debug)]
pub enum LoreError {
    #[error("Config file not found at {path}. Run \"lore init\" first.")]
    ConfigNotFound { path: String },

    #[error("Invalid config: {details}")]
    ConfigInvalid { details: String },

    #[error("GitLab authentication failed. Check your token has read_api scope.")]
    GitLabAuthFailed,

    #[error("GitLab resource not found: {resource}")]
    GitLabNotFound { resource: String },

    #[error("Rate limited. Retry after {retry_after}s")]
    GitLabRateLimited { retry_after: u64 },

    #[error("Cannot connect to GitLab at {base_url}")]
    GitLabNetworkError {
        base_url: String,
        kind: NetworkErrorKind,
        detail: Option<String>,
    },

    #[error(
        "Another sync is running (owner: {owner}, started: {started_at}). Use --force to override if stale."
    )]
    DatabaseLocked { owner: String, started_at: String },

    #[error("Migration {version} failed: {message}")]
    MigrationFailed {
        version: i32,
        message: String,
        #[source]
        source: Option<rusqlite::Error>,
    },

    #[error("GitLab token not set. Run 'lore token set' or export {env_var}.")]
    TokenNotSet { env_var: String },

    #[error("Database error: {0}")]
    Database(#[from] rusqlite::Error),

    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Transform error: {0}")]
    Transform(#[from] crate::gitlab::transformers::issue::TransformError),

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Ambiguous: {0}")]
    Ambiguous(String),

    #[error("{0}")]
    Other(String),

    #[error("Cannot connect to Ollama at {base_url}. Is it running?")]
    OllamaUnavailable {
        base_url: String,
        detail: Option<String>,
    },

    #[error("Ollama model '{model}' not found. Run: ollama pull {model}")]
    OllamaModelNotFound { model: String },

    #[error("Embedding failed for document {document_id}: {reason}")]
    EmbeddingFailed { document_id: i64, reason: String },

    #[error("No embeddings found. Run: lore embed")]
    EmbeddingsNotBuilt,

    #[error("Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}")]
    SurgicalPreflightFailed {
        entity_type: String,
        iid: u64,
        project: String,
        reason: String,
    },
}

impl LoreError {
    pub fn code(&self) -> ErrorCode {
        match self {
            Self::ConfigNotFound { .. } => ErrorCode::ConfigNotFound,
            Self::ConfigInvalid { .. } => ErrorCode::ConfigInvalid,
            Self::GitLabAuthFailed => ErrorCode::GitLabAuthFailed,
            Self::GitLabNotFound { .. } => ErrorCode::GitLabNotFound,
            Self::GitLabRateLimited { .. } => ErrorCode::GitLabRateLimited,
            Self::GitLabNetworkError { .. } => ErrorCode::GitLabNetworkError,
            Self::DatabaseLocked { .. } => ErrorCode::DatabaseLocked,
            Self::MigrationFailed { .. } => ErrorCode::MigrationFailed,
            Self::TokenNotSet { .. } => ErrorCode::TokenNotSet,
            Self::Database(e) => {
                if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) {
                    ErrorCode::DatabaseLocked
                } else {
                    ErrorCode::DatabaseError
                }
            }
            Self::Json(_) => ErrorCode::InternalError,
            Self::Io(_) => ErrorCode::IoError,
            Self::Transform(_) => ErrorCode::TransformError,
            Self::NotFound(_) => ErrorCode::NotFound,
            Self::Ambiguous(_) => ErrorCode::Ambiguous,
            Self::Other(_) => ErrorCode::InternalError,
            Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable,
            Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound,
            Self::EmbeddingFailed { .. } => ErrorCode::EmbeddingFailed,
            Self::EmbeddingsNotBuilt => ErrorCode::EmbeddingsNotBuilt,
            Self::SurgicalPreflightFailed { .. } => ErrorCode::SurgicalPreflightFailed,
        }
    }

    pub fn suggestion(&self) -> Option<&'static str> {
        match self {
            Self::ConfigNotFound { .. } => Some(
                "Run 'lore init' to set up your GitLab connection.\n\n  Expected: ~/.config/lore/config.json",
            ),
            Self::ConfigInvalid { .. } => Some(
                "Check config file syntax or run 'lore init' to recreate.\n\n  Example:\n    lore init\n    lore init --force",
            ),
            Self::GitLabAuthFailed => Some(
                "Verify token has read_api scope and is not expired.\n\n  Example:\n    export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n    lore auth",
            ),
            Self::GitLabNotFound { .. } => Some(
                "Check the resource path exists and you have access.\n\n  Example:\n    lore issues -p group/project\n    lore mrs -p group/project",
            ),
            Self::GitLabRateLimited { .. } => Some("Wait and retry, or reduce request frequency"),
            Self::GitLabNetworkError { .. } => Some(
                "Check network connection and GitLab URL.\n\n  Example:\n    lore doctor\n    lore auth",
            ),
            Self::DatabaseLocked { .. } => Some(
                "Wait for other sync to complete or use --force.\n\n  Example:\n    lore ingest --force\n    lore ingest issues --force",
            ),
            Self::MigrationFailed { .. } => Some(
                "Check database file permissions and try again.\n\n  Example:\n    lore migrate\n    lore doctor",
            ),
            Self::TokenNotSet { .. } => Some(
                "Set your token:\n\n  lore token set\n\n  Or export to your shell:\n\n  export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n  Your token needs the read_api scope.",
            ),
            Self::Database(e) => {
                if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) {
                    Some(
                        "Another process has the database locked. Wait a moment and retry.\n\n  Common causes:\n    - A cron sync is running (lore cron status)\n    - Another lore command is active",
                    )
                } else {
                    Some("Check database file permissions.\n\n  Example:\n    lore doctor")
                }
            }
            Self::NotFound(_) => {
                Some("Verify the entity exists.\n\n  Example:\n    lore issues\n    lore mrs")
            }
            Self::Ambiguous(_) => Some(
                "Use -p to choose a specific project.\n\n  Example:\n    lore issues 42 -p group/project-a\n    lore mrs 99 -p group/project-b",
            ),
            Self::OllamaUnavailable { .. } => Some("Start Ollama: ollama serve"),
            Self::OllamaModelNotFound { .. } => {
                Some("Pull the model: ollama pull nomic-embed-text")
            }
            Self::EmbeddingFailed { .. } => {
                Some("Check Ollama logs or retry with 'lore embed --retry-failed'")
            }
            Self::EmbeddingsNotBuilt => Some("Generate embeddings first: lore embed"),
            Self::SurgicalPreflightFailed { .. } => Some(
                "Verify the IID exists in the project and you have access.\n\n  Example:\n    lore issues -p <project>\n    lore mrs -p <project>",
            ),
            Self::Json(_) | Self::Io(_) | Self::Transform(_) | Self::Other(_) => None,
        }
    }

    pub fn is_permanent_api_error(&self) -> bool {
        matches!(self, Self::GitLabNotFound { .. } | Self::GitLabAuthFailed)
    }

    pub fn exit_code(&self) -> i32 {
        self.code().exit_code()
    }

    pub fn actions(&self) -> Vec<&'static str> {
        match self {
            Self::ConfigNotFound { .. } => vec!["lore init"],
            Self::ConfigInvalid { .. } => vec!["lore init --force"],
            Self::GitLabAuthFailed => {
                vec!["export GITLAB_TOKEN=glpat-xxx", "lore auth"]
            }
            Self::TokenNotSet { .. } => vec!["lore token set", "export GITLAB_TOKEN=glpat-xxx"],
            Self::OllamaUnavailable { .. } => vec!["ollama serve"],
            Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"],
            Self::DatabaseLocked { .. } => vec!["lore ingest --force"],
            Self::Database(e)
                if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) =>
            {
                vec!["lore cron status"]
            }
            Self::EmbeddingsNotBuilt => vec!["lore embed"],
            Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"],
            Self::MigrationFailed { .. } => vec!["lore migrate"],
            Self::GitLabNetworkError { .. } => vec!["lore doctor"],
            Self::SurgicalPreflightFailed { .. } => {
                vec!["lore issues -p <project>", "lore mrs -p <project>"]
            }
            _ => vec![],
        }
    }

    pub fn to_robot_error(&self) -> RobotError {
        let actions = self.actions().into_iter().map(String::from).collect();
        RobotError {
            code: self.code().to_string(),
            message: self.to_string(),
            suggestion: self.suggestion().map(String::from),
            actions,
        }
    }
}

#[derive(Debug, Serialize)]
pub struct RobotError {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggestion: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub actions: Vec<String>,
}

#[derive(Debug, Serialize)]
pub struct RobotErrorOutput {
    pub error: RobotError,
}

impl From<&LoreError> for RobotErrorOutput {
    fn from(e: &LoreError) -> Self {
        Self {
            error: e.to_robot_error(),
        }
    }
}

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

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

    #[test]
    fn surgical_preflight_failed_display() {
        let err = LoreError::SurgicalPreflightFailed {
            entity_type: "issue".to_string(),
            iid: 42,
            project: "group/repo".to_string(),
            reason: "not found on GitLab".to_string(),
        };
        let msg = err.to_string();
        assert!(msg.contains("issue"), "missing entity_type: {msg}");
        assert!(msg.contains("42"), "missing iid: {msg}");
        assert!(msg.contains("group/repo"), "missing project: {msg}");
        assert!(msg.contains("not found on GitLab"), "missing reason: {msg}");
    }

    #[test]
    fn surgical_preflight_failed_error_code() {
        let code = ErrorCode::SurgicalPreflightFailed;
        assert_eq!(code.exit_code(), 6);
    }

    #[test]
    fn surgical_preflight_failed_code_mapping() {
        let err = LoreError::SurgicalPreflightFailed {
            entity_type: "merge_request".to_string(),
            iid: 99,
            project: "ns/proj".to_string(),
            reason: "404".to_string(),
        };
        assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);
    }
}
