//! Bitbucket Code Insights Annotations API formatter and publisher.
//!
//! Creates reports and annotations via the Bitbucket API using reqwest.

use crate::env::Env;
use crate::models::finding::{Finding, Severity, Summary};
use crate::output::{OutputFormatter, OutputPublisher};
use thiserror::Error;

/// Errors from Bitbucket API calls.
#[derive(Error, Debug)]
pub enum BitbucketError {
    #[error("missing environment variable: {0}")]
    MissingEnvVar(String),

    #[error("API request failed: {0}")]
    ApiError(String),
}

/// Bitbucket Code Insights formatter.
///
/// Outputs JSON that can be posted to the Bitbucket API.
/// For actually posting to the API, use [`BitbucketPublisher`].
pub struct BitbucketFormatter;

impl OutputFormatter for BitbucketFormatter {
    fn format(&self, findings: &[Finding]) -> String {
        let annotations: Vec<serde_json::Value> = findings
            .iter()
            .map(|f| {
                let severity = f.severity.as_bitbucket_severity();
                let mut message = f.message.clone();
                if let Some(ref suggestion) = f.suggestion {
                    message.push_str(&format!("\n\nSuggestion: {suggestion}"));
                }
                serde_json::json!({
                    "path": f.file,
                    "line": f.line,
                    "message": message,
                    "severity": severity,
                    "type": "CODE_SMELL",
                    "summary": f.title,
                })
            })
            .collect();

        serde_json::to_string_pretty(&serde_json::json!({
            "annotations": annotations
        }))
        .unwrap_or_else(|_| "{}".to_string())
    }
}

/// Bitbucket Code Insights publisher.
///
/// Posts findings as reports and annotations to the Bitbucket API.
pub struct BitbucketPublisher<'a> {
    fail_on: Option<Severity>,
    env: &'a Env,
}

impl<'a> BitbucketPublisher<'a> {
    /// Create a new publisher with the given threshold and environment.
    pub fn new(fail_on: Option<Severity>, env: &'a Env) -> Self {
        Self { fail_on, env }
    }
}

impl OutputPublisher for BitbucketPublisher<'_> {
    async fn publish(
        &self,
        findings: &[Finding],
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        post_to_bitbucket(findings, self.fail_on, self.env).await?;
        Ok(())
    }
}

