use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use axum_extra::extract::Form;
use chrono::Datelike;
use forage_core::platform::{
    validate_slug, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
    PipelineStage, PolicyConfig, UpdatePolicyInput, UpdateReleasePipelineInput,
    UpdateTriggerInput,
};
use forage_core::session::CachedOrg;
use minijinja::context;
use serde::{Deserialize, Serialize};

use super::{error_page, internal_error, warn_default};
use crate::auth::{self, Session};
use crate::state::AppState;

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/dashboard", get(dashboard))
        .route("/notifications", get(notifications_page))
        .route("/orgs", post(create_org_submit))
        .route("/orgs/{org}/projects", get(projects_list))
        .route("/orgs/{org}/projects/{project}", get(project_detail))
        .route(
            "/orgs/{org}/projects/{project}/releases",
            get(project_releases),
        )
        .route(
            "/orgs/{org}/projects/{project}/releases/{slug}",
            get(artifact_detail),
        )
        .route("/orgs/{org}/releases", get(releases_page))
        .route("/orgs/{org}/destinations", get(destinations_page))
        .route(
            "/orgs/{org}/destinations/environments",
            post(create_environment_submit),
        )
        .route(
            "/orgs/{org}/destinations/create",
            post(create_destination_submit),
        )
        .route(
            "/orgs/{org}/destinations/detail",
            get(destination_detail),
        )
        .route(
            "/orgs/{org}/destinations/detail/update",
            post(update_destination_submit),
        )
        .route("/orgs/{org}/usage", get(usage))
        .route(
            "/orgs/{org}/settings/members",
            get(members_page).post(add_member_submit),
        )
        .route(
            "/orgs/{org}/settings/members/{user_id}/role",
            post(update_member_role_submit),
        )
        .route(
            "/orgs/{org}/settings/members/{user_id}/remove",
            post(remove_member_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/deploy",
            post(deploy_release),
        )
        .route(
            "/orgs/{org}/projects/{project}/triggers",
            get(triggers_page).post(create_trigger_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/triggers/{name}",
            get(edit_trigger_page).post(edit_trigger_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/triggers/{name}/toggle",
            post(toggle_trigger),
        )
        .route(
            "/orgs/{org}/projects/{project}/triggers/{name}/delete",
            post(delete_trigger),
        )
        .route(
            "/orgs/{org}/projects/{project}/policies",
            get(policies_page).post(create_policy_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/policies/{name}",
            get(edit_policy_page).post(edit_policy_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/policies/{name}/toggle",
            post(toggle_policy),
        )
        .route(
            "/orgs/{org}/projects/{project}/policies/{name}/delete",
            post(delete_policy),
        )
        .route(
            "/orgs/{org}/projects/{project}/pipelines",
            get(pipelines_page).post(create_pipeline_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/pipelines/{name}/toggle",
            post(toggle_pipeline),
        )
        .route(
            "/orgs/{org}/projects/{project}/pipelines/{name}/update",
            post(update_pipeline_submit),
        )
        .route(
            "/orgs/{org}/projects/{project}/pipelines/{name}/delete",
            post(delete_pipeline),
        )
        .route("/users/{username}", get(user_profile))
        .route(
            "/api/orgs/{org}/projects/{project}/timeline",
            get(timeline_api),
        )
        .route("/api/orgs/{org}/timeline", get(org_timeline_api))
}

fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
    orgs.iter()
        .map(|o| context! { name => o.name, role => o.role })
        .collect()
}


#[allow(clippy::result_large_err)]
fn require_org_membership<'a>(
    state: &AppState,
    orgs: &'a [CachedOrg],
    org: &str,
) -> Result<&'a CachedOrg, Response> {
    if !validate_slug(org) {
        return Err(error_page(
            state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid organisation name.",
        ));
    }
    orgs.iter().find(|o| o.name == org).ok_or_else(|| {
        error_page(
            state,
            StatusCode::FORBIDDEN,
            "Access denied",
            "You don't have access to this organisation.",
        )
    })
}

/// Require the user to be an admin or owner of the organisation.
#[allow(clippy::result_large_err)]
fn require_admin(state: &AppState, org: &CachedOrg) -> Result<(), Response> {
    if org.role == "owner" || org.role == "admin" {
        Ok(())
    } else {
        Err(error_page(
            state,
            StatusCode::FORBIDDEN,
            "Access denied",
            "You must be an admin to perform this action.",
        ))
    }
}

// ─── Dashboard ──────────────────────────────────────────────────────

async fn dashboard(
    State(state): State<AppState>,
    session: Session,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;

    if orgs.is_empty() {
        // No orgs: show onboarding with create org form
        let html = state
            .templates
            .render(
                "pages/onboarding.html.jinja",
                context! {
                    title => "Get Started - Forage",
                    description => "Create your first organisation",
                    user => context! { username => session.user.username },
                    csrf_token => &session.csrf_token,
                    active_tab => "dashboard",
                },
            )
            .map_err(|e| {
                internal_error(&state, "template error", &e)
            })?;
        return Ok(Html(html).into_response());
    }

    // Fetch recent activity: for each org, get projects, then artifacts
    let mut recent_activity = Vec::new();
    let mut first_org_projects: Vec<String> = Vec::new();
    for org in orgs {
        let projects = warn_default(
            "dashboard: list_projects",
            state.platform_client.list_projects(&session.access_token, &org.name).await,
        );

        if first_org_projects.is_empty() && org.name == orgs.first().map(|o| o.name.as_str()).unwrap_or_default() {
            first_org_projects = projects.clone();
        }

        for project in projects.iter().take(5) {
            let artifacts = warn_default(
                "dashboard: list_artifacts",
                state.platform_client.list_artifacts(&session.access_token, &org.name, project).await,
            );

            for artifact in artifacts {
                let mut seen_envs = std::collections::HashSet::new();
                let dest_envs: Vec<String> = artifact
                    .destinations
                    .iter()
                    .filter(|d| seen_envs.insert(d.environment.clone()))
                    .map(|d| d.environment.clone())
                    .collect();
                recent_activity.push(context! {
                    org_name => org.name,
                    project_name => project,
                    slug => artifact.slug,
                    title => artifact.context.title,
                    created_at => artifact.created_at,
                    dest_envs => dest_envs,
                });
                if recent_activity.len() >= 10 {
                    break;
                }
            }
            if recent_activity.len() >= 10 {
                break;
            }
        }
    }

    let html = state
        .templates
        .render(
            "pages/dashboard.html.jinja",
            context! {
                title => "Dashboard - Forage",
                description => "Your Forage dashboard",
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => orgs.first().map(|o| &o.name),
                orgs => orgs_context(orgs),
                projects => first_org_projects,
                recent_activity => recent_activity,
                active_tab => "dashboard",
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

// ─── Notifications ───────────────────────────────────────────────────

struct NotifRelease {
    org: String,
    project: String,
    slug: String,
    title: String,
    description: Option<String>,
    version: Option<String>,
    branch: Option<String>,
    commit_sha: Option<String>,
    commit_message: Option<String>,
    source_user: Option<String>,
    created_at: String,
    summary_status: String,
    env_groups: Vec<minijinja::Value>,
    pipeline_stages: Vec<minijinja::Value>,
    has_pipeline: bool,
    destinations: Vec<minijinja::Value>,
}

async fn fetch_notifications(
    state: &AppState,
    session: &Session,
) -> Vec<NotifRelease> {
    let orgs = &session.user.orgs;
    let username = &session.user.username;
    let mut releases: Vec<NotifRelease> = Vec::new();

    for org in orgs {
        let (projects, dest_states, release_intents) = tokio::join!(
            state
                .platform_client
                .list_projects(&session.access_token, &org.name),
            state
                .platform_client
                .get_destination_states(&session.access_token, &org.name, None),
            state
                .platform_client
                .get_release_intent_states(&session.access_token, &org.name, None, true),
        );
        let projects = match projects {
            Ok(p) => p,
            Err(_) => continue,
        };
        let dest_states = dest_states.unwrap_or_default();
        let release_intents = release_intents.unwrap_or_default();

        // Index destination states by artifact_id.
        let mut states_by_artifact: std::collections::HashMap<
            &str,
            Vec<&forage_core::platform::DestinationState>,
        > = std::collections::HashMap::new();
        for ds in &dest_states.destinations {
            if let Some(aid) = ds.artifact_id.as_deref() {
                states_by_artifact.entry(aid).or_default().push(ds);
            }
        }

        // Index pipeline stages by artifact_id.
        let mut intent_stages_by_artifact: std::collections::HashMap<
            &str,
            &[forage_core::platform::PipelineRunStageState],
        > = std::collections::HashMap::new();
        for ri in &release_intents {
            if !ri.stages.is_empty() {
                intent_stages_by_artifact.insert(ri.artifact_id.as_str(), &ri.stages);
            }
        }

        // Fetch pipeline configs per project to know which projects have pipelines.
        let mut pipelines_by_project: std::collections::HashMap<String, bool> =
            std::collections::HashMap::new();
        for p in &projects {
            let has = warn_default(
                "list_release_pipelines",
                state
                    .platform_client
                    .list_release_pipelines(&session.access_token, &org.name, p)
                    .await,
            )
            .iter()
            .any(|pl| pl.enabled);
            if has {
                pipelines_by_project.insert(p.clone(), true);
            }
        }

        for project in &projects {
            let artifacts = match state
                .platform_client
                .list_artifacts(&session.access_token, &org.name, project)
                .await
            {
                Ok(a) => a,
                Err(_) => continue,
            };
            for artifact in artifacts {
                // Filter to current user's releases.
                let is_mine = artifact
                    .source
                    .as_ref()
                    .and_then(|s| s.user.as_deref())
                    .map(|u| u == username)
                    .unwrap_or(false);
                if !is_mine {
                    continue;
                }

                let matching_states = states_by_artifact
                    .get(artifact.artifact_id.as_str())
                    .cloned()
                    .unwrap_or_default();

                // Compute summary status.
                let aid = artifact.artifact_id.as_str();
                let summary_status = compute_summary_status(&matching_states, || {
                    intent_stages_by_artifact.contains_key(aid)
                });

                // Build env groups for display.
                let env_groups = build_env_groups(&matching_states);

                // Build pipeline stages from intent data.
                let mut pipeline_stages: Vec<minijinja::Value> = Vec::new();
                if let Some(run_stages) = intent_stages_by_artifact.get(aid) {
                    let sorted = topo_sort_run_stages(run_stages);
                    for rs in sorted {
                        let display_status = deploy_stage_display_status(rs, &matching_states);
                        pipeline_stages.push(context! {
                            id => rs.stage_id,
                            stage_type => rs.stage_type,
                            environment => rs.environment,
                            duration_seconds => rs.duration_seconds,
                            status => display_status,
                            started_at => rs.started_at,
                            completed_at => rs.completed_at,
                            error_message => rs.error_message,
                            wait_until => rs.wait_until,
                        });
                    }
                }

                let project_has_pipeline = pipelines_by_project.contains_key(project);
                let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;

                // Build destinations.
                let destinations: Vec<minijinja::Value> = matching_states
                    .iter()
                    .map(|ds| {
                        context! {
                            name => ds.destination_name,
                            environment => ds.environment,
                            status => ds.status,
                            error_message => ds.error_message,
                            queued_at => ds.queued_at,
                            started_at => ds.started_at,
                            completed_at => ds.completed_at,
                            queue_position => ds.queue_position,
                        }
                    })
                    .collect();

                releases.push(NotifRelease {
                    org: org.name.clone(),
                    project: project.clone(),
                    slug: artifact.slug,
                    title: artifact.context.title,
                    description: artifact.context.description,
                    version: artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
                    branch: artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
                    commit_sha: artifact
                        .git_ref
                        .as_ref()
                        .map(|r| r.commit_sha[..r.commit_sha.len().min(7)].to_string()),
                    commit_message: artifact
                        .git_ref
                        .as_ref()
                        .and_then(|r| r.commit_message.clone()),
                    source_user: artifact.source.as_ref().and_then(|s| s.user.clone()),
                    created_at: artifact.created_at,
                    summary_status: summary_status.to_string(),
                    env_groups,
                    pipeline_stages,
                    has_pipeline,
                    destinations,
                });
            }
        }
    }

    // Sort: in-progress first (RUNNING, QUEUED), then by created_at descending.
    releases.sort_by(|a, b| {
        let active = |s: &str| matches!(s, "RUNNING" | "QUEUED");
        let a_active = active(&a.summary_status);
        let b_active = active(&b.summary_status);
        match (a_active, b_active) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => b.created_at.cmp(&a.created_at),
        }
    });

    releases.truncate(50);
    releases
}

fn notifications_to_values(releases: Vec<NotifRelease>) -> Vec<minijinja::Value> {
    releases
        .into_iter()
        .map(|r| {
            context! {
                org => r.org,
                project => r.project,
                slug => r.slug,
                title => r.title,
                description => r.description,
                version => r.version,
                branch => r.branch,
                commit_sha => r.commit_sha,
                commit_message => r.commit_message,
                source_user => r.source_user,
                created_at => r.created_at,
                summary_status => r.summary_status,
                env_groups => r.env_groups,
                pipeline_stages => r.pipeline_stages,
                has_pipeline => r.has_pipeline,
                destinations => r.destinations,
            }
        })
        .collect()
}

#[derive(Deserialize)]
struct NotificationsQuery {
    #[serde(default)]
    _partial: Option<String>,
}

async fn notifications_page(
    State(state): State<AppState>,
    session: Session,
    Query(query): Query<NotificationsQuery>,
) -> Result<Response, Response> {
    let releases = fetch_notifications(&state, &session).await;
    let release_values = notifications_to_values(releases);

    // Partial render: return just the list HTML for AJAX polling.
    if query._partial.is_some() {
        let html = state
            .templates
            .render(
                "components/notifications_list.html.jinja",
                context! { releases => release_values },
            )
            .map_err(|e| internal_error(&state, "template error", &e))?;
        return Ok(Html(html).into_response());
    }

    let orgs = &session.user.orgs;
    let html = state
        .templates
        .render(
            "pages/notifications.html.jinja",
            context! {
                title => "Notifications - Forage",
                description => "Your release activity",
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => orgs.first().map(|o| &o.name),
                orgs => orgs_context(orgs),
                releases => release_values,
                active_tab => "notifications",
            },
        )
        .map_err(|e| internal_error(&state, "template error", &e))?;

    Ok(Html(html).into_response())
}

// ─── Create organisation ────────────────────────────────────────────

#[derive(Deserialize)]
struct CreateOrgForm {
    name: String,
    _csrf: String,
}

async fn create_org_submit(
    State(state): State<AppState>,
    session: Session,
    Form(form): Form<CreateOrgForm>,
) -> Result<Response, Response> {
    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    if !validate_slug(&form.name) {
        // Re-render onboarding/dashboard with error
        let html = state
            .templates
            .render(
                "pages/onboarding.html.jinja",
                context! {
                    title => "Get Started - Forage",
                    description => "Create your first organisation",
                    user => context! { username => session.user.username },
                    csrf_token => &session.csrf_token,
                    active_tab => "dashboard",
                    error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
                },
            )
            .map_err(|e| {
                internal_error(&state, "template error", &e)
            })?;
        return Ok(Html(html).into_response());
    }

    match state
        .platform_client
        .create_organisation(&session.access_token, &form.name)
        .await
    {
        Ok(org_id) => {
            // Update session with new org
            if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
                if let Some(ref mut user) = session_data.user {
                    user.orgs.push(CachedOrg {
                        organisation_id: org_id,
                        name: form.name.clone(),
                        role: "owner".into(),
                    });
                }
                let _ = state
                    .sessions
                    .update(&session.session_id, session_data)
                    .await;
            }
            Ok(Redirect::to(&format!("/orgs/{}/projects", form.name)).into_response())
        }
        Err(e) => {
            tracing::error!("failed to create org: {e}");
            let html = state
                .templates
                .render(
                    "pages/onboarding.html.jinja",
                    context! {
                        title => "Get Started - Forage",
                        description => "Create your first organisation",
                        user => context! { username => session.user.username },
                        csrf_token => &session.csrf_token,
                        active_tab => "dashboard",
                        error => "Could not create organisation. Please try again.",
                    },
                )
                .map_err(|e| {
                    tracing::error!("template error: {e:#}");
                    error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
                })?;
            Ok(Html(html).into_response())
        }
    }
}

// ─── Projects list ──────────────────────────────────────────────────

async fn projects_list(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    let projects = state
        .platform_client
        .list_projects(&session.access_token, &org)
        .await
        .map_err(|e| internal_error(&state, "list_projects", &e))?;

    let html = state
        .templates
        .render(
            "pages/projects.html.jinja",
            context! {
                title => format!("{org} - Projects - Forage"),
                description => format!("Projects in {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                projects => projects,
                active_tab => "projects",
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

// ─── Project detail ─────────────────────────────────────────────────

async fn project_detail(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (artifacts, projects, environments, dest_states, release_intents, project_pipelines) = tokio::join!(
        state
            .platform_client
            .list_artifacts(&session.access_token, &org, &project),
        state
            .platform_client
            .list_projects(&session.access_token, &org),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .get_destination_states(&session.access_token, &org, Some(&project)),
        state
            .platform_client
            .get_release_intent_states(&session.access_token, &org, Some(&project), true),
        state
            .platform_client
            .list_release_pipelines(&session.access_token, &org, &project),
    );
    let artifacts = artifacts.map_err(|e| internal_error(&state, "list_artifacts", &e))?;
    let projects = warn_default("list_projects", projects);
    let environments = warn_default("list_environments", environments);
    let dest_states = warn_default("get_destination_states", dest_states);
    let release_intents = warn_default("get_release_intent_states", release_intents);
    let project_pipelines = warn_default("list_release_pipelines", project_pipelines);

    // Environment options for the deploy dropdown (sorted by sort_order).
    let mut sorted_envs = environments.clone();
    sorted_envs.sort_by_key(|e| e.sort_order);
    let env_options: Vec<minijinja::Value> = if !sorted_envs.is_empty() {
        sorted_envs
            .iter()
            .map(|e| context! { name => e.name })
            .collect()
    } else {
        // Fallback: derive from artifact destinations
        let mut env_seen = std::collections::HashSet::new();
        artifacts
            .iter()
            .flat_map(|a| a.destinations.iter())
            .filter(|d| env_seen.insert(d.environment.clone()))
            .map(|d| context! { name => d.environment })
            .collect()
    };

    let items: Vec<ArtifactWithProject> = artifacts
        .into_iter()
        .map(|a| ArtifactWithProject {
            artifact: a,
            project_name: project.clone(),
        })
        .collect();
    let mut pipelines_map = PipelinesByProject::new();
    if !project_pipelines.is_empty() {
        pipelines_map.insert(project.clone(), project_pipelines);
    }
    let data = build_timeline(items, &org, &environments, &dest_states, &release_intents, &pipelines_map);


    let html = state
        .templates
        .render(
            "pages/project_detail.html.jinja",
            context! {
                title => format!("{project} - {org} - Forage"),
                description => format!("Project {project} in {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                project_name => &project,
                projects => projects,
                active_tab => "project_overview",
                timeline => data.timeline,
                lanes => data.lanes,
                env_options => env_options,
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

// ─── Project releases list ───────────────────────────────────────────

async fn project_releases(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (artifacts, projects) = tokio::join!(
        state
            .platform_client
            .list_artifacts(&session.access_token, &org, &project),
        state
            .platform_client
            .list_projects(&session.access_token, &org),
    );
    let artifacts = artifacts.map_err(|e| internal_error(&state, "list_artifacts", &e))?;
    let projects = warn_default("list_projects", projects);

    let releases: Vec<minijinja::Value> = artifacts
        .iter()
        .map(|a| {
            let mut seen_envs = std::collections::HashSet::new();
            let envs: Vec<String> = a
                .destinations
                .iter()
                .filter(|d| seen_envs.insert(d.environment.clone()))
                .map(|d| d.environment.clone())
                .collect();
            let status = if a.destinations.is_empty() {
                "pending"
            } else {
                "deployed"
            };
            context! {
                slug => a.slug,
                title => a.context.title,
                description => a.context.description,
                created_at => a.created_at,
                commit_sha => a.git_ref.as_ref().map(|r| r.commit_sha.clone()),
                branch => a.git_ref.as_ref().and_then(|r| r.branch.clone()),
                version => a.git_ref.as_ref().and_then(|r| r.version.clone()),
                source_user => a.source.as_ref().and_then(|s| s.user.clone()),
                source_type => a.source.as_ref().and_then(|s| s.source_type.clone()),
                envs => envs,
                status => status,
            }
        })
        .collect();

    let html = state
        .templates
        .render(
            "pages/project_releases.html.jinja",
            context! {
                title => format!("Releases - {project} - {org} - Forage"),
                description => format!("All releases for {project} in {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                project_name => &project,
                projects => projects,
                active_tab => "project_releases",
                releases => releases,
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

// ─── Artifact detail ─────────────────────────────────────────────────

async fn artifact_detail(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, slug)): Path<(String, String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (artifact_result, projects, dest_states, release_intents, pipelines) = tokio::join!(
        state
            .platform_client
            .get_artifact_by_slug(&session.access_token, &slug),
        state
            .platform_client
            .list_projects(&session.access_token, &org),
        state
            .platform_client
            .get_destination_states(&session.access_token, &org, Some(&project)),
        state
            .platform_client
            .get_release_intent_states(&session.access_token, &org, Some(&project), true),
        state
            .platform_client
            .list_release_pipelines(&session.access_token, &org, &project),
    );
    // Fetch artifact spec after we have the artifact_id (needs artifact_result first).

    let artifact = artifact_result.map_err(|e| match e {
        forage_core::platform::PlatformError::NotFound(_) => error_page(
            &state,
            StatusCode::NOT_FOUND,
            "Not found",
            "This release could not be found.",
        ),
        other => {
            internal_error(&state, "failed to fetch artifact", &other)
        }
    })?;

    // Fetch artifact spec now that we have the artifact_id.
    let artifact_spec = state
        .platform_client
        .get_artifact_spec(&session.access_token, &artifact.artifact_id)
        .await
        .unwrap_or_default();

    let projects = warn_default("list_projects", projects);
    let dest_states = dest_states.unwrap_or_default();
    let release_intents = release_intents.unwrap_or_default();
    let project_has_pipeline = warn_default("list_release_pipelines", pipelines)
        .iter()
        .any(|pl| pl.enabled);

    // Filter destination states to this artifact.
    let matching_states: Vec<&forage_core::platform::DestinationState> = dest_states
        .destinations
        .iter()
        .filter(|ds| ds.artifact_id.as_deref() == Some(&artifact.artifact_id))
        .collect();

    // Compute summary status.
    let summary_status = compute_summary_status(&matching_states, || {
        release_intents
            .iter()
            .any(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
    });

    // Build pipeline stages from intent data.
    let mut pipeline_stages: Vec<minijinja::Value> = Vec::new();
    for ri in &release_intents {
        if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
            let sorted = topo_sort_run_stages(&ri.stages);
            for rs in sorted {
                let display_status = deploy_stage_display_status(rs, &matching_states);
                pipeline_stages.push(context! {
                    id => rs.stage_id,
                    stage_type => rs.stage_type,
                    environment => rs.environment,
                    duration_seconds => rs.duration_seconds,
                    status => display_status,
                    started_at => rs.started_at,
                    completed_at => rs.completed_at,
                    error_message => rs.error_message,
                    wait_until => rs.wait_until,
                });
            }
        }
    }

    let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;

    // Build env groups.
    let env_groups = build_env_groups(&matching_states);

    // Build destinations with status.
    let destinations: Vec<minijinja::Value> = matching_states
        .iter()
        .map(|ds| {
            context! {
                name => ds.destination_name,
                environment => ds.environment,
                status => ds.status,
                error_message => ds.error_message,
                queued_at => ds.queued_at,
                started_at => ds.started_at,
                completed_at => ds.completed_at,
                queue_position => ds.queue_position,
            }
        })
        .collect();

    let html = state
        .templates
        .render(
            "pages/artifact_detail.html.jinja",
            context! {
                title => format!("{} - {} - {} - Forage", artifact.context.title, project, org),
                description => artifact.context.description,
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                project_name => &project,
                projects => projects,
                active_tab => "project_releases",
                artifact => {
                    // Parse auto-generated description for fallback metadata.
                    let desc_meta = artifact.context.description.as_deref()
                        .filter(|d| d.starts_with("Branch:"))
                        .map(parse_description_metadata)
                        .unwrap_or_default();

                    let branch = artifact.git_ref.as_ref().and_then(|r| r.branch.clone())
                        .or_else(|| desc_meta.get("branch").cloned());
                    let source_type = artifact.source.as_ref().and_then(|s| s.source_type.clone())
                        .or_else(|| desc_meta.get("source").cloned());
                    let source_user = artifact.source.as_ref().and_then(|s| s.user.clone())
                        .or_else(|| desc_meta.get("author").cloned());

                    context! {
                        slug => artifact.slug,
                        title => artifact.context.title,
                        description => artifact.context.description,
                        web => artifact.context.web,
                        pr => artifact.context.pr,
                        created_at => artifact.created_at,
                        source_user => source_user,
                        source_email => artifact.source.as_ref().and_then(|s| s.email.clone()),
                        source_type => source_type,
                        run_url => artifact.source.as_ref().and_then(|s| s.run_url.clone()),
                        commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
                        branch => branch,
                        commit_message => artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()),
                        version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
                        repo_url => artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()),
                    }
                },
                summary_status => &summary_status,
                pipeline_stages => pipeline_stages,
                has_pipeline => has_pipeline,
                env_groups => env_groups,
                destinations => destinations,
                configured_destinations => artifact.destinations.iter().map(|d| {
                    context! { name => d.name, environment => d.environment }
                }).collect::<Vec<_>>(),
                has_release_intents => release_intents.iter().any(|ri| ri.artifact_id == artifact.artifact_id),
                artifact_spec => if artifact_spec.is_empty() { None::<String> } else { Some(artifact_spec) },
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

/// Compute summary status from destination states.
fn compute_summary_status<F: FnOnce() -> bool>(
    matching_states: &[&forage_core::platform::DestinationState],
    has_intent_stages: F,
) -> String {
    if matching_states.is_empty() {
        if has_intent_stages() {
            "QUEUED".to_string()
        } else {
            "PENDING".to_string()
        }
    } else {
        let statuses: Vec<&str> = matching_states
            .iter()
            .filter_map(|ds| ds.status.as_deref())
            .collect();
        if statuses.iter().any(|s| *s == "RUNNING" || *s == "ASSIGNED") {
            "RUNNING"
        } else if statuses.contains(&"QUEUED") {
            "QUEUED"
        } else if statuses.contains(&"FAILED") {
            "FAILED"
        } else if statuses.contains(&"TIMED_OUT") {
            "TIMED_OUT"
        } else if statuses.contains(&"CANCELLED") {
            "CANCELLED"
        } else if statuses.contains(&"SUCCEEDED") {
            "SUCCEEDED"
        } else {
            "PENDING"
        }
        .to_string()
    }
}

/// Destination-aware status override for deploy stages.
fn deploy_stage_display_status<'a>(
    rs: &'a forage_core::platform::PipelineRunStageState,
    matching_states: &[&forage_core::platform::DestinationState],
) -> &'a str {
    if rs.stage_type == "deploy" && (rs.status == "RUNNING" || rs.status == "ASSIGNED") {
        if let Some(ref env) = rs.environment {
            let env_dests: Vec<&str> = matching_states
                .iter()
                .filter(|ds| ds.environment == *env)
                .filter_map(|ds| ds.status.as_deref())
                .collect();
            if !env_dests.is_empty() && env_dests.iter().all(|s| *s == "QUEUED") {
                return "QUEUED";
            }
        }
    }
    &rs.status
}

/// Parse auto-generated description like "Branch: main. Source: github_actions. Author: tnielsen."
/// into a map of key-value pairs. Used as fallback when structured fields are empty.
fn parse_description_metadata(desc: &str) -> std::collections::HashMap<String, String> {
    let mut meta = std::collections::HashMap::new();
    for part in desc.split(". ") {
        let part = part.trim_end_matches('.');
        if let Some((key, val)) = part.split_once(": ") {
            meta.insert(key.to_lowercase(), val.to_string());
        }
    }
    meta
}

/// Build env groups for display (grouped by best status).
fn build_env_groups(
    matching_states: &[&forage_core::platform::DestinationState],
) -> Vec<minijinja::Value> {
    let mut env_best: std::collections::HashMap<&str, &str> =
        std::collections::HashMap::new();
    let mut unique_envs = Vec::new();
    let mut seen = std::collections::HashSet::new();
    for ds in matching_states {
        let status = ds.status.as_deref().unwrap_or("PENDING");
        let env = ds.environment.as_str();
        if seen.insert(env) {
            unique_envs.push(env);
        }
        let cur = env_best.get(env).copied().unwrap_or("PENDING");
        let pri = |s: &str| -> u8 {
            match s {
                "RUNNING" | "ASSIGNED" => 6,
                "QUEUED" => 5,
                "FAILED" => 4,
                "TIMED_OUT" => 3,
                "CANCELLED" => 2,
                "SUCCEEDED" => 1,
                _ => 0,
            }
        };
        if pri(status) > pri(cur) {
            env_best.insert(env, status);
        }
    }
    let status_order = [
        "RUNNING", "QUEUED", "FAILED", "TIMED_OUT", "CANCELLED", "SUCCEEDED",
    ];
    let mut env_groups = Vec::new();
    for &gs in &status_order {
        let envs_in: Vec<&str> = unique_envs
            .iter()
            .filter(|e| env_best.get(*e).copied() == Some(gs))
            .copied()
            .collect();
        if !envs_in.is_empty() {
            let ds = if gs == "ASSIGNED" { "RUNNING" } else { gs };
            env_groups.push(context! {
                status => ds,
                envs => envs_in,
            });
        }
    }
    env_groups
}

// ─── Usage ──────────────────────────────────────────────────────────

async fn usage(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org_data = require_org_membership(&state, orgs, &org)?;

    let projects = state
        .platform_client
        .list_projects(&session.access_token, &org)
        .await
        .map_err(|e| internal_error(&state, "list_projects", &e))?;

    let html = state
        .templates
        .render(
            "pages/usage.html.jinja",
            context! {
                title => format!("Usage - {org} - Forage"),
                description => format!("Usage and plan for {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                role => &current_org_data.role,
                project_count => projects.len(),
                active_tab => "usage",
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

// ─── Deploy release ────────────────────────────────────────────────

#[derive(Deserialize)]
struct DeployForm {
    _csrf: String,
    artifact_id: String,
    #[serde(default)]
    environment: Option<String>,
    #[serde(default)]
    use_pipeline: Option<String>,
}

async fn deploy_release(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
    Form(form): Form<DeployForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;
    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed.",
        ));
    }

    let use_pipeline = form.use_pipeline.as_deref() == Some("true");
    let environments: Vec<String> = form.environment.into_iter().collect();

    state
        .platform_client
        .release_artifact(
            &session.access_token,
            &form.artifact_id,
            &[],
            &environments,
            use_pipeline,
        )
        .await
        .map_err(|e| {
            internal_error(&state, "deploy failed", &e)
        })?;

    Ok(Redirect::to(&format!(
        "/orgs/{org}/projects/{project}/releases"
    ))
    .into_response())
}

// ─── User profile ──────────────────────────────────────────────────

async fn user_profile(
    State(state): State<AppState>,
    session: Session,
    Path(username): Path<String>,
) -> Result<Response, Response> {
    let profile = state
        .forest_client
        .get_user_by_username(&session.access_token, &username)
        .await
        .map_err(|e| {
{
                tracing::error!("get_user_by_username({username}): {e:#}");
                error_page(
                    &state,
                    StatusCode::NOT_FOUND,
                    "User not found",
                    &format!("No user named '{username}' was found."),
                )
            }
        })?;

    let orgs = &session.user.orgs;

    // Fetch contributions: collect artifacts created by this user across all orgs/projects.
    let profile_data = build_user_profile_data(&state, &session, orgs, &username).await;

    let html = state
        .templates
        .render(
            "pages/user_profile.html.jinja",
            context! {
                title => format!("{} - Forage", profile.username),
                description => format!("Profile for {}", profile.username),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => orgs.first().map(|o| &o.name),
                orgs => orgs_context(orgs),
                profile => context! {
                    username => profile.username,
                    user_id => profile.user_id,
                    created_at => profile.created_at,
                },
                heatmap => profile_data.heatmap,
                recent_releases => profile_data.recent_releases,
                contributed_projects => profile_data.contributed_projects,
                active_tab => "",
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

/// User profile data: heatmap, recent releases, and contributed projects.
struct UserProfileData {
    heatmap: minijinja::Value,
    recent_releases: Vec<minijinja::Value>,
    contributed_projects: Vec<minijinja::Value>,
}

/// Build user profile data: contribution heatmap, recent releases, and contributed projects.
async fn build_user_profile_data(
    state: &AppState,
    session: &Session,
    orgs: &[forage_core::session::CachedOrg],
    target_username: &str,
) -> UserProfileData {
    use std::collections::{HashMap, HashSet};

    let today = chrono::Utc::now().date_naive();
    let start = today - chrono::Duration::days(363);
    let start_weekday = start.weekday().num_days_from_sunday();
    let grid_start = start - chrono::Duration::days(start_weekday as i64);

    let mut day_counts: HashMap<chrono::NaiveDate, u32> = HashMap::new();
    let mut total_contributions = 0u32;

    // Collect recent releases (sorted by created_at desc, capped at 10).
    struct RecentRelease {
        org: String,
        project: String,
        slug: String,
        version: Option<String>,
        branch: Option<String>,
        commit_sha: Option<String>,
        created_at: String,
    }
    let mut all_releases: Vec<RecentRelease> = Vec::new();

    // Track unique org/project pairs for contributed projects.
    let mut project_set: HashSet<(String, String)> = HashSet::new();
    let mut project_release_counts: HashMap<(String, String), u32> = HashMap::new();

    // Fetch artifacts from all orgs/projects (best effort).
    for org in orgs {
        let projects = match state
            .platform_client
            .list_projects(&session.access_token, &org.name)
            .await
        {
            Ok(p) => p,
            Err(_) => continue,
        };
        for project in &projects {
            let artifacts = match state
                .platform_client
                .list_artifacts(&session.access_token, &org.name, project)
                .await
            {
                Ok(a) => a,
                Err(_) => continue,
            };
            for artifact in &artifacts {
                let is_match = artifact
                    .source
                    .as_ref()
                    .and_then(|s| s.user.as_deref())
                    .map(|u| u == target_username)
                    .unwrap_or(false);
                if !is_match {
                    continue;
                }

                // Track contributed project.
                let key = (org.name.clone(), project.clone());
                project_set.insert(key.clone());
                *project_release_counts.entry(key).or_default() += 1;

                // Collect for recent releases list.
                all_releases.push(RecentRelease {
                    org: org.name.clone(),
                    project: project.clone(),
                    slug: artifact.slug.clone(),
                    version: artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
                    branch: artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
                    commit_sha: artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
                    created_at: artifact.created_at.clone(),
                });

                // Heatmap day counts.
                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&artifact.created_at) {
                    let date = dt.date_naive();
                    if date >= grid_start && date <= today {
                        *day_counts.entry(date).or_default() += 1;
                        total_contributions += 1;
                    }
                }
            }
        }
    }

    // Sort releases by created_at descending, take top 10.
    all_releases.sort_by(|a, b| b.created_at.cmp(&a.created_at));
    all_releases.truncate(10);
    let recent_releases: Vec<minijinja::Value> = all_releases
        .into_iter()
        .map(|r| {
            context! {
                org => r.org,
                project => r.project,
                slug => r.slug,
                version => r.version,
                branch => r.branch,
                commit_sha => r.commit_sha.as_deref().map(|s| &s[..s.len().min(7)]),
                created_at => r.created_at,
            }
        })
        .collect();

    // Build contributed projects list sorted by release count descending.
    let mut contributed_projects: Vec<((String, String), u32)> = project_release_counts
        .into_iter()
        .collect();
    contributed_projects.sort_by(|a, b| b.1.cmp(&a.1));
    let contributed_projects: Vec<minijinja::Value> = contributed_projects
        .into_iter()
        .map(|((org, project), count)| {
            context! {
                org => org,
                project => project,
                release_count => count,
            }
        })
        .collect();

    // Build the grid: 53 columns (weeks) x 7 rows (days).
    // Use a power scale (sqrt) to map counts to 5 levels (0-4).
    let max_count = day_counts.values().copied().max().unwrap_or(0);

    let mut weeks: Vec<minijinja::Value> = Vec::new();
    let mut current = grid_start;
    let grid_end = grid_start + chrono::Duration::days(53 * 7 - 1);

    let mut week_days: Vec<minijinja::Value> = Vec::new();
    while current <= grid_end && current <= today {
        let count = day_counts.get(&current).copied().unwrap_or(0);
        let opacity = contribution_opacity(count, max_count);
        let in_range = current >= grid_start && current <= today;

        week_days.push(context! {
            date => current.format("%Y-%m-%d").to_string(),
            count => count,
            opacity => opacity,
            in_range => in_range,
        });

        // End of week (Saturday) — flush to weeks.
        if current.weekday() == chrono::Weekday::Sat || current == today {
            weeks.push(minijinja::Value::from_serialize(&week_days));
            week_days = Vec::new();
        }
        current += chrono::Duration::days(1);
    }
    if !week_days.is_empty() {
        weeks.push(minijinja::Value::from_serialize(&week_days));
    }

    // Month labels: first occurrence of each month in the grid.
    let mut month_labels: Vec<minijinja::Value> = Vec::new();
    let mut last_month = None;
    let mut col = 0usize;
    let mut d = grid_start;
    while d <= today {
        if d.weekday() == chrono::Weekday::Sun {
            let m = d.month();
            if last_month != Some(m) {
                last_month = Some(m);
                let label = d.format("%b").to_string();
                month_labels.push(context! { col => col, label => label });
            }
            col += 1;
        }
        d += chrono::Duration::days(1);
    }

    let heatmap = context! {
        weeks => weeks,
        month_labels => month_labels,
        total => total_contributions,
        max_count => max_count,
    };

    UserProfileData {
        heatmap,
        recent_releases,
        contributed_projects,
    }
}

/// Map a contribution count to an opacity 0.0–1.0 using a power scale (sqrt).
/// This gives a smooth gradient where low counts still have visible color
/// and high counts don't dwarf everything else.
fn contribution_opacity(count: u32, max_count: u32) -> String {
    if count == 0 || max_count == 0 {
        return "0".to_string();
    }
    // sqrt scale: brings low values up, compresses high values.
    // Min opacity 0.15 so even 1 contribution is visible.
    let ratio = (count as f64).sqrt() / (max_count as f64).sqrt();
    let opacity = 0.15 + ratio * 0.85;
    format!("{:.2}", opacity.clamp(0.15, 1.0))
}

// ─── Timeline builder (shared between dashboard, project detail, releases) ───

struct ArtifactWithProject {
    artifact: forage_core::platform::Artifact,
    project_name: String,
}

struct TimelineData {
    timeline: Vec<minijinja::Value>,
    lanes: Vec<minijinja::Value>,
}

/// Pipeline info indexed by project name, for overlaying onto releases.
type PipelinesByProject = std::collections::HashMap<String, Vec<forage_core::platform::ReleasePipeline>>;



/// Topologically sort pipeline run stage states by their `depends_on` edges.
fn topo_sort_run_stages(
    stages: &[forage_core::platform::PipelineRunStageState],
) -> Vec<&forage_core::platform::PipelineRunStageState> {
    use std::collections::{HashMap, VecDeque};

    let index_by_id: HashMap<&str, usize> = stages
        .iter()
        .enumerate()
        .map(|(i, s)| (s.stage_id.as_str(), i))
        .collect();

    let mut in_degree = vec![0u32; stages.len()];
    for (i, stage) in stages.iter().enumerate() {
        for dep in &stage.depends_on {
            if index_by_id.contains_key(dep.as_str()) {
                in_degree[i] += 1;
            }
        }
    }

    let mut dependents: HashMap<usize, Vec<usize>> = HashMap::new();
    for (i, stage) in stages.iter().enumerate() {
        for dep in &stage.depends_on {
            if let Some(&dep_idx) = index_by_id.get(dep.as_str()) {
                dependents.entry(dep_idx).or_default().push(i);
            }
        }
    }

    let mut queue: VecDeque<usize> = in_degree
        .iter()
        .enumerate()
        .filter(|(_, d)| **d == 0)
        .map(|(i, _)| i)
        .collect();

    let mut result = Vec::with_capacity(stages.len());
    while let Some(idx) = queue.pop_front() {
        result.push(&stages[idx]);
        if let Some(deps) = dependents.get(&idx) {
            for &dep_idx in deps {
                in_degree[dep_idx] -= 1;
                if in_degree[dep_idx] == 0 {
                    queue.push_back(dep_idx);
                }
            }
        }
    }

    if result.len() < stages.len() {
        let in_result: std::collections::HashSet<usize> =
            result.iter().map(|s| index_by_id[s.stage_id.as_str()]).collect();
        for (i, stage) in stages.iter().enumerate() {
            if !in_result.contains(&i) {
                result.push(stage);
            }
        }
    }

    result
}

fn build_timeline(
    items: Vec<ArtifactWithProject>,
    org_name: &str,
    environments: &[forage_core::platform::Environment],
    deployment_states: &forage_core::platform::DeploymentStates,
    release_intents: &[forage_core::platform::ReleaseIntentState],
    pipelines_by_project: &PipelinesByProject,
) -> TimelineData {
    // Index destination states by artifact_id for quick lookup.
    let mut states_by_artifact: std::collections::HashMap<
        &str,
        Vec<&forage_core::platform::DestinationState>,
    > = std::collections::HashMap::new();
    for ds in &deployment_states.destinations {
        if let Some(aid) = ds.artifact_id.as_deref() {
            states_by_artifact.entry(aid).or_default().push(ds);
        }
    }

    // Index release intent stages by artifact_id for quick lookup.
    let mut intent_stages_by_artifact: std::collections::HashMap<
        &str,
        &[forage_core::platform::PipelineRunStageState],
    > = std::collections::HashMap::new();
    for ri in release_intents {
        if !ri.stages.is_empty() {
            intent_stages_by_artifact.insert(ri.artifact_id.as_str(), &ri.stages);
        }
    }

    struct RawRelease {
        value: minijinja::Value,
        has_dests: bool,
    }

    let mut raw_releases: Vec<RawRelease> = Vec::new();

    for item in items {
        let artifact = item.artifact;
        let project = &item.project_name;

        // Look up deployment state from destination states instead of artifact.destinations.
        let matching_states = states_by_artifact
            .get(artifact.artifact_id.as_str())
            .cloned()
            .unwrap_or_default();

        let mut release_envs = Vec::new();
        let mut release_env_statuses = Vec::new();
        let dests: Vec<minijinja::Value> = matching_states
            .iter()
            .map(|ds| {
                release_envs.push(ds.environment.clone());
                let status_str = ds.status.as_deref().unwrap_or("PENDING");
                release_env_statuses.push(format!("{}:{}", ds.environment, status_str));
                context! {
                    name => ds.destination_name,
                    environment => ds.environment,
                    status => ds.status,
                    error_message => ds.error_message,
                    queued_at => ds.queued_at,
                    started_at => ds.started_at,
                    completed_at => ds.completed_at,
                    queue_position => ds.queue_position,
                }
            })
            .collect();

        let has_dests = !dests.is_empty();
        let dest_envs_str = release_env_statuses.join(",");
        let mut seen_envs = std::collections::HashSet::new();
        let unique_envs: Vec<String> = release_envs
            .iter()
            .filter(|e| seen_envs.insert(e.as_str()))
            .cloned()
            .collect();

        // Group environments by status for the summary line.
        // Each env gets its best (highest-priority) status.
        let mut env_best_status: std::collections::HashMap<&str, &str> =
            std::collections::HashMap::new();
        for ds in &matching_states {
            let status = ds.status.as_deref().unwrap_or("PENDING");
            let env = ds.environment.as_str();
            let current = env_best_status.get(env).copied().unwrap_or("PENDING");
            let priority = |s: &str| -> u8 {
                match s {
                    "RUNNING" | "ASSIGNED" => 6,
                    "QUEUED" => 5,
                    "FAILED" => 4,
                    "TIMED_OUT" => 3,
                    "CANCELLED" => 2,
                    "SUCCEEDED" => 1,
                    _ => 0,
                }
            };
            if priority(status) > priority(current) {
                env_best_status.insert(env, status);
            }
        }
        // Build groups sorted by priority (deploying first), then collect envs per group.
        let status_order = [
            "RUNNING", "QUEUED", "FAILED", "TIMED_OUT", "CANCELLED", "SUCCEEDED",
        ];
        let mut env_groups: Vec<minijinja::Value> = Vec::new();
        for &group_status in &status_order {
            let envs_in_group: Vec<String> = unique_envs
                .iter()
                .filter(|e| env_best_status.get(e.as_str()).copied() == Some(group_status))
                .cloned()
                .collect();
            if !envs_in_group.is_empty() {
                // Normalize ASSIGNED to RUNNING for display
                let display_status = if group_status == "ASSIGNED" {
                    "RUNNING"
                } else {
                    group_status
                };
                env_groups.push(context! {
                    status => display_status,
                    envs => envs_in_group,
                });
            }
        }

        // Build pipeline stage view from pipeline run data (if available) or
        // fall back to heuristic matching from destination states.
        let pipeline_stages: Vec<minijinja::Value> = {
            let mut stages = Vec::new();

            // First, check if the server returned pipeline run data for this artifact.
            if let Some(run_stages) = intent_stages_by_artifact.get(artifact.artifact_id.as_str()) {
                let sorted = topo_sort_run_stages(run_stages);
                for rs in sorted {
                    let wait_until_str = rs.wait_until.as_deref();
                    // For deploy stages the orchestrator may mark a stage as
                    // RUNNING before the actual destinations have started.
                    // Check destination states: if all destinations for this
                    // environment are still QUEUED, report the stage as QUEUED.
                    let display_status = if rs.stage_type == "deploy"
                        && (rs.status == "RUNNING" || rs.status == "ASSIGNED")
                    {
                        if let Some(ref env) = rs.environment {
                            let env_dests: Vec<&str> = matching_states
                                .iter()
                                .filter(|ds| ds.environment == *env)
                                .filter_map(|ds| ds.status.as_deref())
                                .collect();
                            if !env_dests.is_empty()
                                && env_dests.iter().all(|s| *s == "QUEUED")
                            {
                                "QUEUED"
                            } else {
                                &rs.status
                            }
                        } else {
                            &rs.status
                        }
                    } else {
                        &rs.status
                    };
                    stages.push(context! {
                        id => rs.stage_id,
                        stage_type => rs.stage_type,
                        environment => rs.environment,
                        duration_seconds => rs.duration_seconds,
                        depends_on => rs.depends_on,
                        status => display_status,
                        started_at => rs.started_at,
                        completed_at => rs.completed_at,
                        error_message => rs.error_message,
                        wait_until => wait_until_str,
                    });
                }
            }
            // No heuristic fallback: if there is no pipeline run data for
            // this artifact we leave pipeline_stages empty.  The frontend
            // uses env_groups to decide between "Deployed" and "Queued".
            stages
        };
        // A release "has a pipeline" if we have stage data from the server,
        // OR if the project has an enabled pipeline config (for not-yet-deployed releases).
        let project_has_enabled_pipeline = pipelines_by_project
            .get(project)
            .map(|ps| ps.iter().any(|p| p.enabled))
            .unwrap_or(false);
        let has_pipeline = !pipeline_stages.is_empty() || project_has_enabled_pipeline;

        // Compute summary status from individual destination statuses.
        // Priority: RUNNING/ASSIGNED > QUEUED > FAILED/TIMED_OUT/CANCELLED > SUCCEEDED
        let summary_status = if !has_dests {
            "PENDING"
        } else {
            let statuses: Vec<&str> = matching_states
                .iter()
                .filter_map(|ds| ds.status.as_deref())
                .collect();
            if statuses.iter().any(|s| *s == "RUNNING" || *s == "ASSIGNED") {
                "RUNNING"
            } else if statuses.contains(&"QUEUED") {
                "QUEUED"
            } else if statuses.contains(&"FAILED") {
                "FAILED"
            } else if statuses.contains(&"TIMED_OUT") {
                "TIMED_OUT"
            } else if statuses.contains(&"CANCELLED") {
                "CANCELLED"
            } else if statuses.contains(&"SUCCEEDED") {
                "SUCCEEDED"
            } else {
                "PENDING"
            }
        };

        raw_releases.push(RawRelease {
            value: context! {
                artifact_id => artifact.artifact_id,
                slug => artifact.slug,
                title => artifact.context.title,
                description => artifact.context.description,
                web => artifact.context.web,
                pr => artifact.context.pr,
                project_name => project,
                org_name => org_name,
                created_at => artifact.created_at,
                commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
                branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
                version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
                commit_message => artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()),
                repo_url => artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()),
                source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
                source_email => artifact.source.as_ref().and_then(|s| s.email.clone()),
                source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
                run_url => artifact.source.as_ref().and_then(|s| s.run_url.clone()),
                destinations => dests,
                dest_envs => dest_envs_str,
                unique_envs => unique_envs,
                env_groups => env_groups,
                summary_status => summary_status,
                pipeline_stages => pipeline_stages,
                has_pipeline => has_pipeline,
            },
            has_dests,
        });
    }

    // Use environments from the API (sorted by sort_order), falling back to
    // environments discovered from destination states.
    let lanes: Vec<minijinja::Value> = if !environments.is_empty() {
        let mut envs: Vec<_> = environments.to_vec();
        envs.sort_by_key(|e| e.sort_order);
        envs.iter()
            .map(|env| {
                context! {
                    name => env.name,
                    description => env.description,
                    color => env_lane_color(&env.name),
                }
            })
            .collect()
    } else {
        let mut env_set = std::collections::BTreeSet::new();
        for ds in &deployment_states.destinations {
            if !ds.environment.is_empty() {
                env_set.insert(ds.environment.clone());
            }
        }
        env_set
            .into_iter()
            .map(|env| {
                let color = env_lane_color(&env);
                context! { name => env, color => color }
            })
            .collect()
    };

    // Truncate: keep everything up to the last deployed release, plus 3
    // older items for context.
    let last_deployed_idx = raw_releases
        .iter()
        .rposition(|r| r.has_dests)
        .map(|i| i + 1)
        .unwrap_or(0);
    let keep = last_deployed_idx + 3;
    if keep < raw_releases.len() {
        raw_releases.truncate(keep);
    }

    let mut timeline_items: Vec<minijinja::Value> = Vec::new();
    let mut hidden_buf: Vec<minijinja::Value> = Vec::new();
    let mut seen_deployed = false;

    for raw in raw_releases {
        if raw.has_dests {
            // Flush any hidden buffer before a deployed release
            if !hidden_buf.is_empty() {
                let count = hidden_buf.len();
                timeline_items.push(context! {
                    kind => "hidden",
                    count => count,
                    releases => std::mem::take(&mut hidden_buf),
                });
            }
            seen_deployed = true;
            timeline_items.push(context! {
                kind => "release",
                release => raw.value,
            });
        } else if !seen_deployed {
            // Before any deployment: show as regular (pending) release
            timeline_items.push(context! {
                kind => "release",
                release => raw.value,
            });
        } else {
            // After a deployment: group as hidden
            hidden_buf.push(raw.value);
        }
    }
    if !hidden_buf.is_empty() {
        let count = hidden_buf.len();
        timeline_items.push(context! {
            kind => "hidden",
            count => count,
            releases => std::mem::take(&mut hidden_buf),
        });
    }

    TimelineData {
        timeline: timeline_items,
        lanes,
    }
}

// ─── Serialisable API types (for the JSON timeline endpoint) ─────────

#[derive(Debug, Serialize)]
pub struct ApiTimelineResponse {
    pub timeline: Vec<ApiTimelineItem>,
    pub lanes: Vec<ApiLane>,
}

#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ApiTimelineItem {
    Release { release: Box<ApiRelease> },
    Hidden { count: usize, releases: Vec<ApiRelease> },
}

#[derive(Debug, Serialize)]
pub struct ApiRelease {
    pub artifact_id: String,
    pub slug: String,
    pub title: String,
    pub description: Option<String>,
    pub web: Option<String>,
    pub pr: Option<String>,
    pub project_name: String,
    pub created_at: String,
    pub commit_sha: Option<String>,
    pub branch: Option<String>,
    pub version: Option<String>,
    pub commit_message: Option<String>,
    pub repo_url: Option<String>,
    pub source_user: Option<String>,
    pub source_type: Option<String>,
    pub run_url: Option<String>,
    pub summary_status: String,
    pub has_pipeline: bool,
    pub dest_envs: String,
    pub destinations: Vec<ApiDestinationState>,
    pub env_groups: Vec<ApiEnvGroup>,
    pub pipeline_stages: Vec<ApiPipelineStage>,
}

#[derive(Debug, Serialize)]
pub struct ApiDestinationState {
    pub name: String,
    pub environment: String,
    pub status: Option<String>,
    pub error_message: Option<String>,
    pub queued_at: Option<String>,
    pub started_at: Option<String>,
    pub completed_at: Option<String>,
    pub queue_position: Option<i32>,
}

#[derive(Debug, Serialize)]
pub struct ApiEnvGroup {
    pub status: String,
    pub envs: Vec<String>,
}

#[derive(Debug, Serialize)]
pub struct ApiPipelineStage {
    pub id: String,
    pub stage_type: String,
    pub environment: Option<String>,
    pub duration_seconds: Option<i64>,
    pub status: String,
    pub started_at: Option<String>,
    pub completed_at: Option<String>,
    pub error_message: Option<String>,
    pub wait_until: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct ApiLane {
    pub name: String,
    pub color: String,
    pub description: Option<String>,
}

/// Build a serialisable timeline from the same inputs as `build_timeline`.
/// The logic is kept intentionally parallel so both renderers stay in sync.
fn build_timeline_json(
    items: Vec<ArtifactWithProject>,
    environments: &[forage_core::platform::Environment],
    deployment_states: &forage_core::platform::DeploymentStates,
    release_intents: &[forage_core::platform::ReleaseIntentState],
    pipelines_by_project: &PipelinesByProject,
) -> ApiTimelineResponse {
    // Index destination states by artifact_id.
    let mut states_by_artifact: std::collections::HashMap<
        &str,
        Vec<&forage_core::platform::DestinationState>,
    > = std::collections::HashMap::new();
    for ds in &deployment_states.destinations {
        if let Some(aid) = ds.artifact_id.as_deref() {
            states_by_artifact.entry(aid).or_default().push(ds);
        }
    }

    // Index pipeline run stages by artifact_id.
    let mut intent_stages_by_artifact: std::collections::HashMap<
        &str,
        &[forage_core::platform::PipelineRunStageState],
    > = std::collections::HashMap::new();
    for ri in release_intents {
        if !ri.stages.is_empty() {
            intent_stages_by_artifact.insert(ri.artifact_id.as_str(), &ri.stages);
        }
    }

    struct RawRelease {
        release: ApiRelease,
        has_dests: bool,
    }

    let priority = |s: &str| -> u8 {
        match s {
            "RUNNING" | "ASSIGNED" => 6,
            "QUEUED" => 5,
            "FAILED" => 4,
            "TIMED_OUT" => 3,
            "CANCELLED" => 2,
            "SUCCEEDED" => 1,
            _ => 0,
        }
    };

    let mut raw_releases: Vec<RawRelease> = Vec::new();

    for item in items {
        let artifact = item.artifact;
        let project = item.project_name;

        let matching_states = states_by_artifact
            .get(artifact.artifact_id.as_str())
            .cloned()
            .unwrap_or_default();

        let mut release_envs: Vec<String> = Vec::new();
        let mut release_env_statuses: Vec<String> = Vec::new();
        let destinations: Vec<ApiDestinationState> = matching_states
            .iter()
            .map(|ds| {
                release_envs.push(ds.environment.clone());
                let status_str = ds.status.as_deref().unwrap_or("PENDING");
                release_env_statuses.push(format!("{}:{}", ds.environment, status_str));
                ApiDestinationState {
                    name: ds.destination_name.clone(),
                    environment: ds.environment.clone(),
                    status: ds.status.clone(),
                    error_message: ds.error_message.clone(),
                    queued_at: ds.queued_at.clone(),
                    started_at: ds.started_at.clone(),
                    completed_at: ds.completed_at.clone(),
                    queue_position: ds.queue_position,
                }
            })
            .collect();

        let has_dests = !destinations.is_empty();
        let dest_envs = release_env_statuses.join(",");

        let mut seen_envs = std::collections::HashSet::new();
        let unique_envs: Vec<String> = release_envs
            .iter()
            .filter(|e| seen_envs.insert(e.as_str()))
            .cloned()
            .collect();

        // Per-environment best status for grouping.
        let mut env_best_status: std::collections::HashMap<&str, &str> =
            std::collections::HashMap::new();
        for ds in &matching_states {
            let status = ds.status.as_deref().unwrap_or("PENDING");
            let env = ds.environment.as_str();
            let current = env_best_status.get(env).copied().unwrap_or("PENDING");
            if priority(status) > priority(current) {
                env_best_status.insert(env, status);
            }
        }

        let status_order = [
            "RUNNING", "QUEUED", "FAILED", "TIMED_OUT", "CANCELLED", "SUCCEEDED",
        ];
        let env_groups: Vec<ApiEnvGroup> = status_order
            .iter()
            .filter_map(|&group_status| {
                let envs_in_group: Vec<String> = unique_envs
                    .iter()
                    .filter(|e| env_best_status.get(e.as_str()).copied() == Some(group_status))
                    .cloned()
                    .collect();
                if envs_in_group.is_empty() {
                    return None;
                }
                let display_status = if group_status == "ASSIGNED" {
                    "RUNNING"
                } else {
                    group_status
                };
                Some(ApiEnvGroup {
                    status: display_status.to_string(),
                    envs: envs_in_group,
                })
            })
            .collect();

        // Build pipeline stages — same logic as build_timeline.
        let pipeline_stages: Vec<ApiPipelineStage> = {
            let mut stages = Vec::new();

            if let Some(run_stages) = intent_stages_by_artifact.get(artifact.artifact_id.as_str()) {
                let sorted = topo_sort_run_stages(run_stages);
                for rs in sorted {
                    // Same destination-aware override as build_timeline.
                    let display_status = if rs.stage_type == "deploy"
                        && (rs.status == "RUNNING" || rs.status == "ASSIGNED")
                    {
                        if let Some(ref env) = rs.environment {
                            let env_dests: Vec<&str> = matching_states
                                .iter()
                                .filter(|ds| ds.environment == *env)
                                .filter_map(|ds| ds.status.as_deref())
                                .collect();
                            if !env_dests.is_empty()
                                && env_dests.iter().all(|s| *s == "QUEUED")
                            {
                                "QUEUED".to_string()
                            } else if rs.status == "ASSIGNED" {
                                "RUNNING".to_string()
                            } else {
                                rs.status.clone()
                            }
                        } else if rs.status == "ASSIGNED" {
                            "RUNNING".to_string()
                        } else {
                            rs.status.clone()
                        }
                    } else if rs.status == "ASSIGNED" {
                        "RUNNING".to_string()
                    } else {
                        rs.status.clone()
                    };
                    stages.push(ApiPipelineStage {
                        id: rs.stage_id.clone(),
                        stage_type: rs.stage_type.clone(),
                        environment: rs.environment.clone(),
                        duration_seconds: rs.duration_seconds,
                        status: display_status,
                        started_at: rs.started_at.clone(),
                        completed_at: rs.completed_at.clone(),
                        error_message: rs.error_message.clone(),
                        wait_until: rs.wait_until.clone(),
                    });
                }
            }
            // No heuristic fallback — same rationale as build_timeline.
            stages
        };

        let project_has_enabled_pipeline = pipelines_by_project
            .get(&project)
            .map(|ps| ps.iter().any(|p| p.enabled))
            .unwrap_or(false);
        let has_pipeline = !pipeline_stages.is_empty() || project_has_enabled_pipeline;

        let summary_status = if !has_dests {
            "PENDING"
        } else {
            let statuses: Vec<&str> = matching_states
                .iter()
                .filter_map(|ds| ds.status.as_deref())
                .collect();
            if statuses.iter().any(|s| *s == "RUNNING" || *s == "ASSIGNED") {
                "RUNNING"
            } else if statuses.contains(&"QUEUED") {
                "QUEUED"
            } else if statuses.contains(&"FAILED") {
                "FAILED"
            } else if statuses.contains(&"TIMED_OUT") {
                "TIMED_OUT"
            } else if statuses.contains(&"CANCELLED") {
                "CANCELLED"
            } else if statuses.contains(&"SUCCEEDED") {
                "SUCCEEDED"
            } else {
                "PENDING"
            }
        };

        raw_releases.push(RawRelease {
            release: ApiRelease {
                artifact_id: artifact.artifact_id,
                slug: artifact.slug,
                title: artifact.context.title,
                description: artifact.context.description,
                web: artifact.context.web,
                pr: artifact.context.pr,
                project_name: project,
                created_at: artifact.created_at,
                commit_sha: artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
                branch: artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
                version: artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
                commit_message: artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()),
                repo_url: artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()),
                source_user: artifact.source.as_ref().and_then(|s| s.user.clone()),
                source_type: artifact.source.as_ref().and_then(|s| s.source_type.clone()),
                run_url: artifact.source.as_ref().and_then(|s| s.run_url.clone()),
                summary_status: summary_status.to_string(),
                has_pipeline,
                dest_envs,
                destinations,
                env_groups,
                pipeline_stages,
            },
            has_dests,
        });
    }

    // Build lanes — same logic as build_timeline.
    let lanes: Vec<ApiLane> = if !environments.is_empty() {
        let mut envs = environments.to_vec();
        envs.sort_by_key(|e| e.sort_order);
        envs.iter()
            .map(|env| ApiLane {
                name: env.name.clone(),
                color: env_lane_color(&env.name).to_string(),
                description: env.description.clone(),
            })
            .collect()
    } else {
        let mut env_set = std::collections::BTreeSet::new();
        for ds in &deployment_states.destinations {
            if !ds.environment.is_empty() {
                env_set.insert(ds.environment.clone());
            }
        }
        env_set
            .into_iter()
            .map(|env| ApiLane {
                color: env_lane_color(&env).to_string(),
                name: env,
                description: None,
            })
            .collect()
    };

    // Truncate: keep up to last deployed + 3.
    let last_deployed_idx = raw_releases
        .iter()
        .rposition(|r| r.has_dests)
        .map(|i| i + 1)
        .unwrap_or(0);
    let keep = last_deployed_idx + 3;
    if keep < raw_releases.len() {
        raw_releases.truncate(keep);
    }

    let mut timeline: Vec<ApiTimelineItem> = Vec::new();
    let mut hidden_buf: Vec<ApiRelease> = Vec::new();
    let mut seen_deployed = false;

    for raw in raw_releases {
        if raw.has_dests {
            if !hidden_buf.is_empty() {
                let count = hidden_buf.len();
                timeline.push(ApiTimelineItem::Hidden {
                    count,
                    releases: std::mem::take(&mut hidden_buf),
                });
            }
            seen_deployed = true;
            timeline.push(ApiTimelineItem::Release {
                release: Box::new(raw.release),
            });
        } else if !seen_deployed {
            timeline.push(ApiTimelineItem::Release {
                release: Box::new(raw.release),
            });
        } else {
            hidden_buf.push(raw.release);
        }
    }
    if !hidden_buf.is_empty() {
        let count = hidden_buf.len();
        timeline.push(ApiTimelineItem::Hidden {
            count,
            releases: std::mem::take(&mut hidden_buf),
        });
    }

    ApiTimelineResponse { timeline, lanes }
}

// ─── GET /api/orgs/{org}/projects/{project}/timeline ─────────────────

async fn timeline_api(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({ "error": "invalid project name" })),
        )
            .into_response());
    }

    let (artifacts, environments, dest_states, release_intents, project_pipelines) = tokio::join!(
        state
            .platform_client
            .list_artifacts(&session.access_token, &org, &project),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .get_destination_states(&session.access_token, &org, Some(&project)),
        state
            .platform_client
            .get_release_intent_states(&session.access_token, &org, Some(&project), true),
        state
            .platform_client
            .list_release_pipelines(&session.access_token, &org, &project),
    );
    let artifacts = artifacts.map_err(|e| {
        tracing::error!("timeline_api list_artifacts: {e:#}");
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": "failed to fetch artifacts" })),
        )
            .into_response()
    })?;
    let environments = warn_default("list_environments", environments);
    let dest_states = warn_default("get_destination_states", dest_states);
    let release_intents = warn_default("get_release_intent_states", release_intents);
    let project_pipelines = warn_default("list_release_pipelines", project_pipelines);

    let items: Vec<ArtifactWithProject> = artifacts
        .into_iter()
        .map(|a| ArtifactWithProject {
            artifact: a,
            project_name: project.clone(),
        })
        .collect();

    let mut pipelines_map = PipelinesByProject::new();
    if !project_pipelines.is_empty() {
        pipelines_map.insert(project.clone(), project_pipelines);
    }

    let data = build_timeline_json(items, &environments, &dest_states, &release_intents, &pipelines_map);

    Ok(Json(data).into_response())
}

// ─── GET /api/orgs/{org}/timeline ────────────────────────────────────

async fn org_timeline_api(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    let (projects, environments, dest_states, release_intents) = tokio::join!(
        state
            .platform_client
            .list_projects(&session.access_token, &org),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .get_destination_states(&session.access_token, &org, None),
        state
            .platform_client
            .get_release_intent_states(&session.access_token, &org, None, true),
    );
    let projects = projects.map_err(|e| {
        tracing::error!("org_timeline_api list_projects: {e:#}");
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": "failed to fetch projects" })),
        )
            .into_response()
    })?;
    let environments = warn_default("list_environments", environments);
    let dest_states = warn_default("get_destination_states", dest_states);
    let release_intents = warn_default("get_release_intent_states", release_intents);

    let mut pipelines_by_project = PipelinesByProject::new();
    for p in &projects {
        let pipelines = warn_default(
            "list_release_pipelines",
            state
                .platform_client
                .list_release_pipelines(&session.access_token, &org, p)
                .await,
        );
        if !pipelines.is_empty() {
            pipelines_by_project.insert(p.clone(), pipelines);
        }
    }

    let items = fetch_org_artifacts(&state, &session.access_token, &org, &projects).await;
    let data = build_timeline_json(
        items,
        &environments,
        &dest_states,
        &release_intents,
        &pipelines_by_project,
    );

    Ok(Json(data).into_response())
}

/// Fetch all artifacts across projects and return as ArtifactWithProject list.
async fn fetch_org_artifacts(
    state: &AppState,
    access_token: &str,
    org: &str,
    projects: &[String],
) -> Vec<ArtifactWithProject> {
    let mut items = Vec::new();
    for project in projects {
        let artifacts = warn_default(
            &format!("list_artifacts({project})"),
            state.platform_client.list_artifacts(access_token, org, project).await,
        );
        for artifact in artifacts {
            items.push(ArtifactWithProject {
                artifact,
                project_name: project.clone(),
            });
        }
    }
    items
}

// ─── Releases (Up-inspired pipeline) ─────────────────────────────────

async fn releases_page(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    require_org_membership(&state, orgs, &org)?;

    let (projects, environments, dest_states, release_intents) = tokio::join!(
        state
            .platform_client
            .list_projects(&session.access_token, &org),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .get_destination_states(&session.access_token, &org, None),
        state
            .platform_client
            .get_release_intent_states(&session.access_token, &org, None, true),
    );
    let projects = projects.map_err(|e| internal_error(&state, "list_projects", &e))?;
    let environments = warn_default("list_environments", environments);
    let dest_states = warn_default("get_destination_states", dest_states);
    let release_intents = warn_default("get_release_intent_states", release_intents);

    // Fetch pipelines for all projects.
    let mut pipelines_by_project = PipelinesByProject::new();
    for p in &projects {
        let pipelines = warn_default(
            "list_release_pipelines",
            state
                .platform_client
                .list_release_pipelines(&session.access_token, &org, p)
                .await,
        );
        if !pipelines.is_empty() {
            pipelines_by_project.insert(p.clone(), pipelines);
        }
    }

    let items = fetch_org_artifacts(&state, &session.access_token, &org, &projects).await;
    let data = build_timeline(items, &org, &environments, &dest_states, &release_intents, &pipelines_by_project);

    let mut sorted_envs = environments.clone();
    sorted_envs.sort_by_key(|e| e.sort_order);
    let env_options: Vec<minijinja::Value> = sorted_envs
        .iter()
        .map(|e| context! { name => e.name })
        .collect();

    let html = state
        .templates
        .render(
            "pages/releases.html.jinja",
            context! {
                title => format!("Releases - {org} - Forage"),
                description => format!("Deployment pipeline for {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                timeline => data.timeline,
                lanes => data.lanes,
                env_options => env_options,
                active_tab => "releases",
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

// ─── Destinations ────────────────────────────────────────────────────

async fn destinations_page(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    let is_admin = current_org.role == "owner" || current_org.role == "admin";

    let (environments, org_destinations, projects) = tokio::join!(
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .list_destinations(&session.access_token, &org),
        state
            .platform_client
            .list_projects(&session.access_token, &org),
    );
    let mut environments = environments.map_err(|e| internal_error(&state, "list_environments", &e))?;
    environments.sort_by_key(|e| e.sort_order);
    let org_destinations = org_destinations.map_err(|e| internal_error(&state, "list_destinations", &e))?;
    let projects = warn_default("list_projects", projects);

    let env_list: Vec<minijinja::Value> = environments
        .iter()
        .map(|e| {
            let env_dests: Vec<minijinja::Value> = org_destinations
                .iter()
                .filter(|d| d.environment == e.name)
                .map(|d| {
                    let meta_entries: Vec<minijinja::Value> = d
                        .metadata
                        .iter()
                        .map(|(k, v)| context! { key => k, value => v })
                        .collect();
                    context! {
                        name => d.name,
                        environment => d.environment,
                        type_name => d.dest_type.as_ref().map(|t| t.name.clone()),
                        type_organisation => d.dest_type.as_ref().map(|t| t.organisation.clone()),
                        type_version => d.dest_type.as_ref().map(|t| t.version),
                        metadata => meta_entries,
                    }
                })
                .collect();
            context! {
                id => e.id,
                name => e.name,
                description => e.description,
                sort_order => e.sort_order,
                destinations => env_dests,
            }
        })
        .collect();

    // Also collect destinations not associated with any known environment
    let known_envs: std::collections::HashSet<&str> =
        environments.iter().map(|e| e.name.as_str()).collect();
    let orphan_dests: Vec<minijinja::Value> = org_destinations
        .iter()
        .filter(|d| !known_envs.contains(d.environment.as_str()))
        .map(|d| {
            let meta_entries: Vec<minijinja::Value> = d
                .metadata
                .iter()
                .map(|(k, v)| context! { key => k, value => v })
                .collect();
            context! {
                name => d.name,
                environment => d.environment,
                type_name => d.dest_type.as_ref().map(|t| t.name.clone()),
                type_organisation => d.dest_type.as_ref().map(|t| t.organisation.clone()),
                type_version => d.dest_type.as_ref().map(|t| t.version),
                metadata => meta_entries,
            }
        })
        .collect();

    let html = state
        .templates
        .render(
            "pages/destinations.html.jinja",
            context! {
                title => format!("Destinations - {org} - Forage"),
                description => format!("Deployment destinations for {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                environments => env_list,
                orphan_destinations => orphan_dests,
                projects => projects,
                is_admin => is_admin,
                active_tab => "destinations",
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct CreateEnvironmentForm {
    _csrf: String,
    name: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    sort_order: i32,
}

async fn create_environment_submit(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
    Form(form): Form<CreateEnvironmentForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;
    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
    }

    if !validate_slug(&form.name) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid environment name",
            "Environment names must be lowercase alphanumeric with hyphens, max 64 chars.",
        ));
    }

    let description = if form.description.is_empty() {
        None
    } else {
        Some(form.description.as_str())
    };

    state
        .platform_client
        .create_environment(
            &session.access_token,
            &org,
            &form.name,
            description,
            form.sort_order,
        )
        .await
        .map_err(|e| {
            internal_error(&state, "create environment error", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/destinations")).into_response())
}

#[derive(Deserialize)]
struct CreateDestinationForm {
    _csrf: String,
    name: String,
    environment: String,
    #[serde(default)]
    type_organisation: String,
    #[serde(default)]
    type_name: String,
    #[serde(default)]
    type_version: Option<u64>,
    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
    metadata_keys: Vec<String>,
    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
    metadata_values: Vec<String>,
}

/// HTML forms send a single value as a string, multiple values as a sequence.
/// This deserializer handles both cases.
fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de;

    struct StringOrVec;

    impl<'de> de::Visitor<'de> for StringOrVec {
        type Value = Vec<String>;

        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
            f.write_str("a string or sequence of strings")
        }

        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
            Ok(vec![v.to_string()])
        }

        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
            Ok(vec![v])
        }

        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
            let mut values = Vec::new();
            while let Some(v) = seq.next_element::<String>()? {
                values.push(v);
            }
            Ok(values)
        }
    }

    deserializer.deserialize_any(StringOrVec)
}

fn parse_metadata(keys: &[String], values: &[String]) -> std::collections::HashMap<String, String> {
    keys.iter()
        .zip(values.iter())
        .filter(|(k, _)| !k.trim().is_empty())
        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
        .collect()
}

async fn create_destination_submit(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
    Form(form): Form<CreateDestinationForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;
    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid CSRF token",
            "Please try again.",
        ));
    }

    if form.name.is_empty() || form.environment.is_empty() {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Destination name and environment are required.",
        ));
    }

    let metadata = parse_metadata(&form.metadata_keys, &form.metadata_values);
    let dest_type = if !form.type_name.trim().is_empty() {
        Some(forage_core::platform::DestinationType {
            organisation: if form.type_organisation.trim().is_empty() {
                org.clone()
            } else {
                form.type_organisation.trim().to_string()
            },
            name: form.type_name.trim().to_string(),
            version: form.type_version.unwrap_or(1),
        })
    } else {
        None
    };

    state
        .platform_client
        .create_destination(
            &session.access_token,
            &org,
            &form.name,
            &form.environment,
            &metadata,
            dest_type.as_ref(),
        )
        .await
        .map_err(|e| {
            internal_error(&state, "create destination error", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/destinations")).into_response())
}

#[derive(Deserialize)]
struct DestinationQuery {
    name: String,
}

async fn destination_detail(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
    Query(query): Query<DestinationQuery>,
) -> Result<Response, Response> {
    let dest_name = &query.name;
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    let is_admin = current_org.role == "owner" || current_org.role == "admin";

    let destinations = state
        .platform_client
        .list_destinations(&session.access_token, &org)
        .await
        .map_err(|e| internal_error(&state, "list_destinations", &e))?;

    let dest = destinations
        .iter()
        .find(|d| d.name == *dest_name)
        .ok_or_else(|| {
            error_page(
                &state,
                StatusCode::NOT_FOUND,
                "Destination not found",
                &format!("No destination named '{dest_name}' was found."),
            )
        })?;

    let meta_entries: Vec<minijinja::Value> = dest
        .metadata
        .iter()
        .map(|(k, v)| context! { key => k, value => v })
        .collect();

    let html = state
        .templates
        .render(
            "pages/destination_detail.html.jinja",
            context! {
                title => format!("{} - Destinations - {} - Forage", dest_name, org),
                description => format!("Destination {} in {}", dest_name, org),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                is_admin => is_admin,
                active_tab => "destinations",
                dest_name => &dest.name,
                dest_environment => &dest.environment,
                dest_type_name => dest.dest_type.as_ref().map(|t| t.name.clone()),
                dest_type_organisation => dest.dest_type.as_ref().map(|t| t.organisation.clone()),
                dest_type_version => dest.dest_type.as_ref().map(|t| t.version),
                metadata => meta_entries,
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct UpdateDestinationForm {
    _csrf: String,
    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
    metadata_keys: Vec<String>,
    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
    metadata_values: Vec<String>,
}

async fn update_destination_submit(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
    Query(query): Query<DestinationQuery>,
    Form(form): Form<UpdateDestinationForm>,
) -> Result<Response, Response> {
    let dest_name = &query.name;
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;
    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid CSRF token",
            "Please try again.",
        ));
    }

    let metadata = parse_metadata(&form.metadata_keys, &form.metadata_values);

    state
        .platform_client
        .update_destination(&session.access_token, dest_name, &metadata)
        .await
        .map_err(|e| {
            internal_error(&state, "update destination error", &e)
        })?;

    let encoded_name = urlencoding::encode(dest_name);
    Ok(
        Redirect::to(&format!(
            "/orgs/{org}/destinations/detail?name={encoded_name}"
        ))
        .into_response(),
    )
}

// ─── Members ────────────────────────────────────────────────────────

async fn members_page(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;

    let members = state
        .platform_client
        .list_members(&session.access_token, &current_org.organisation_id)
        .await
        .map_err(|e| internal_error(&state, "list_members", &e))?;

    let is_admin = current_org.role == "owner" || current_org.role == "admin";

    let html = state
        .templates
        .render(
            "pages/members.html.jinja",
            context! {
                title => format!("Members - {org} - Forage"),
                description => format!("Members of {org}"),
                user => context! { username => session.user.username },
                csrf_token => &session.csrf_token,
                current_org => &org,
                orgs => orgs_context(orgs),
                org_name => &org,
                is_admin => is_admin,
                active_tab => "members",
                members => members.iter().map(|m| context! {
                    user_id => m.user_id,
                    username => m.username,
                    role => m.role,
                    joined_at => m.joined_at,
                }).collect::<Vec<_>>(),
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct AddMemberForm {
    username: String,
    role: String,
    _csrf: String,
}

async fn add_member_submit(
    State(state): State<AppState>,
    session: Session,
    Path(org): Path<String>,
    Form(form): Form<AddMemberForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let _ = state
        .platform_client
        .add_member(
            &session.access_token,
            &current_org.organisation_id,
            &form.username,
            &form.role,
        )
        .await
        .map_err(|e| {
            internal_error(&state, "failed to add member", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}

#[derive(Deserialize)]
struct UpdateRoleForm {
    role: String,
    _csrf: String,
}

async fn update_member_role_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, user_id)): Path<(String, String)>,
    Form(form): Form<UpdateRoleForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let _ = state
        .platform_client
        .update_member_role(
            &session.access_token,
            &current_org.organisation_id,
            &user_id,
            &form.role,
        )
        .await
        .map_err(|e| {
            internal_error(&state, "failed to update member role", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}

#[derive(Deserialize)]
struct CsrfForm {
    _csrf: String,
}

async fn remove_member_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, user_id)): Path<(String, String)>,
    Form(form): Form<CsrfForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    state
        .platform_client
        .remove_member(
            &session.access_token,
            &current_org.organisation_id,
            &user_id,
        )
        .await
        .map_err(|e| {
            internal_error(&state, "failed to remove member", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}

// ─── Auto-Release Policies ──────────────────────────────────────────

// ─── Triggers (auto-release triggers) ───────────────────────────────

async fn triggers_page(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (triggers, environments, destinations, pipelines) = tokio::join!(
        state
            .platform_client
            .list_triggers(&session.access_token, &org, &project),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .list_destinations(&session.access_token, &org),
        state
            .platform_client
            .list_release_pipelines(&session.access_token, &org, &project),
    );
    let triggers = triggers.map_err(|e| internal_error(&state, "list_triggers", &e))?;
    let environments = warn_default("list_environments", environments);
    let destinations = warn_default("list_destinations", destinations);
    let pipelines = warn_default("list_release_pipelines", pipelines);

    let is_admin = current_org.role == "owner" || current_org.role == "admin";

    let trigger_items: Vec<minijinja::Value> = triggers
        .iter()
        .map(|t| {
            context! {
                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,
            }
        })
        .collect();

    let env_options: Vec<minijinja::Value> = environments
        .iter()
        .map(|e| context! { name => e.name })
        .collect();

    let dest_options: Vec<minijinja::Value> = destinations
        .iter()
        .map(|d| context! { name => d.name, environment => d.environment })
        .collect();

    let pipeline_options: Vec<minijinja::Value> = pipelines
        .iter()
        .filter(|p| p.enabled)
        .map(|p| context! { name => p.name })
        .collect();

    let projects = warn_default(
        "list_projects",
        state.platform_client.list_projects(&session.access_token, &org).await,
    );

    let html = state
        .templates
        .render(
            "pages/triggers.html.jinja",
            context! {
                page_title => format!("Triggers · {} · {}", project, org),
                user => context! {
                    username => session.user.username,
                },
                csrf_token => session.csrf_token,
                orgs => orgs_context(orgs),
                current_org => org,
                current_project => project,
                projects => projects,
                triggers => trigger_items,
                environments => env_options,
                destinations => dest_options,
                pipelines => pipeline_options,
                is_admin => is_admin,
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct CreateTriggerForm {
    csrf_token: String,
    #[serde(default)]
    name: String,
    #[serde(default)]
    branch_pattern: String,
    #[serde(default)]
    title_pattern: String,
    #[serde(default)]
    author_pattern: String,
    #[serde(default)]
    commit_message_pattern: String,
    #[serde(default)]
    source_type_pattern: String,
    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
    target_environments: Vec<String>,
    #[serde(default)]
    force_release: Option<String>,
    #[serde(default)]
    use_pipeline: Option<String>,
}

async fn create_trigger_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
    Form(form): Form<CreateTriggerForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    if form.name.trim().is_empty() {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Trigger name is required.",
        ));
    }

    // At least one filter pattern is required
    let has_pattern = non_empty(&form.branch_pattern).is_some()
        || non_empty(&form.title_pattern).is_some()
        || non_empty(&form.author_pattern).is_some()
        || non_empty(&form.commit_message_pattern).is_some()
        || non_empty(&form.source_type_pattern).is_some();
    if !has_pattern {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "At least one filter pattern is required.",
        ));
    }

    let environments: Vec<String> = form
        .target_environments
        .iter()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();
    if environments.is_empty() {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "At least one target environment is required.",
        ));
    }

    // Auto-generate name from first pattern if not provided
    let name = if form.name.trim().is_empty() {
        let pattern = non_empty(&form.branch_pattern)
            .or_else(|| non_empty(&form.title_pattern))
            .or_else(|| non_empty(&form.author_pattern))
            .or_else(|| non_empty(&form.commit_message_pattern))
            .or_else(|| non_empty(&form.source_type_pattern))
            .unwrap_or_default();
        let envs = environments.join("-");
        format!("{}-to-{}", pattern, envs)
    } else {
        form.name.trim().to_string()
    };

    let input = CreateTriggerInput {
        name,
        branch_pattern: non_empty(&form.branch_pattern),
        title_pattern: non_empty(&form.title_pattern),
        author_pattern: non_empty(&form.author_pattern),
        commit_message_pattern: non_empty(&form.commit_message_pattern),
        source_type_pattern: non_empty(&form.source_type_pattern),
        target_environments: environments,
        target_destinations: vec![],
        force_release: form.force_release.as_deref() == Some("true"),
        use_pipeline: form.use_pipeline.as_deref() == Some("true"),
    };

    state
        .platform_client
        .create_trigger(&session.access_token, &org, &project, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to create trigger", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/triggers")).into_response())
}

#[derive(Deserialize)]
struct ToggleTriggerForm {
    csrf_token: String,
    #[serde(default)]
    enabled: Option<String>,
}

async fn toggle_trigger(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<ToggleTriggerForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let input = UpdateTriggerInput {
        enabled: Some(form.enabled.is_some()),
        branch_pattern: None,
        title_pattern: None,
        author_pattern: None,
        commit_message_pattern: None,
        source_type_pattern: None,
        target_environments: vec![],
        target_destinations: vec![],
        force_release: None,
        use_pipeline: None,
    };

    state
        .platform_client
        .update_trigger(&session.access_token, &org, &project, &name, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to toggle trigger", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/triggers")).into_response())
}

#[derive(Deserialize)]
struct DeleteTriggerForm {
    csrf_token: String,
}

async fn delete_trigger(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<DeleteTriggerForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    state
        .platform_client
        .delete_trigger(&session.access_token, &org, &project, &name)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to delete trigger", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/triggers")).into_response())
}

async fn edit_trigger_page(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (triggers, environments, pipelines) = tokio::join!(
        state
            .platform_client
            .list_triggers(&session.access_token, &org, &project),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
        state
            .platform_client
            .list_release_pipelines(&session.access_token, &org, &project),
    );
    let triggers = triggers.map_err(|e| internal_error(&state, "list_triggers", &e))?;
    let environments = warn_default("list_environments", environments);
    let pipelines = warn_default("list_release_pipelines", pipelines);

    let trigger = triggers
        .iter()
        .find(|t| t.name == name)
        .ok_or_else(|| {
            error_page(
                &state,
                StatusCode::NOT_FOUND,
                "Not found",
                "Trigger not found.",
            )
        })?;

    let trigger_ctx = context! {
        name => trigger.name,
        enabled => trigger.enabled,
        branch_pattern => trigger.branch_pattern,
        title_pattern => trigger.title_pattern,
        author_pattern => trigger.author_pattern,
        commit_message_pattern => trigger.commit_message_pattern,
        source_type_pattern => trigger.source_type_pattern,
        target_environments => &trigger.target_environments,
        target_destinations => &trigger.target_destinations,
        force_release => trigger.force_release,
        use_pipeline => trigger.use_pipeline,
    };

    let env_options: Vec<minijinja::Value> = environments
        .iter()
        .map(|e| context! { name => e.name })
        .collect();

    let pipeline_options: Vec<minijinja::Value> = pipelines
        .iter()
        .filter(|p| p.enabled)
        .map(|p| context! { name => p.name })
        .collect();

    let projects = warn_default(
        "list_projects",
        state
            .platform_client
            .list_projects(&session.access_token, &org)
            .await,
    );

    let html = state
        .templates
        .render(
            "pages/trigger_edit.html.jinja",
            context! {
                page_title => format!("Edit Trigger · {} · {}", name, org),
                user => context! {
                    username => session.user.username,
                },
                csrf_token => session.csrf_token,
                orgs => orgs_context(orgs),
                current_org => org,
                current_project => project,
                projects => projects,
                trigger => trigger_ctx,
                environments => env_options,
                pipelines => pipeline_options,
            },
        )
        .map_err(|e| internal_error(&state, "template error", &e))?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct EditTriggerForm {
    csrf_token: String,
    #[serde(default)]
    branch_pattern: String,
    #[serde(default)]
    title_pattern: String,
    #[serde(default)]
    author_pattern: String,
    #[serde(default)]
    commit_message_pattern: String,
    #[serde(default)]
    source_type_pattern: String,
    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
    target_environments: Vec<String>,
    #[serde(default)]
    force_release: Option<String>,
    #[serde(default)]
    use_pipeline: Option<String>,
}

async fn edit_trigger_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<EditTriggerForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let has_pattern = non_empty(&form.branch_pattern).is_some()
        || non_empty(&form.title_pattern).is_some()
        || non_empty(&form.author_pattern).is_some()
        || non_empty(&form.commit_message_pattern).is_some()
        || non_empty(&form.source_type_pattern).is_some();
    if !has_pattern {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "At least one filter pattern is required.",
        ));
    }

    let environments: Vec<String> = form
        .target_environments
        .iter()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();
    if environments.is_empty() {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "At least one target environment is required.",
        ));
    }

    let input = UpdateTriggerInput {
        enabled: None,
        branch_pattern: non_empty(&form.branch_pattern),
        title_pattern: non_empty(&form.title_pattern),
        author_pattern: non_empty(&form.author_pattern),
        commit_message_pattern: non_empty(&form.commit_message_pattern),
        source_type_pattern: non_empty(&form.source_type_pattern),
        target_environments: environments,
        target_destinations: vec![],
        force_release: Some(form.force_release.as_deref() == Some("true")),
        use_pipeline: Some(form.use_pipeline.as_deref() == Some("true")),
    };

    state
        .platform_client
        .update_trigger(&session.access_token, &org, &project, &name, &input)
        .await
        .map_err(|e| internal_error(&state, "failed to update trigger", &e))?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/triggers")).into_response())
}

