use forage_core::auth::{
    AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
    UserProfile,
};
use forage_core::platform::{
    Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, CreatePolicyInput,
    CreateReleasePipelineInput, CreateTriggerInput, Destination, DestinationType, Environment,
    ForestPlatform, NotificationPreference, Organisation, OrgMember, PipelineStage,
    PipelineStageConfig, PlatformError, Policy, PolicyConfig, ReleasePipeline, Trigger,
    UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput,
};
use forage_grpc::policy_service_client::PolicyServiceClient;
use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient;
use forage_grpc::trigger_service_client::TriggerServiceClient;
use forage_grpc::destination_service_client::DestinationServiceClient;
use forage_grpc::environment_service_client::EnvironmentServiceClient;
use forage_grpc::organisation_service_client::OrganisationServiceClient;
use forage_grpc::release_service_client::ReleaseServiceClient;
use forage_grpc::users_service_client::UsersServiceClient;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::Request;

fn bearer_request<T>(access_token: &str, msg: T) -> Result<Request<T>, String> {
    let mut req = Request::new(msg);
    let bearer: MetadataValue<_> = format!("Bearer {access_token}")
        .parse()
        .map_err(|_| "invalid token format".to_string())?;
    req.metadata_mut().insert("authorization", bearer);
    Ok(req)
}

/// Real gRPC client to forest-server's UsersService.
#[derive(Clone)]
pub struct GrpcForestClient {
    channel: Channel,
}

impl GrpcForestClient {
    /// Create a client that connects lazily (for when server may not be available at startup).
    pub fn connect_lazy(endpoint: &str) -> anyhow::Result<Self> {
        let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy();
        Ok(Self { channel })
    }

    fn client(&self) -> UsersServiceClient<Channel> {
        UsersServiceClient::new(self.channel.clone())
    }

    fn org_client(&self) -> OrganisationServiceClient<Channel> {
        OrganisationServiceClient::new(self.channel.clone())
    }

    pub(crate) fn artifact_client(
        &self,
    ) -> forage_grpc::artifact_service_client::ArtifactServiceClient<Channel> {
        forage_grpc::artifact_service_client::ArtifactServiceClient::new(self.channel.clone())
    }

    pub(crate) fn release_client(&self) -> ReleaseServiceClient<Channel> {
        ReleaseServiceClient::new(self.channel.clone())
    }

    fn env_client(&self) -> EnvironmentServiceClient<Channel> {
        EnvironmentServiceClient::new(self.channel.clone())
    }

    fn dest_client(&self) -> DestinationServiceClient<Channel> {
        DestinationServiceClient::new(self.channel.clone())
    }

    fn trigger_client(&self) -> TriggerServiceClient<Channel> {
        TriggerServiceClient::new(self.channel.clone())
    }

    fn policy_client(&self) -> PolicyServiceClient<Channel> {
        PolicyServiceClient::new(self.channel.clone())
    }

    fn pipeline_client(&self) -> ReleasePipelineServiceClient<Channel> {
        ReleasePipelineServiceClient::new(self.channel.clone())
    }

    pub fn event_client(
        &self,
    ) -> forage_grpc::event_service_client::EventServiceClient<Channel> {
        forage_grpc::event_service_client::EventServiceClient::new(self.channel.clone())
    }

    pub(crate) fn notification_client(
        &self,
    ) -> forage_grpc::notification_service_client::NotificationServiceClient<Channel> {
        forage_grpc::notification_service_client::NotificationServiceClient::new(
            self.channel.clone(),
        )
    }

    fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
        bearer_request(access_token, msg).map_err(AuthError::Other)
    }

    /// Fetch release intent states using a service token (for background workers).
    pub async fn get_release_intent_states_with_token(
        &self,
        service_token: &str,
        organisation: &str,
        project: Option<&str>,
        include_completed: bool,
    ) -> Result<Vec<forage_core::platform::ReleaseIntentState>, String> {
        let req = bearer_request(
            service_token,
            forage_grpc::GetReleaseIntentStatesRequest {
                organisation: organisation.into(),
                project: project.map(|p| p.into()),
                include_completed,
            },
        )
        .map_err(|e| format!("invalid token: {e}"))?;

        let resp = self
            .release_client()
            .get_release_intent_states(req)
            .await
            .map_err(|e| format!("gRPC: {e}"))?;

        Ok(resp
            .into_inner()
            .release_intents
            .into_iter()
            .map(|ri| forage_core::platform::ReleaseIntentState {
                release_intent_id: ri.release_intent_id,
                artifact_id: ri.artifact_id,
                project: ri.project,
                created_at: ri.created_at,
                stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(),
                steps: ri.steps.into_iter().map(convert_release_step_state).collect(),
            })
            .collect())
    }
}

fn map_status(status: tonic::Status) -> AuthError {
    match status.code() {
        tonic::Code::Unauthenticated => AuthError::InvalidCredentials,
        tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()),
        tonic::Code::PermissionDenied => AuthError::NotAuthenticated,
        tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()),
        _ => AuthError::Other(status.message().into()),
    }
}