/// Post findings to the Bitbucket Code Insights API.
///
/// When running inside Bitbucket Pipelines (detected via `BITBUCKET_BUILD_NUMBER`),
/// requests are routed through the local authentication proxy at `localhost:29418`,
/// which injects credentials automatically — no `BITBUCKET_TOKEN` required.
///
/// Outside Pipelines, `BITBUCKET_TOKEN` is required for authentication.
///
/// Always requires: `BITBUCKET_WORKSPACE`, `BITBUCKET_REPO_SLUG`, `BITBUCKET_COMMIT`.
///
/// `fail_on` controls the report result: if any finding meets or exceeds
/// the threshold, the report is marked `FAILED`. When `None`, only errors
/// cause a `FAILED` result.
pub async fn post_to_bitbucket(
    findings: &[Finding],
    fail_on: Option<Severity>,
    env: &Env,
) -> Result<(), BitbucketError> {
    let workspace = env
        .var("BITBUCKET_WORKSPACE")
        .map_err(|_| BitbucketError::MissingEnvVar("BITBUCKET_WORKSPACE".into()))?;
    let repo_slug = env
        .var("BITBUCKET_REPO_SLUG")
        .map_err(|_| BitbucketError::MissingEnvVar("BITBUCKET_REPO_SLUG".into()))?;
    let commit = env
        .var("BITBUCKET_COMMIT")
        .map_err(|_| BitbucketError::MissingEnvVar("BITBUCKET_COMMIT".into()))?;

    // Inside Bitbucket Pipelines the local proxy at localhost:29418 injects
    // auth headers automatically, so no token is needed. Outside Pipelines
    // we fall back to explicit bearer-token authentication.
    let in_pipelines = env.is_set("BITBUCKET_BUILD_NUMBER");
    let token = if in_pipelines {
        None
    } else {
        Some(
            env.var("BITBUCKET_TOKEN")
                .map_err(|_| BitbucketError::MissingEnvVar("BITBUCKET_TOKEN".into()))?,
        )
    };

    let client = if in_pipelines {
        crate::http::build_bitbucket_pipelines_client()
            .map_err(|e| BitbucketError::ApiError(format!("HTTP client error: {e}")))?
    } else {
        crate::http::build_client()
            .map_err(|e| BitbucketError::ApiError(format!("HTTP client error: {e}")))?
    };

    // The proxy expects plain http:// — it handles TLS to the upstream API.
    let scheme = if in_pipelines { "http" } else { "https" };
    let base_url = format!(
        "{scheme}://api.bitbucket.org/2.0/repositories/{workspace}/{repo_slug}/commit/{commit}"
    );
    let report_id = format!("{}-review", crate::constants::APP_NAME);

    // Step 1: Create/update the report
    let summary = Summary::from_findings(findings);
    let threshold = fail_on.unwrap_or(Severity::Error);
    let should_fail = findings.iter().any(|f| f.severity >= threshold);
    let result = if should_fail { "FAILED" } else { "PASSED" };

    let report_body = serde_json::json!({
        "title": format!("{} Code Review", crate::constants::APP_NAME),
        "details": format!(
            "{} findings: {} errors, {} warnings, {} info. {}",
            summary.total, summary.errors, summary.warnings, summary.info,
            crate::constants::AI_DISCLOSURE,
        ),
        "report_type": "BUG",
        "result": result,
    });

    let mut report_request = client
        .put(format!("{base_url}/reports/{report_id}"))
        .json(&report_body);
    if let Some(ref t) = token {
        report_request = report_request.bearer_auth(t);
    }
    let report_response = report_request
        .send()
        .await
        .map_err(|e| BitbucketError::ApiError(e.to_string()))?;

    if !report_response.status().is_success() {
        let status = report_response.status();
        let body = report_response
            .text()
            .await
            .unwrap_or_else(|_| "<no body>".to_string());
        return Err(BitbucketError::ApiError(format!(
            "report creation failed with HTTP {status}: {body}"
        )));
    }

    // Step 2: Post annotations (in batches of 100)
    let annotations: Vec<serde_json::Value> = findings
        .iter()
        .enumerate()
        .map(|(i, f)| {
            let severity = f.severity.as_bitbucket_severity();
            let mut message = f.message.clone();
            if let Some(ref suggestion) = f.suggestion {
                message.push_str(&format!("\n\nSuggestion: {suggestion}"));
            }
            serde_json::json!({
                "external_id": format!("{}-{i}", crate::constants::APP_NAME),
                "path": f.file,
                "line": f.line,
                "summary": f.title,
                "details": message,
                "annotation_type": "CODE_SMELL",
                "severity": severity,
            })
        })
        .collect();

    for chunk in annotations.chunks(100) {
        let mut ann_request = client
            .post(format!("{base_url}/reports/{report_id}/annotations"))
            .json(&chunk.to_vec());
        if let Some(ref t) = token {
            ann_request = ann_request.bearer_auth(t);
        }
        let ann_response = ann_request
            .send()
            .await
            .map_err(|e| BitbucketError::ApiError(e.to_string()))?;

        if !ann_response.status().is_success() {
            let status = ann_response.status();
            let body = ann_response
                .text()
                .await
                .unwrap_or_else(|_| "<no body>".to_string());
            return Err(BitbucketError::ApiError(format!(
                "annotation post failed with HTTP {status}: {body}"
            )));
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::env::Env;
    use crate::models::finding::{Finding, Severity};

    fn sample_findings() -> Vec<Finding> {
        vec![
            Finding {
                file: "src/main.rs".to_string(),
                line: 10,
                end_line: None,
                severity: Severity::Error,
                title: "Bug".to_string(),
                message: "A bug was found".to_string(),
                suggestion: Some("Fix the bug".to_string()),
                agent: "backend".to_string(),
            },
            Finding {
                file: "src/lib.rs".to_string(),
                line: 20,
                end_line: None,
                severity: Severity::Warning,
                title: "Style".to_string(),
                message: "Style issue".to_string(),
                suggestion: None,
                agent: "backend".to_string(),
            },
        ]
    }

    #[test]
    fn render_produces_valid_json() {
        let findings = sample_findings();
        let output = BitbucketFormatter.format(&findings);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let annotations = parsed["annotations"].as_array().unwrap();
        assert_eq!(annotations.len(), 2);
    }

    #[test]
    fn render_maps_severity_correctly() {
        let findings = vec![
            Finding {
                file: "a.rs".to_string(),
                line: 1,
                end_line: None,
                severity: Severity::Error,
                title: "E".to_string(),
                message: "error".to_string(),
                suggestion: None,
                agent: "t".to_string(),
            },
            Finding {
                file: "b.rs".to_string(),
                line: 2,
                end_line: None,
                severity: Severity::Warning,
                title: "W".to_string(),
                message: "warning".to_string(),
                suggestion: None,
                agent: "t".to_string(),
            },
            Finding {
                file: "c.rs".to_string(),
                line: 3,
                end_line: None,
                severity: Severity::Info,
                title: "I".to_string(),
                message: "info".to_string(),
                suggestion: None,
                agent: "t".to_string(),
            },
        ];
        let output = BitbucketFormatter.format(&findings);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let annotations = parsed["annotations"].as_array().unwrap();
        assert_eq!(annotations[0]["severity"], "HIGH");
        assert_eq!(annotations[1]["severity"], "MEDIUM");
        assert_eq!(annotations[2]["severity"], "LOW");
    }

    #[test]
    fn render_includes_suggestion_in_message() {
        let findings = sample_findings();
        let output = BitbucketFormatter.format(&findings);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let annotations = parsed["annotations"].as_array().unwrap();
        // First finding has a suggestion
        let msg = annotations[0]["message"].as_str().unwrap();
        assert!(msg.contains("Suggestion: Fix the bug"));
        // Second finding has no suggestion
        let msg2 = annotations[1]["message"].as_str().unwrap();
        assert!(!msg2.contains("Suggestion"));
    }

    #[test]
    fn render_empty_findings() {
        let output = BitbucketFormatter.format(&[]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let annotations = parsed["annotations"].as_array().unwrap();
        assert!(annotations.is_empty());
    }

    #[tokio::test]
    async fn post_missing_env_vars_cascade() {
        // Missing BITBUCKET_WORKSPACE
        let env = Env::mock(Vec::<(&str, &str)>::new());
        let result = post_to_bitbucket(&sample_findings(), None, &env).await;
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("BITBUCKET_WORKSPACE"),
            "expected BITBUCKET_WORKSPACE error"
        );

        // Missing BITBUCKET_REPO_SLUG
        let env = Env::mock([("BITBUCKET_WORKSPACE", "test-ws")]);
        let result = post_to_bitbucket(&sample_findings(), None, &env).await;
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("BITBUCKET_REPO_SLUG"),
            "expected BITBUCKET_REPO_SLUG error"
        );

        // Missing BITBUCKET_COMMIT
        let env = Env::mock([
            ("BITBUCKET_WORKSPACE", "test-ws"),
            ("BITBUCKET_REPO_SLUG", "test-repo"),
        ]);
        let result = post_to_bitbucket(&sample_findings(), None, &env).await;
        assert!(result.is_err());
        assert!(
            result.unwrap_err().to_string().contains("BITBUCKET_COMMIT"),
            "expected BITBUCKET_COMMIT error"
        );

        // Missing BITBUCKET_TOKEN (outside Pipelines — no BITBUCKET_BUILD_NUMBER)
        let env = Env::mock([
            ("BITBUCKET_WORKSPACE", "test-ws"),
            ("BITBUCKET_REPO_SLUG", "test-repo"),
            ("BITBUCKET_COMMIT", "abc123"),
        ]);
        let result = post_to_bitbucket(&sample_findings(), None, &env).await;
        assert!(result.is_err());
        assert!(
            result.unwrap_err().to_string().contains("BITBUCKET_TOKEN"),
            "expected BITBUCKET_TOKEN error"
        );
    }

    #[tokio::test]
    async fn post_skips_token_in_pipelines() {
        // With BITBUCKET_BUILD_NUMBER set, BITBUCKET_TOKEN should NOT be required.
        // The call will fail at the HTTP level (no real server), but it should
        // not fail with a MissingEnvVar error for BITBUCKET_TOKEN.
        let env = Env::mock([
            ("BITBUCKET_WORKSPACE", "test-ws"),
            ("BITBUCKET_REPO_SLUG", "test-repo"),
            ("BITBUCKET_COMMIT", "abc123"),
            ("BITBUCKET_BUILD_NUMBER", "42"),
        ]);
        let result = post_to_bitbucket(&sample_findings(), None, &env).await;
        // Should fail with an API/connection error, not a missing env var error
        assert!(result.is_err());
        let err_msg = result.unwrap_err().to_string();
        assert!(
            !err_msg.contains("missing environment variable"),
            "should not require BITBUCKET_TOKEN in Pipelines, got: {err_msg}"
        );
    }
}
