//! `/api/v1/admin/apps/{id}/metrics` — read-only observability rollup (A2).
//!
//! Aggregates the existing `execution_logs` table (counts, error rate, latency
//! percentiles, an hourly series) for one app over a trailing window. No
//! hot-path instrumentation: the data plane already persists `duration_ms` /
//! `status` / `source` / `created_at` per run, so this is a pure read model
//! behind the dashboard's Metrics tab.
//!
//! Capability: `AppLogRead` (the same tier as the per-script log view),
//! resolved against the app loaded from the path.

use std::sync::Arc;

use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde::Deserialize;
use serde_json::json;

use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ExecutionLogRepository, MetricsSummary};

#[derive(Clone)]
pub struct MetricsState {
    pub logs: Arc<dyn ExecutionLogRepository>,
    pub apps: Arc<dyn AppRepository>,
    pub authz: Arc<dyn AuthzRepo>,
}

pub fn metrics_router(state: MetricsState) -> Router {
    Router::new()
        .route("/apps/{app_id}/metrics", get(summary))
        .with_state(state)
}

/// Default rollup window when `?window=` is absent (last 24 hours).
const DEFAULT_WINDOW_HOURS: i32 = 24;

#[derive(Debug, Deserialize)]
pub struct MetricsQuery {
    /// Trailing window in hours (clamped to `[1, 2160]` by the repo).
    #[serde(default)]
    pub window: Option<i32>,
}

async fn summary(
    State(s): State<MetricsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Query(q): Query<MetricsQuery>,
) -> Result<Json<MetricsSummary>, MetricsApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(s.authz.as_ref(), &principal, Capability::AppLogRead(app_id)).await?;
    let window = q.window.unwrap_or(DEFAULT_WINDOW_HOURS);
    let summary = s
        .logs
        .summarize_for_app(app_id, window)
        .await
        .map_err(|e| MetricsApiError::Backend(e.to_string()))?;
    Ok(Json(summary))
}

async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, MetricsApiError> {
    crate::app_repo::resolve_app(apps, ident)
        .await
        .map_err(|e| MetricsApiError::Backend(e.to_string()))?
        .map(|l| l.app.id)
        .ok_or(MetricsApiError::AppNotFound)
}

#[derive(Debug, thiserror::Error)]
pub enum MetricsApiError {
    #[error("app not found")]
    AppNotFound,
    #[error("forbidden")]
    Forbidden,
    #[error("authorization repo error: {0}")]
    AuthzRepo(String),
    #[error("metrics backend: {0}")]
    Backend(String),
}

impl From<AuthzDenied> for MetricsApiError {
    fn from(d: AuthzDenied) -> Self {
        match d {
            AuthzDenied::Denied => Self::Forbidden,
            AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
        }
    }
}

impl IntoResponse for MetricsApiError {
    fn into_response(self) -> Response {
        use axum::http::StatusCode;
        let (status, body) = match &self {
            Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "metrics admin authz error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Backend(e) => {
                tracing::error!(error = %e, "metrics admin backend error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