// ─── Policies (deployment gating) ──────────────────────────────────

async fn policies_page(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (policies, environments) = tokio::join!(
        state
            .platform_client
            .list_policies(&session.access_token, &org, &project),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
    );
    let policies = policies.map_err(|e| internal_error(&state, "list_policies", &e))?;
    let environments = warn_default("list_environments", environments);

    let is_admin = current_org.role == "owner" || current_org.role == "admin";

    let policy_items: Vec<minijinja::Value> = policies
        .iter()
        .map(|p| {
            let (policy_type, config_detail) = match &p.config {
                PolicyConfig::SoakTime {
                    source_environment,
                    target_environment,
                    duration_seconds,
                } => (
                    "soak_time",
                    context! {
                        source_environment => source_environment,
                        target_environment => target_environment,
                        duration_seconds => duration_seconds,
                        duration_human => format_duration(*duration_seconds),
                    },
                ),
                PolicyConfig::BranchRestriction {
                    target_environment,
                    branch_pattern,
                } => (
                    "branch_restriction",
                    context! {
                        target_environment => target_environment,
                        branch_pattern => branch_pattern,
                    },
                ),
            };
            context! {
                id => p.id,
                name => p.name,
                enabled => p.enabled,
                policy_type => policy_type,
                config => config_detail,
                created_at => p.created_at,
                updated_at => p.updated_at,
            }
        })
        .collect();

    let env_options: Vec<minijinja::Value> = environments
        .iter()
        .map(|e| context! { name => e.name })
        .collect();

    let projects = warn_default(
        "list_projects",
        state.platform_client.list_projects(&session.access_token, &org).await,
    );

    let html = state
        .templates
        .render(
            "pages/policies.html.jinja",
            context! {
                page_title => format!("Policies · {} · {}", project, org),
                user => context! {
                    username => session.user.username,
                },
                csrf_token => session.csrf_token,
                orgs => orgs_context(orgs),
                current_org => org,
                current_project => project,
                projects => projects,
                policies => policy_items,
                environments => env_options,
                is_admin => is_admin,
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

/// Map environment name to a swimlane bar color (matches ENV_COLORS in swim-lanes.js).
fn env_lane_color(name: &str) -> &'static str {
    let lower = name.to_lowercase();
    if lower.contains("prod") && !lower.contains("preprod") && !lower.contains("pre-prod") {
        "#ec4899"
    } else if lower.contains("preprod") || lower.contains("pre-prod") {
        "#f97316"
    } else if lower.contains("stag") {
        "#eab308"
    } else if lower.contains("dev") {
        "#8b5cf6"
    } else if lower.contains("test") {
        "#06b6d4"
    } else {
        "#6b7280"
    }
}

fn format_duration(seconds: i64) -> String {
    if seconds >= 3600 {
        let hours = seconds / 3600;
        let mins = (seconds % 3600) / 60;
        if mins > 0 {
            format!("{}h {}m", hours, mins)
        } else {
            format!("{}h", hours)
        }
    } else if seconds >= 60 {
        format!("{}m", seconds / 60)
    } else {
        format!("{}s", seconds)
    }
}

#[derive(Deserialize)]
struct CreatePolicyForm {
    csrf_token: String,
    #[serde(default)]
    name: String,
    #[serde(default)]
    policy_type: String,
    // SoakTime fields
    #[serde(default)]
    source_environment: String,
    #[serde(default)]
    target_environment: String,
    #[serde(default)]
    duration_seconds: Option<i64>,
    // BranchRestriction fields
    #[serde(default)]
    branch_pattern: String,
}

async fn create_policy_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
    Form(form): Form<CreatePolicyForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    if form.name.trim().is_empty() {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Policy name is required.",
        ));
    }

    let config = match form.policy_type.as_str() {
        "soak_time" => {
            let source = form.source_environment.trim();
            let target = form.target_environment.trim();
            let duration = form.duration_seconds.unwrap_or(0);
            if source.is_empty() || target.is_empty() || duration <= 0 {
                return Err(error_page(
                    &state,
                    StatusCode::BAD_REQUEST,
                    "Invalid request",
                    "Soak time requires source environment, target environment, and a positive duration.",
                ));
            }
            PolicyConfig::SoakTime {
                source_environment: source.to_string(),
                target_environment: target.to_string(),
                duration_seconds: duration,
            }
        }
        "branch_restriction" => {
            let target = form.target_environment.trim();
            let pattern = form.branch_pattern.trim();
            if target.is_empty() || pattern.is_empty() {
                return Err(error_page(
                    &state,
                    StatusCode::BAD_REQUEST,
                    "Invalid request",
                    "Branch restriction requires a target environment and branch pattern.",
                ));
            }
            PolicyConfig::BranchRestriction {
                target_environment: target.to_string(),
                branch_pattern: pattern.to_string(),
            }
        }
        _ => {
            return Err(error_page(
                &state,
                StatusCode::BAD_REQUEST,
                "Invalid request",
                "Invalid policy type. Must be 'soak_time' or 'branch_restriction'.",
            ));
        }
    };

    let input = CreatePolicyInput {
        name: form.name.trim().to_string(),
        config,
    };

    state
        .platform_client
        .create_policy(&session.access_token, &org, &project, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to create policy", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/policies")).into_response())
}

