//! Changelog endpoints — serve per-component release notes from the changelog directory.
//!
//! `GET /api/changelog/:component/:version` serves changelog Markdown for a component:
//!   - `version == "latest"` → most recent changelog (`LATEST_AGENT.md`, etc.)
//!   - `version == "0.1.0"` (or `v0.1.0`) → a specific version (`agent/v0.1.0.md`)
//!
//! A single route is used because axum 0.7 / matchit 0.7 panics at router construction when a
//! static segment (`latest`) and a path param (`:version`) occupy the same position; the handler
//! dispatches on the literal value `latest` instead.
//!
//! This endpoint is public (no auth), mirroring `GET /api/version`. The base directory is read
//! from the `CHANGELOG_DIR` env var (default `./changelogs`). Both the `component` and `version`
//! path parameters are validated/sanitized to prevent path traversal.

use axum::{
    extract::Path,
    http::{header, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use std::path::PathBuf;

use super::auth::ErrorResponse;

/// Resolve the base changelog directory from the `CHANGELOG_DIR` env var.
///
/// Defaults to `./changelogs` (relative to the server's working directory) when unset.
fn changelog_dir() -> PathBuf {
    std::env::var("CHANGELOG_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("./changelogs"))
}

/// Whether `component` is a recognized GuruConnect component.
///
/// Restricting to a fixed allow-list is the primary defense against path traversal: an
/// attacker cannot smuggle `..` or absolute paths through this parameter.
fn valid_component(component: &str) -> bool {
    matches!(component, "agent" | "server" | "dashboard")
}

/// Build a `404 Not Found` response using the project's standard error envelope.
fn not_found(message: &str) -> Response {
    (
        StatusCode::NOT_FOUND,
        Json(ErrorResponse {
            error: message.to_string(),
        }),
    )
        .into_response()
}

/// Build a `200 OK` Markdown response.
fn markdown_response(content: String) -> Response {
    (
        StatusCode::OK,
        [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")],
        content,
    )
        .into_response()
}

/// `GET /api/changelog/:component/:version`
///
/// Serves changelog Markdown for `component`. The `version` segment is dispatched as follows:
///   - `latest` → `changelogs/LATEST_<COMPONENT>.md` (uppercase component)
///   - a semver string (with or without a leading `v`, e.g. `0.1.0` or `v0.1.0`)
///     → `changelogs/<component>/v<version>.md`
///
/// `component` is validated against the allow-list FIRST; the version (when not `latest`) is
/// sanitized to digits and dots only, rejecting any path-traversal attempt before touching the
/// filesystem.
pub async fn get(Path((component, version)): Path<(String, String)>) -> Response {
    // Allow-list check first: the primary defense against path traversal via `component`.
    if !valid_component(&component) {
        return not_found("Unknown component");
    }

    // The literal "latest" selects the most-recent changelog file for the component.
    if version == "latest" {
        let file = changelog_dir().join(format!("LATEST_{}.md", component.to_uppercase()));

        return match tokio::fs::read_to_string(&file).await {
            Ok(content) => markdown_response(content),
            Err(e) => {
                tracing::warn!(
                    "Changelog latest not found for component '{}' ({}): {}",
                    component,
                    file.display(),
                    e
                );
                not_found("Changelog not found")
            }
        };
    }

    // Otherwise treat `version` as a specific release. Accept it with or without a leading 'v';
    // work with the bare numeric form.
    let bare = version.strip_prefix('v').unwrap_or(&version);

    // Sanitize: a valid version is non-empty and contains only digits and dots. This rejects any
    // path-traversal attempt (slashes, `..`, backslashes) before touching the filesystem.
    let safe = !bare.is_empty()
        && bare.chars().all(|c| c.is_ascii_digit() || c == '.')
        && !bare.contains("..");
    if !safe {
        return not_found("Invalid version");
    }

    let file = changelog_dir().join(&component).join(format!("v{bare}.md"));

    match tokio::fs::read_to_string(&file).await {
        Ok(content) => markdown_response(content),
        Err(e) => {
            tracing::warn!(
                "Changelog not found for component '{}' version 'v{}' ({}): {}",
                component,
                bare,
                file.display(),
                e
            );
            not_found("Changelog not found")
        }
    }
}