fn convert_user(u: forage_grpc::User) -> User {
    User {
        user_id: u.user_id,
        username: u.username,
        emails: u
            .emails
            .into_iter()
            .map(|e| UserEmail {
                email: e.email,
                verified: e.verified,
            })
            .collect(),
    }
}

fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken {
    PersonalAccessToken {
        token_id: t.token_id,
        name: t.name,
        scopes: t.scopes,
        created_at: t.created_at.map(|ts| ts.to_string()),
        last_used: t.last_used.map(|ts| ts.to_string()),
        expires_at: t.expires_at.map(|ts| ts.to_string()),
    }
}

#[async_trait::async_trait]
impl ForestAuth for GrpcForestClient {
    async fn register(
        &self,
        username: &str,
        email: &str,
        password: &str,
    ) -> Result<AuthTokens, AuthError> {
        let resp = self
            .client()
            .register(forage_grpc::RegisterRequest {
                username: username.into(),
                email: email.into(),
                password: password.into(),
            })
            .await
            .map_err(map_status)?
            .into_inner();

        let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
        Ok(AuthTokens {
            access_token: tokens.access_token,
            refresh_token: tokens.refresh_token,
            expires_in_seconds: tokens.expires_in_seconds,
        })
    }

    async fn login(&self, identifier: &str, password: &str) -> Result<AuthTokens, AuthError> {
        let login_identifier = if identifier.contains('@') {
            forage_grpc::login_request::Identifier::Email(identifier.into())
        } else {
            forage_grpc::login_request::Identifier::Username(identifier.into())
        };

        let resp = self
            .client()
            .login(forage_grpc::LoginRequest {
                identifier: Some(login_identifier),
                password: password.into(),
            })
            .await
            .map_err(map_status)?
            .into_inner();

        let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
        Ok(AuthTokens {
            access_token: tokens.access_token,
            refresh_token: tokens.refresh_token,
            expires_in_seconds: tokens.expires_in_seconds,
        })
    }

    async fn refresh_token(&self, refresh_token: &str) -> Result<AuthTokens, AuthError> {
        let resp = self
            .client()
            .refresh_token(forage_grpc::RefreshTokenRequest {
                refresh_token: refresh_token.into(),
            })
            .await
            .map_err(map_status)?
            .into_inner();

        let tokens = resp
            .tokens
            .ok_or(AuthError::Other("no tokens in response".into()))?;
        Ok(AuthTokens {
            access_token: tokens.access_token,
            refresh_token: tokens.refresh_token,
            expires_in_seconds: tokens.expires_in_seconds,
        })
    }

    async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> {
        self.client()
            .logout(forage_grpc::LogoutRequest {
                refresh_token: refresh_token.into(),
            })
            .await
            .map_err(map_status)?;
        Ok(())
    }

    async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::TokenInfoRequest {},
        )?;

        let info = self
            .client()
            .token_info(req)
            .await
            .map_err(map_status)?
            .into_inner();

        let req = Self::authed_request(
            access_token,
            forage_grpc::GetUserRequest {
                identifier: Some(forage_grpc::get_user_request::Identifier::UserId(
                    info.user_id,
                )),
            },
        )?;

        let resp = self
            .client()
            .get_user(req)
            .await
            .map_err(map_status)?
            .into_inner();

        let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
        Ok(convert_user(user))
    }

    async fn get_user_by_username(
        &self,
        access_token: &str,
        username: &str,
    ) -> Result<UserProfile, AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::GetUserRequest {
                identifier: Some(forage_grpc::get_user_request::Identifier::Username(
                    username.into(),
                )),
            },
        )?;

        let resp = self
            .client()
            .get_user(req)
            .await
            .map_err(map_status)?
            .into_inner();

        let user = resp
            .user
            .ok_or(AuthError::Other("no user in response".into()))?;
        Ok(UserProfile {
            user_id: user.user_id,
            username: user.username,
            created_at: user.created_at.map(|ts| {
                chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32)
                    .map(|dt| dt.to_rfc3339())
                    .unwrap_or_default()
            }),
        })
    }

    async fn list_tokens(
        &self,
        access_token: &str,
        user_id: &str,
    ) -> Result<Vec<PersonalAccessToken>, AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::ListPersonalAccessTokensRequest {
                user_id: user_id.into(),
            },
        )?;

        let resp = self
            .client()
            .list_personal_access_tokens(req)
            .await
            .map_err(map_status)?
            .into_inner();

        Ok(resp.tokens.into_iter().map(convert_token).collect())
    }

    async fn create_token(
        &self,
        access_token: &str,
        user_id: &str,
        name: &str,
    ) -> Result<CreatedToken, AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::CreatePersonalAccessTokenRequest {
                user_id: user_id.into(),
                name: name.into(),
                scopes: vec![],
                expires_in_seconds: 0,
            },
        )?;

        let resp = self
            .client()
            .create_personal_access_token(req)
            .await
            .map_err(map_status)?
            .into_inner();

        let token = resp
            .token
            .ok_or(AuthError::Other("no token in response".into()))?;
        Ok(CreatedToken {
            token: convert_token(token),
            raw_token: resp.raw_token,
        })
    }

    async fn delete_token(
        &self,
        access_token: &str,
        token_id: &str,
    ) -> Result<(), AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::DeletePersonalAccessTokenRequest {
                token_id: token_id.into(),
            },
        )?;

        self.client()
            .delete_personal_access_token(req)
            .await
            .map_err(map_status)?;
        Ok(())
    }

    async fn update_username(
        &self,
        access_token: &str,
        user_id: &str,
        new_username: &str,
    ) -> Result<User, AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::UpdateUserRequest {
                user_id: user_id.into(),
                username: Some(new_username.into()),
            },
        )?;

        let resp = self
            .client()
            .update_user(req)
            .await
            .map_err(map_status)?
            .into_inner();

        let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
        Ok(convert_user(user))
    }

    async fn change_password(
        &self,
        access_token: &str,
        user_id: &str,
        current_password: &str,
        new_password: &str,
    ) -> Result<(), AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::ChangePasswordRequest {
                user_id: user_id.into(),
                current_password: current_password.into(),
                new_password: new_password.into(),
            },
        )?;

        self.client()
            .change_password(req)
            .await
            .map_err(map_status)?;
        Ok(())
    }

    async fn add_email(
        &self,
        access_token: &str,
        user_id: &str,
        email: &str,
    ) -> Result<UserEmail, AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::AddEmailRequest {
                user_id: user_id.into(),
                email: email.into(),
            },
        )?;

        let resp = self
            .client()
            .add_email(req)
            .await
            .map_err(map_status)?
            .into_inner();

        let email = resp.email.ok_or(AuthError::Other("no email in response".into()))?;
        Ok(UserEmail {
            email: email.email,
            verified: email.verified,
        })
    }

    async fn remove_email(
        &self,
        access_token: &str,
        user_id: &str,
        email: &str,
    ) -> Result<(), AuthError> {
        let req = Self::authed_request(
            access_token,
            forage_grpc::RemoveEmailRequest {
                user_id: user_id.into(),
                email: email.into(),
            },
        )?;

        self.client()
            .remove_email(req)
            .await
            .map_err(map_status)?;
        Ok(())
    }
}