#[derive(Deserialize)]
struct TogglePolicyForm {
    csrf_token: String,
    #[serde(default)]
    enabled: Option<String>,
}

async fn toggle_policy(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<TogglePolicyForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let input = UpdatePolicyInput {
        enabled: Some(form.enabled.is_some()),
        config: None,
    };

    state
        .platform_client
        .update_policy(&session.access_token, &org, &project, &name, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to toggle policy", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/policies")).into_response())
}

#[derive(Deserialize)]
struct DeletePolicyForm {
    csrf_token: String,
}

async fn delete_policy(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<DeletePolicyForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    state
        .platform_client
        .delete_policy(&session.access_token, &org, &project, &name)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to delete policy", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/policies")).into_response())
}

async fn edit_policy_page(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (policies, environments) = tokio::join!(
        state
            .platform_client
            .list_policies(&session.access_token, &org, &project),
        state
            .platform_client
            .list_environments(&session.access_token, &org),
    );
    let policies = policies.map_err(|e| internal_error(&state, "list_policies", &e))?;
    let environments = warn_default("list_environments", environments);

    let policy = policies
        .iter()
        .find(|p| p.name == name)
        .ok_or_else(|| {
            error_page(
                &state,
                StatusCode::NOT_FOUND,
                "Not found",
                "Policy not found.",
            )
        })?;

    let (policy_type, config_ctx) = match &policy.config {
        PolicyConfig::SoakTime {
            source_environment,
            target_environment,
            duration_seconds,
        } => (
            "soak_time",
            context! {
                source_environment => source_environment,
                target_environment => target_environment,
                duration_seconds => duration_seconds,
            },
        ),
        PolicyConfig::BranchRestriction {
            target_environment,
            branch_pattern,
        } => (
            "branch_restriction",
            context! {
                target_environment => target_environment,
                branch_pattern => branch_pattern,
            },
        ),
    };

    let policy_ctx = context! {
        name => policy.name,
        enabled => policy.enabled,
        policy_type => policy_type,
        config => config_ctx,
    };

    let env_options: Vec<minijinja::Value> = environments
        .iter()
        .map(|e| context! { name => e.name })
        .collect();

    let projects = warn_default(
        "list_projects",
        state
            .platform_client
            .list_projects(&session.access_token, &org)
            .await,
    );

    let html = state
        .templates
        .render(
            "pages/policy_edit.html.jinja",
            context! {
                page_title => format!("Edit Policy · {} · {}", name, org),
                user => context! {
                    username => session.user.username,
                },
                csrf_token => session.csrf_token,
                orgs => orgs_context(orgs),
                current_org => org,
                current_project => project,
                projects => projects,
                policy => policy_ctx,
                environments => env_options,
            },
        )
        .map_err(|e| internal_error(&state, "template error", &e))?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct EditPolicyForm {
    csrf_token: String,
    #[serde(default)]
    policy_type: String,
    #[serde(default)]
    source_environment: String,
    #[serde(default)]
    target_environment: String,
    #[serde(default)]
    duration_seconds: Option<i64>,
    #[serde(default)]
    branch_pattern: String,
}

async fn edit_policy_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<EditPolicyForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if form.csrf_token != session.csrf_token {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let config = match form.policy_type.as_str() {
        "soak_time" => {
            let source = form.source_environment.trim();
            let target = form.target_environment.trim();
            let duration = form.duration_seconds.unwrap_or(0);
            if source.is_empty() || target.is_empty() || duration <= 0 {
                return Err(error_page(
                    &state,
                    StatusCode::BAD_REQUEST,
                    "Invalid request",
                    "Soak time requires source environment, target environment, and a positive duration.",
                ));
            }
            PolicyConfig::SoakTime {
                source_environment: source.to_string(),
                target_environment: target.to_string(),
                duration_seconds: duration,
            }
        }
        "branch_restriction" => {
            let target = form.target_environment.trim();
            let pattern = form.branch_pattern.trim();
            if target.is_empty() || pattern.is_empty() {
                return Err(error_page(
                    &state,
                    StatusCode::BAD_REQUEST,
                    "Invalid request",
                    "Branch restriction requires a target environment and branch pattern.",
                ));
            }
            PolicyConfig::BranchRestriction {
                target_environment: target.to_string(),
                branch_pattern: pattern.to_string(),
            }
        }
        _ => {
            return Err(error_page(
                &state,
                StatusCode::BAD_REQUEST,
                "Invalid request",
                "Invalid policy type.",
            ));
        }
    };

    let input = UpdatePolicyInput {
        enabled: None,
        config: Some(config),
    };

    state
        .platform_client
        .update_policy(&session.access_token, &org, &project, &name, &input)
        .await
        .map_err(|e| internal_error(&state, "failed to update policy", &e))?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/policies")).into_response())
}

// ─── Release Pipelines ──────────────────────────────────────────────

#[tracing::instrument(skip(state, session), fields(org, project))]
async fn pipelines_page(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;

    if !validate_slug(&project) {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Invalid project name.",
        ));
    }

    let (pipelines, projects) = tokio::join!(
        state
            .platform_client
            .list_release_pipelines(&session.access_token, &org, &project),
        state
            .platform_client
            .list_projects(&session.access_token, &org),
    );
    let pipelines = pipelines.map_err(|e| internal_error(&state, "list_pipelines", &e))?;
    let projects = warn_default("list_projects", projects);

    let is_admin = current_org.role == "owner" || current_org.role == "admin";

    let pipeline_items: Vec<minijinja::Value> = pipelines
        .iter()
        .map(|p| {
            let stage_count = p.stages.len();
            context! {
                id => p.id,
                name => p.name,
                enabled => p.enabled,
                stages_json => serde_json::to_string(&p.stages).unwrap_or_default(),
                stage_count => stage_count,
                created_at => p.created_at,
                updated_at => p.updated_at,
            }
        })
        .collect();

    let html = state
        .templates
        .render(
            "pages/pipelines.html.jinja",
            context! {
                page_title => format!("Pipelines · {} · {}", project, org),
                user => context! {
                    username => session.user.username,
                },
                csrf_token => session.csrf_token,
                orgs => orgs_context(orgs),
                current_org => org,
                current_project => project,
                projects => projects,
                pipelines => pipeline_items,
                is_admin => is_admin,
            },
        )
        .map_err(|e| {
            internal_error(&state, "template error", &e)
        })?;

    Ok(Html(html).into_response())
}

#[derive(Deserialize)]
struct CreatePipelineForm {
    _csrf: String,
    name: String,
    #[serde(default)]
    stages_json: String,
}

#[tracing::instrument(skip(state, session, form), fields(org, project))]
async fn create_pipeline_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, project)): Path<(String, String)>,
    Form(form): Form<CreatePipelineForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    if form.name.trim().is_empty() {
        return Err(error_page(
            &state,
            StatusCode::BAD_REQUEST,
            "Invalid request",
            "Pipeline name is required.",
        ));
    }

    let stages: Vec<PipelineStage> = if form.stages_json.trim().is_empty() {
        Vec::new()
    } else {
        serde_json::from_str(&form.stages_json).map_err(|_| {
            error_page(
                &state,
                StatusCode::BAD_REQUEST,
                "Invalid request",
                "stages_json is not valid JSON.",
            )
        })?
    };

    let input = CreateReleasePipelineInput {
        name: form.name.trim().to_string(),
        stages,
    };

    state
        .platform_client
        .create_release_pipeline(&session.access_token, &org, &project, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to create pipeline", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/pipelines")).into_response())
}

#[derive(Deserialize)]
struct TogglePipelineForm {
    _csrf: String,
    #[serde(default)]
    enabled: Option<String>,
}

#[tracing::instrument(skip(state, session, form), fields(org, project, name))]
async fn toggle_pipeline(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<TogglePipelineForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let input = UpdateReleasePipelineInput {
        enabled: Some(form.enabled.is_some()),
        stages: None,
    };

    state
        .platform_client
        .update_release_pipeline(&session.access_token, &org, &project, &name, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to toggle pipeline", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/pipelines")).into_response())
}