fn convert_organisations(
    organisations: Vec<forage_grpc::Organisation>,
    roles: Vec<String>,
) -> Vec<Organisation> {
    organisations
        .into_iter()
        .zip(roles)
        .map(|(org, role)| Organisation {
            organisation_id: org.organisation_id,
            name: org.name,
            role,
        })
        .collect()
}

fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
    let ctx = a.context.unwrap_or_default();
    let source = a.source.map(|s| ArtifactSource {
        user: s.user.filter(|v| !v.is_empty()),
        email: s.email.filter(|v| !v.is_empty()),
        source_type: s.source_type.filter(|v| !v.is_empty()),
        run_url: s.run_url.filter(|v| !v.is_empty()),
    });
    let git_ref = a.r#ref.map(|r| ArtifactRef {
        commit_sha: r.commit_sha,
        branch: r.branch.filter(|v| !v.is_empty()),
        commit_message: r.commit_message.filter(|v| !v.is_empty()),
        version: r.version.filter(|v| !v.is_empty()),
        repo_url: r.repo_url.filter(|v| !v.is_empty()),
    });
    let destinations = a
        .destinations
        .into_iter()
        .map(|d| ArtifactDestination {
            name: d.name,
            environment: d.environment,
            type_organisation: if d.type_organisation.is_empty() {
                None
            } else {
                Some(d.type_organisation)
            },
            type_name: if d.type_name.is_empty() {
                None
            } else {
                Some(d.type_name)
            },
            type_version: if d.type_version == 0 {
                None
            } else {
                Some(d.type_version)
            },
            status: if d.status.is_empty() {
                None
            } else {
                Some(d.status)
            },
        })
        .collect();
    Artifact {
        artifact_id: a.artifact_id,
        slug: a.slug,
        context: ArtifactContext {
            title: ctx.title,
            description: if ctx.description.as_deref() == Some("") {
                None
            } else {
                ctx.description
            },
            web: ctx.web.filter(|v| !v.is_empty()),
            pr: ctx.pr.filter(|v| !v.is_empty()),
        },
        source,
        git_ref,
        destinations,
        created_at: a.created_at,
    }
}

fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
    let config = match s.config {
        Some(forage_grpc::pipeline_stage::Config::Deploy(d)) => {
            PipelineStageConfig::Deploy { environment: d.environment }
        }
        Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
            PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
        }
        None => PipelineStageConfig::Deploy { environment: String::new() },
    };
    PipelineStage {
        id: s.id,
        depends_on: s.depends_on,
        config,
    }
}

/// Convert a `PipelineStageState` proto message (from GetReleaseIntentStates)
/// to the domain type. Same enum mapping as `convert_pipeline_run_stage`.
fn convert_pipeline_stage_state(
    s: forage_grpc::PipelineStageState,
) -> forage_core::platform::PipelineRunStageState {
    let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) {
        Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy",
        Ok(forage_grpc::PipelineRunStageType::Wait) => "wait",
        _ => "unknown",
    };
    let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) {
        Ok(forage_grpc::PipelineRunStageStatus::Pending) => "PENDING",
        Ok(forage_grpc::PipelineRunStageStatus::Active) => "RUNNING",
        Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED",
        Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED",
        Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED",
        _ => "PENDING",
    };
    forage_core::platform::PipelineRunStageState {
        stage_id: s.stage_id,
        depends_on: s.depends_on,
        stage_type: stage_type.into(),
        status: status.into(),
        environment: s.environment,
        duration_seconds: s.duration_seconds,
        queued_at: s.queued_at,
        started_at: s.started_at,
        completed_at: s.completed_at,
        error_message: s.error_message,
        wait_until: s.wait_until,
        release_ids: s.release_ids,
    }
}