#[derive(Deserialize)]
struct UpdatePipelineForm {
    _csrf: String,
    #[serde(default)]
    stages_json: String,
}

#[tracing::instrument(skip(state, session, form), fields(org, project, name))]
async fn update_pipeline_submit(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<UpdatePipelineForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    let stages: Vec<PipelineStage> = if form.stages_json.trim().is_empty() {
        Vec::new()
    } else {
        serde_json::from_str(&form.stages_json).map_err(|_| {
            error_page(
                &state,
                StatusCode::BAD_REQUEST,
                "Invalid request",
                "stages_json is not valid JSON.",
            )
        })?
    };

    let input = UpdateReleasePipelineInput {
        enabled: None,
        stages: Some(stages),
    };

    state
        .platform_client
        .update_release_pipeline(&session.access_token, &org, &project, &name, &input)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to update pipeline", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/pipelines")).into_response())
}

#[derive(Deserialize)]
struct DeletePipelineForm {
    _csrf: String,
}

#[tracing::instrument(skip(state, session, form), fields(org, project, name))]
async fn delete_pipeline(
    State(state): State<AppState>,
    session: Session,
    Path((org, project, name)): Path<(String, String, String)>,
    Form(form): Form<DeletePipelineForm>,
) -> Result<Response, Response> {
    let orgs = &session.user.orgs;
    let current_org = require_org_membership(&state, orgs, &org)?;
    require_admin(&state, current_org)?;

    if !auth::validate_csrf(&session, &form._csrf) {
        return Err(error_page(
            &state,
            StatusCode::FORBIDDEN,
            "Invalid request",
            "CSRF validation failed. Please try again.",
        ));
    }

    state
        .platform_client
        .delete_release_pipeline(&session.access_token, &org, &project, &name)
        .await
        .map_err(|e| {
            internal_error(&state, "failed to delete pipeline", &e)
        })?;

    Ok(Redirect::to(&format!("/orgs/{org}/projects/{project}/pipelines")).into_response())
}

fn non_empty(s: &str) -> Option<String> {
    let trimmed = s.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