fn convert_release_step_state(
    s: forage_grpc::ReleaseStepState,
) -> forage_core::platform::ReleaseStepState {
    forage_core::platform::ReleaseStepState {
        release_id: s.release_id,
        stage_id: s.stage_id,
        destination_name: s.destination_name,
        environment: s.environment,
        status: s.status,
        queued_at: s.queued_at,
        assigned_at: s.assigned_at,
        started_at: s.started_at,
        completed_at: s.completed_at,
        error_message: s.error_message,
    }
}

fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec<forage_grpc::PipelineStage> {
    stages
        .iter()
        .map(|s| forage_grpc::PipelineStage {
            id: s.id.clone(),
            depends_on: s.depends_on.clone(),
            config: Some(match &s.config {
                PipelineStageConfig::Deploy { environment } => {
                    forage_grpc::pipeline_stage::Config::Deploy(forage_grpc::DeployStageConfig {
                        environment: environment.clone(),
                    })
                }
                PipelineStageConfig::Wait { duration_seconds } => {
                    forage_grpc::pipeline_stage::Config::Wait(forage_grpc::WaitStageConfig {
                        duration_seconds: *duration_seconds,
                    })
                }
            }),
        })
        .collect()
}

fn convert_release_pipeline(p: forage_grpc::ReleasePipeline) -> ReleasePipeline {
    ReleasePipeline {
        id: p.id,
        name: p.name,
        enabled: p.enabled,
        stages: p.stages.into_iter().map(convert_pipeline_stage).collect(),
        created_at: p.created_at,
        updated_at: p.updated_at,
    }
}

fn convert_trigger(t: forage_grpc::Trigger) -> Trigger {
    Trigger {
        id: t.id,
        name: t.name,
        enabled: t.enabled,
        branch_pattern: t.branch_pattern,
        title_pattern: t.title_pattern,
        author_pattern: t.author_pattern,
        commit_message_pattern: t.commit_message_pattern,
        source_type_pattern: t.source_type_pattern,
        target_environments: t.target_environments,
        target_destinations: t.target_destinations,
        force_release: t.force_release,
        use_pipeline: t.use_pipeline,
        created_at: t.created_at,
        updated_at: t.updated_at,
    }
}

fn convert_policy(p: forage_grpc::Policy) -> Policy {
    let policy_type_str = match forage_grpc::PolicyType::try_from(p.policy_type) {
        Ok(forage_grpc::PolicyType::SoakTime) => "soak_time",
        Ok(forage_grpc::PolicyType::BranchRestriction) => "branch_restriction",
        _ => "unknown",
    };
    let config = match p.config {
        Some(forage_grpc::policy::Config::SoakTime(c)) => PolicyConfig::SoakTime {
            source_environment: c.source_environment,
            target_environment: c.target_environment,
            duration_seconds: c.duration_seconds,
        },
        Some(forage_grpc::policy::Config::BranchRestriction(c)) => {
            PolicyConfig::BranchRestriction {
                target_environment: c.target_environment,
                branch_pattern: c.branch_pattern,
            }
        }
        None => PolicyConfig::SoakTime {
            source_environment: String::new(),
            target_environment: String::new(),
            duration_seconds: 0,
        },
    };
    Policy {
        id: p.id,
        name: p.name,
        enabled: p.enabled,
        policy_type: policy_type_str.into(),
        config,
        created_at: p.created_at,
        updated_at: p.updated_at,
    }
}

fn policy_config_to_grpc(
    config: &PolicyConfig,
) -> (i32, Option<forage_grpc::create_policy_request::Config>) {
    match config {
        PolicyConfig::SoakTime {
            source_environment,
            target_environment,
            duration_seconds,
        } => (
            forage_grpc::PolicyType::SoakTime as i32,
            Some(forage_grpc::create_policy_request::Config::SoakTime(
                forage_grpc::SoakTimeConfig {
                    source_environment: source_environment.clone(),
                    target_environment: target_environment.clone(),
                    duration_seconds: *duration_seconds,
                },
            )),
        ),
        PolicyConfig::BranchRestriction {
            target_environment,
            branch_pattern,
        } => (
            forage_grpc::PolicyType::BranchRestriction as i32,
            Some(
                forage_grpc::create_policy_request::Config::BranchRestriction(
                    forage_grpc::BranchRestrictionConfig {
                        target_environment: target_environment.clone(),
                        branch_pattern: branch_pattern.clone(),
                    },
                ),
            ),
        ),
    }
}

fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
    OrgMember {
        user_id: m.user_id,
        username: m.username,
        role: m.role,
        joined_at: m.joined_at.map(|ts| ts.to_string()),
    }
}

fn map_platform_status(status: tonic::Status) -> PlatformError {
    match status.code() {
        tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
            PlatformError::NotAuthenticated
        }
        tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
        tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
        _ => PlatformError::Other(status.message().into()),
    }
}

fn platform_authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, PlatformError> {
    bearer_request(access_token, msg).map_err(PlatformError::Other)
}

#[async_trait::async_trait]
impl ForestPlatform for GrpcForestClient {
    async fn list_my_organisations(
        &self,
        access_token: &str,
    ) -> Result<Vec<Organisation>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::ListMyOrganisationsRequest { role: String::new() },
        )?;

        let resp = self
            .org_client()
            .list_my_organisations(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        Ok(convert_organisations(resp.organisations, resp.roles))
    }

    async fn list_projects(
        &self,
        access_token: &str,
        organisation: &str,
    ) -> Result<Vec<String>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::GetProjectsRequest {
                query: Some(forage_grpc::get_projects_request::Query::Organisation(
                    forage_grpc::OrganisationRef {
                        organisation: organisation.into(),
                    },
                )),
            },
        )?;

        let resp = self
            .release_client()
            .get_projects(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        Ok(resp.projects)
    }

    async fn list_artifacts(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
    ) -> Result<Vec<Artifact>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::GetArtifactsByProjectRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
            },
        )?;

        let resp = self
            .release_client()
            .get_artifacts_by_project(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        Ok(resp.artifact.into_iter().map(convert_artifact).collect())
    }

    async fn create_organisation(
        &self,
        access_token: &str,
        name: &str,
    ) -> Result<String, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::CreateOrganisationRequest {
                name: name.into(),
            },
        )?;

        let resp = self
            .org_client()
            .create_organisation(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        Ok(resp.organisation_id)
    }

    async fn list_members(
        &self,
        access_token: &str,
        organisation_id: &str,
    ) -> Result<Vec<OrgMember>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::ListMembersRequest {
                organisation_id: organisation_id.into(),
                page_size: 100,
                page_token: String::new(),
            },
        )?;

        let resp = self
            .org_client()
            .list_members(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        Ok(resp.members.into_iter().map(convert_member).collect())
    }

    async fn add_member(
        &self,
        access_token: &str,
        organisation_id: &str,
        user_id: &str,
        role: &str,
    ) -> Result<OrgMember, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::AddMemberRequest {
                organisation_id: organisation_id.into(),
                user_id: user_id.into(),
                role: role.into(),
            },
        )?;

        let resp = self
            .org_client()
            .add_member(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        let member = resp
            .member
            .ok_or(PlatformError::Other("no member in response".into()))?;
        Ok(convert_member(member))
    }

    async fn remove_member(
        &self,
        access_token: &str,
        organisation_id: &str,
        user_id: &str,
    ) -> Result<(), PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::RemoveMemberRequest {
                organisation_id: organisation_id.into(),
                user_id: user_id.into(),
            },
        )?;

        self.org_client()
            .remove_member(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }

    async fn update_member_role(
        &self,
        access_token: &str,
        organisation_id: &str,
        user_id: &str,
        role: &str,
    ) -> Result<OrgMember, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::UpdateMemberRoleRequest {
                organisation_id: organisation_id.into(),
                user_id: user_id.into(),
                role: role.into(),
            },
        )?;

        let resp = self
            .org_client()
            .update_member_role(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        let member = resp
            .member
            .ok_or(PlatformError::Other("no member in response".into()))?;
        Ok(convert_member(member))
    }

    async fn get_artifact_by_slug(
        &self,
        access_token: &str,
        slug: &str,
    ) -> Result<Artifact, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::GetArtifactBySlugRequest {
                slug: slug.into(),
            },
        )?;

        let resp = self
            .release_client()
            .get_artifact_by_slug(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();

        let artifact = resp
            .artifact
            .ok_or(PlatformError::NotFound("artifact not found".into()))?;
        Ok(convert_artifact(artifact))
    }

    async fn list_environments(
        &self,
        access_token: &str,
        organisation: &str,
    ) -> Result<Vec<Environment>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::ListEnvironmentsRequest {
                organisation: organisation.into(),
            },
        )?;
        let resp = self
            .env_client()
            .list_environments(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        Ok(resp
            .environments
            .into_iter()
            .map(|e| Environment {
                id: e.id,
                organisation: e.organisation,
                name: e.name,
                description: e.description.filter(|v| !v.is_empty()),
                sort_order: e.sort_order,
                created_at: e.created_at,
            })
            .collect())
    }

    async fn list_destinations(
        &self,
        access_token: &str,
        organisation: &str,
    ) -> Result<Vec<Destination>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::GetDestinationsRequest {
                organisation: organisation.into(),
            },
        )?;
        let resp = self
            .dest_client()
            .get_destinations(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        Ok(resp
            .destinations
            .into_iter()
            .map(|d| Destination {
                name: d.name,
                environment: d.environment,
                organisation: d.organisation,
                metadata: d.metadata,
                dest_type: d.r#type.map(|t| DestinationType {
                    organisation: t.organisation,
                    name: t.name,
                    version: t.version,
                }),
            })
            .collect())
    }

    async fn create_environment(
        &self,
        access_token: &str,
        organisation: &str,
        name: &str,
        description: Option<&str>,
        sort_order: i32,
    ) -> Result<Environment, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::CreateEnvironmentRequest {
                organisation: organisation.into(),
                name: name.into(),
                description: description.map(|s| s.to_string()),
                sort_order,
            },
        )?;
        let resp = self
            .env_client()
            .create_environment(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let e = resp
            .environment
            .ok_or(PlatformError::Other("no environment in response".into()))?;
        Ok(Environment {
            id: e.id,
            organisation: e.organisation,
            name: e.name,
            description: e.description.filter(|v| !v.is_empty()),
            sort_order: e.sort_order,
            created_at: e.created_at,
        })
    }

    async fn create_destination(
        &self,
        access_token: &str,
        organisation: &str,
        name: &str,
        environment: &str,
        metadata: &std::collections::HashMap<String, String>,
        dest_type: Option<&forage_core::platform::DestinationType>,
    ) -> Result<(), PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::CreateDestinationRequest {
                organisation: organisation.into(),
                name: name.into(),
                environment: environment.into(),
                metadata: metadata.clone(),
                r#type: dest_type.map(|t| forage_grpc::DestinationType {
                    organisation: t.organisation.clone(),
                    name: t.name.clone(),
                    version: t.version,
                }),
            },
        )?;
        self.dest_client()
            .create_destination(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }

    async fn update_destination(
        &self,
        access_token: &str,
        name: &str,
        metadata: &std::collections::HashMap<String, String>,
    ) -> Result<(), PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::UpdateDestinationRequest {
                name: name.into(),
                metadata: metadata.clone(),
            },
        )?;
        self.dest_client()
            .update_destination(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }

    async fn get_destination_states(
        &self,
        access_token: &str,
        organisation: &str,
        project: Option<&str>,
    ) -> Result<forage_core::platform::DeploymentStates, PlatformError> {
        let req = bearer_request(
            access_token,
            forage_grpc::GetDestinationStatesRequest {
                organisation: organisation.into(),
                project: project.map(|p| p.into()),
            },
        )
        .map_err(|e| PlatformError::Other(e.to_string()))?;

        let resp = self
            .release_client()
            .get_destination_states(req)
            .await
            .map_err(map_platform_status)?;

        let inner = resp.into_inner();

        let destinations = inner
            .destinations
            .into_iter()
            .map(|d| forage_core::platform::DestinationState {
                destination_id: d.destination_id,
                destination_name: d.destination_name,
                environment: d.environment,
                release_id: d.release_id,
                artifact_id: d.artifact_id,
                status: d.status,
                error_message: d.error_message,
                queued_at: d.queued_at,
                completed_at: d.completed_at,
                queue_position: d.queue_position,
                started_at: d.started_at,
            })
            .collect();

        Ok(forage_core::platform::DeploymentStates {
            destinations,
        })
    }

    async fn get_release_intent_states(
        &self,
        access_token: &str,
        organisation: &str,
        project: Option<&str>,
        include_completed: bool,
    ) -> Result<Vec<forage_core::platform::ReleaseIntentState>, PlatformError> {
        let req = bearer_request(
            access_token,
            forage_grpc::GetReleaseIntentStatesRequest {
                organisation: organisation.into(),
                project: project.map(|p| p.into()),
                include_completed,
            },
        )
        .map_err(|e| PlatformError::Other(e.to_string()))?;

        let resp = self
            .release_client()
            .get_release_intent_states(req)
            .await
            .map_err(map_platform_status)?;

        Ok(resp
            .into_inner()
            .release_intents
            .into_iter()
            .map(|ri| forage_core::platform::ReleaseIntentState {
                release_intent_id: ri.release_intent_id,
                artifact_id: ri.artifact_id,
                project: ri.project,
                created_at: ri.created_at,
                stages: ri
                    .stages
                    .into_iter()
                    .map(convert_pipeline_stage_state)
                    .collect(),
                steps: ri
                    .steps
                    .into_iter()
                    .map(convert_release_step_state)
                    .collect(),
            })
            .collect())
    }

    async fn release_artifact(
        &self,
        access_token: &str,
        artifact_id: &str,
        destinations: &[String],
        environments: &[String],
        use_pipeline: bool,
    ) -> Result<(), PlatformError> {
        let req = bearer_request(
            access_token,
            forage_grpc::ReleaseRequest {
                artifact_id: artifact_id.into(),
                destinations: destinations.to_vec(),
                environments: environments.to_vec(),
                force: false,
                use_pipeline,
            },
        )
        .map_err(|e| PlatformError::Other(e.to_string()))?;

        self.release_client()
            .release(req)
            .await
            .map_err(map_platform_status)?;

        Ok(())
    }

    async fn list_triggers(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
    ) -> Result<Vec<Trigger>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::ListTriggersRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
            },
        )?;
        let resp = self
            .trigger_client()
            .list_triggers(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        Ok(resp.triggers.into_iter().map(convert_trigger).collect())
    }

    async fn create_trigger(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        input: &CreateTriggerInput,
    ) -> Result<Trigger, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::CreateTriggerRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: input.name.clone(),
                branch_pattern: input.branch_pattern.clone(),
                title_pattern: input.title_pattern.clone(),
                author_pattern: input.author_pattern.clone(),
                commit_message_pattern: input.commit_message_pattern.clone(),
                source_type_pattern: input.source_type_pattern.clone(),
                target_environments: input.target_environments.clone(),
                target_destinations: input.target_destinations.clone(),
                force_release: input.force_release,
                use_pipeline: input.use_pipeline,
            },
        )?;
        let resp = self
            .trigger_client()
            .create_trigger(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let trigger = resp
            .trigger
            .ok_or(PlatformError::Other("no trigger in response".into()))?;
        Ok(convert_trigger(trigger))
    }

    async fn update_trigger(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        name: &str,
        input: &UpdateTriggerInput,
    ) -> Result<Trigger, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::UpdateTriggerRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: name.into(),
                enabled: input.enabled,
                branch_pattern: input.branch_pattern.clone(),
                title_pattern: input.title_pattern.clone(),
                author_pattern: input.author_pattern.clone(),
                commit_message_pattern: input.commit_message_pattern.clone(),
                source_type_pattern: input.source_type_pattern.clone(),
                target_environments: input.target_environments.clone(),
                target_destinations: input.target_destinations.clone(),
                force_release: input.force_release,
                use_pipeline: input.use_pipeline,
            },
        )?;
        let resp = self
            .trigger_client()
            .update_trigger(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let trigger = resp
            .trigger
            .ok_or(PlatformError::Other("no trigger in response".into()))?;
        Ok(convert_trigger(trigger))
    }

    async fn delete_trigger(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        name: &str,
    ) -> Result<(), PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::DeleteTriggerRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: name.into(),
            },
        )?;
        self.trigger_client()
            .delete_trigger(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }

    async fn list_policies(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
    ) -> Result<Vec<Policy>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::ListPoliciesRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
            },
        )?;
        let resp = self
            .policy_client()
            .list_policies(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        Ok(resp.policies.into_iter().map(convert_policy).collect())
    }

    async fn create_policy(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        input: &CreatePolicyInput,
    ) -> Result<Policy, PlatformError> {
        let (policy_type, config) = policy_config_to_grpc(&input.config);
        let req = platform_authed_request(
            access_token,
            forage_grpc::CreatePolicyRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: input.name.clone(),
                policy_type,
                config,
            },
        )?;
        let resp = self
            .policy_client()
            .create_policy(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let policy = resp
            .policy
            .ok_or(PlatformError::Other("no policy in response".into()))?;
        Ok(convert_policy(policy))
    }

    async fn update_policy(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        name: &str,
        input: &UpdatePolicyInput,
    ) -> Result<Policy, PlatformError> {
        let config = input.config.as_ref().map(|c| {
            let (_, grpc_config) = policy_config_to_grpc(c);
            match grpc_config {
                Some(forage_grpc::create_policy_request::Config::SoakTime(s)) => {
                    forage_grpc::update_policy_request::Config::SoakTime(s)
                }
                Some(forage_grpc::create_policy_request::Config::BranchRestriction(b)) => {
                    forage_grpc::update_policy_request::Config::BranchRestriction(b)
                }
                None => forage_grpc::update_policy_request::Config::SoakTime(
                    forage_grpc::SoakTimeConfig::default(),
                ),
            }
        });
        let req = platform_authed_request(
            access_token,
            forage_grpc::UpdatePolicyRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: name.into(),
                enabled: input.enabled,
                config,
            },
        )?;
        let resp = self
            .policy_client()
            .update_policy(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let policy = resp
            .policy
            .ok_or(PlatformError::Other("no policy in response".into()))?;
        Ok(convert_policy(policy))
    }

    async fn delete_policy(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        name: &str,
    ) -> Result<(), PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::DeletePolicyRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: name.into(),
            },
        )?;
        self.policy_client()
            .delete_policy(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }

    async fn list_release_pipelines(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
    ) -> Result<Vec<ReleasePipeline>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::ListReleasePipelinesRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
            },
        )?;
        let resp = self
            .pipeline_client()
            .list_release_pipelines(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        Ok(resp
            .pipelines
            .into_iter()
            .map(convert_release_pipeline)
            .collect())
    }

    async fn create_release_pipeline(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        input: &CreateReleasePipelineInput,
    ) -> Result<ReleasePipeline, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::CreateReleasePipelineRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: input.name.clone(),
                stages: convert_stages_to_grpc(&input.stages),
            },
        )?;
        let resp = self
            .pipeline_client()
            .create_release_pipeline(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let pipeline = resp
            .pipeline
            .ok_or(PlatformError::Other("no pipeline in response".into()))?;
        Ok(convert_release_pipeline(pipeline))
    }

    async fn update_release_pipeline(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        name: &str,
        input: &UpdateReleasePipelineInput,
    ) -> Result<ReleasePipeline, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::UpdateReleasePipelineRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: name.into(),
                enabled: input.enabled,
                stages: input.stages.as_ref().map(|s| convert_stages_to_grpc(s)).unwrap_or_default(),
                update_stages: input.stages.is_some(),
            },
        )?;
        let resp = self
            .pipeline_client()
            .update_release_pipeline(req)
            .await
            .map_err(map_platform_status)?
            .into_inner();
        let pipeline = resp
            .pipeline
            .ok_or(PlatformError::Other("no pipeline in response".into()))?;
        Ok(convert_release_pipeline(pipeline))
    }

    async fn delete_release_pipeline(
        &self,
        access_token: &str,
        organisation: &str,
        project: &str,
        name: &str,
    ) -> Result<(), PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::DeleteReleasePipelineRequest {
                project: Some(forage_grpc::Project {
                    organisation: organisation.into(),
                    project: project.into(),
                }),
                name: name.into(),
            },
        )?;
        self.pipeline_client()
            .delete_release_pipeline(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }

    async fn get_artifact_spec(
        &self,
        access_token: &str,
        artifact_id: &str,
    ) -> Result<String, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::GetArtifactSpecRequest {
                artifact_id: artifact_id.into(),
            },
        )?;
        let resp = self
            .artifact_client()
            .get_artifact_spec(req)
            .await
            .map_err(map_platform_status)?;
        Ok(resp.into_inner().content)
    }

    async fn get_notification_preferences(
        &self,
        access_token: &str,
    ) -> Result<Vec<NotificationPreference>, PlatformError> {
        let req = platform_authed_request(
            access_token,
            forage_grpc::GetNotificationPreferencesRequest {},
        )?;
        let resp = self
            .notification_client()
            .get_notification_preferences(req)
            .await
            .map_err(map_platform_status)?;
        Ok(resp
            .into_inner()
            .preferences
            .into_iter()
            .map(|p| {
                let nt = forage_grpc::NotificationType::try_from(p.notification_type)
                    .unwrap_or(forage_grpc::NotificationType::Unspecified);
                let ch = forage_grpc::NotificationChannel::try_from(p.channel)
                    .unwrap_or(forage_grpc::NotificationChannel::Unspecified);
                NotificationPreference {
                    notification_type: nt.as_str_name().to_string(),
                    channel: ch.as_str_name().to_string(),
                    enabled: p.enabled,
                }
            })
            .collect())
    }

    async fn set_notification_preference(
        &self,
        access_token: &str,
        notification_type: &str,
        channel: &str,
        enabled: bool,
    ) -> Result<(), PlatformError> {
        let nt = forage_grpc::NotificationType::from_str_name(notification_type)
            .unwrap_or(forage_grpc::NotificationType::Unspecified) as i32;
        let ch = forage_grpc::NotificationChannel::from_str_name(channel)
            .unwrap_or(forage_grpc::NotificationChannel::Unspecified) as i32;
        let req = platform_authed_request(
            access_token,
            forage_grpc::SetNotificationPreferenceRequest {
                notification_type: nt,
                channel: ch,
                enabled,
            },
        )?;
        self.notification_client()
            .set_notification_preference(req)
            .await
            .map_err(map_platform_status)?;
        Ok(())
    }
}

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

    fn make_org(id: &str, name: &str) -> forage_grpc::Organisation {
        forage_grpc::Organisation {
            organisation_id: id.into(),
            name: name.into(),
            ..Default::default()
        }
    }

    fn make_artifact(slug: &str, ctx: Option<forage_grpc::ArtifactContext>) -> forage_grpc::Artifact {
        forage_grpc::Artifact {
            artifact_id: "a1".into(),
            slug: slug.into(),
            context: ctx,
            created_at: "2026-01-01".into(),
            ..Default::default()
        }
    }

    #[test]
    fn convert_organisations_pairs_orgs_with_roles() {
        let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
        let roles = vec!["owner".into(), "member".into()];

        let result = convert_organisations(orgs, roles);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].name, "alpha");
        assert_eq!(result[0].role, "owner");
        assert_eq!(result[1].name, "beta");
        assert_eq!(result[1].role, "member");
    }

    #[test]
    fn convert_organisations_truncates_when_roles_shorter() {
        let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
        let roles = vec!["owner".into()]; // only 1 role for 2 orgs

        let result = convert_organisations(orgs, roles);
        // zip truncates to shorter iterator
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].name, "alpha");
    }

    #[test]
    fn convert_organisations_empty() {
        let result = convert_organisations(vec![], vec![]);
        assert!(result.is_empty());
    }

    #[test]
    fn convert_artifact_with_full_context() {
        let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
            title: "My API".into(),
            description: Some("A cool API".into()),
            ..Default::default()
        }));

        let result = convert_artifact(a);
        assert_eq!(result.slug, "my-api");
        assert_eq!(result.context.title, "My API");
        assert_eq!(result.context.description.as_deref(), Some("A cool API"));
    }

    #[test]
    fn convert_artifact_empty_description_becomes_none() {
        let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
            title: "My API".into(),
            description: Some(String::new()),
            ..Default::default()
        }));

        let result = convert_artifact(a);
        assert!(result.context.description.is_none());
    }

    #[test]
    fn convert_artifact_missing_context_uses_defaults() {
        let a = make_artifact("my-api", None);

        let result = convert_artifact(a);
        assert_eq!(result.context.title, "");
        assert!(result.context.description.is_none());
    }

    #[test]
    fn convert_artifact_none_description_stays_none() {
        let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
            title: "My API".into(),
            description: None,
            ..Default::default()
        }));

        let result = convert_artifact(a);
        assert!(result.context.description.is_none());
    }
}
