//! Gate de bloqueio do orquestrador (TASK-09 — AD-5, CP-2).
//!
//! Impede que `sdd orchestrator <CARD-ID>` avance quando o último comentário
//! Jira do card for exatamente `/bloqueado` (case-sensitive, trim apenas).
//!
//! Responsabilidades:
//!   A) `is_blocked_marker` — lógica pura, sem I/O.
//!   B) `ensure_not_blocked` — integração com config + HTTP ao Jira.

use anyhow::{Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const BLOCK_GATE_HTTP_TIMEOUT: Duration = Duration::from_secs(10);

// ─── A. Lógica pura ──────────────────────────────────────────────────────────

/// Retorna `true` se o texto for exatamente `"/bloqueado"` após trim.
/// Case-sensitive (AD-5, confirmado CP-2).
pub fn is_blocked_marker(text: Option<&str>) -> bool {
    match text {
        Some(t) => t.trim() == "/bloqueado",
        None => false,
    }
}

// ─── B. Config + integração HTTP ─────────────────────────────────────────────

/// Configuração Jira extraída de `.sdd/bot/sdd-bot.config.yaml`.
#[derive(Debug, Deserialize)]
struct BotConfig {
    integrations: Option<BotIntegrations>,
    jira: Option<JiraCfg>,
    github: Option<GitHubCfg>,
}

#[derive(Debug, Deserialize)]
struct BotIntegrations {
    work_tracker: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
pub(crate) struct JiraCfg {
    /// URL base do workspace, ex: `https://<workspace>.atlassian.net`
    pub base_url: String,
    /// Token de API Jira (gerado em id.atlassian.com).
    pub api_token: String,
    /// E-mail do usuário Jira associado ao token.
    pub user: String,
}

#[derive(Debug, Deserialize, Clone)]
pub(crate) struct GitHubCfg {
    pub owner: String,
    pub repo: String,
    pub auth: Option<GitHubAuthCfg>,
}

#[derive(Debug, Deserialize, Clone)]
pub(crate) struct GitHubAuthCfg {
    pub token: Option<String>,
    pub token_env: Option<String>,
    pub app_id: Option<String>,
    pub installation_id: Option<String>,
    pub private_key: Option<String>,
    pub private_key_env: Option<String>,
    pub private_key_path: Option<String>,
}

#[derive(Debug, Deserialize)]
struct GitHubInstallationTokenResponse {
    token: String,
}

#[derive(Debug, Serialize)]
struct GitHubAppClaims {
    iat: u64,
    exp: u64,
    iss: String,
}

/// Carrega `.sdd/bot/sdd-bot.config.yaml`.
/// Retorna `None` se o arquivo não existir ou não puder ser lido.
fn load_bot_config(root: &Path) -> Option<BotConfig> {
    let path = root.join(".sdd").join("bot").join("sdd-bot.config.yaml");
    let content = std::fs::read_to_string(&path).ok()?;
    serde_yaml::from_str(&content).ok()
}

/// Busca o último comentário do card via API Jira REST v3.
/// Retorna `Ok(None)` se o card não tiver comentários.
fn fetch_last_comment(jira: &JiraCfg, card_id: &str) -> Result<Option<String>> {
    let url = format!(
        "{}/rest/api/3/issue/{}/comment?orderBy=-created&maxResults=1",
        jira.base_url.trim_end_matches('/'),
        card_id
    );

    let client = block_gate_http_client()?;
    let resp = send_read_request(
        client
            .get(&url)
            .basic_auth(&jira.user, Some(&jira.api_token))
            .header("Accept", "application/json"),
    )?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().unwrap_or_default();
        anyhow::bail!(
            "Jira API retornou status {} ({}) ao buscar comentários de {}: {}",
            status,
            http_error_kind(status),
            card_id,
            sanitize_error_body(&body)
        );
    }

    let json: serde_json::Value = resp.json()?;

    // Estrutura: { "comments": [ { "body": { "content": [...] } } ] }
    // O campo body do Jira API v3 é Atlassian Document Format (ADF).
    // Extraímos o texto plano percorrendo os nós de conteúdo.
    let comments = json.get("comments").and_then(|c| c.as_array());
    let first = match comments.and_then(|arr| arr.first()) {
        Some(c) => c,
        None => return Ok(None),
    };

    let text = extract_adf_text(first.get("body"));
    Ok(Some(text))
}

fn fetch_github_last_comment(github: &GitHubCfg, issue_number: u64) -> Result<Option<String>> {
    let url = format!(
        "https://api.github.com/repos/{}/{}/issues/{}/comments?per_page=100",
        github.owner, github.repo, issue_number
    );

    let token = github_token(github)?;
    let client = block_gate_http_client()?;
    let (first_page, last_url) = fetch_github_comments_page(&client, &url, &token, issue_number)?;
    let json = if let Some(last_url) = last_url {
        fetch_github_comments_page(&client, &last_url, &token, issue_number)?.0
    } else {
        first_page
    };

    let last = json.as_array().and_then(|items| items.last());
    Ok(last
        .and_then(|comment| comment.get("body"))
        .and_then(|body| body.as_str())
        .map(ToOwned::to_owned))
}

fn fetch_github_comments_page(
    client: &reqwest::blocking::Client,
    url: &str,
    token: &str,
    issue_number: u64,
) -> Result<(serde_json::Value, Option<String>)> {
    let resp = send_read_request(
        client
            .get(url)
            .bearer_auth(token)
            .header("Accept", "application/vnd.github+json")
            .header("X-GitHub-Api-Version", "2026-03-10")
            .header("User-Agent", "sdd-layer"),
    )?;

    let last_url = resp
        .headers()
        .get(reqwest::header::LINK)
        .and_then(|value| value.to_str().ok())
        .and_then(github_last_link_url);

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().unwrap_or_default();
        anyhow::bail!(
            "GitHub API retornou status {} ({}) ao buscar comentários da issue #{}: {}",
            status,
            http_error_kind(status),
            issue_number,
            sanitize_error_body(&body)
        );
    }

    Ok((resp.json()?, last_url))
}

fn github_last_link_url(link: &str) -> Option<String> {
    link.split(',').find_map(|raw_part| {
        let part = raw_part.trim();
        let has_last_rel = part.split(';').any(|segment| {
            let segment = segment.trim();
            segment == "rel=\"last\"" || segment == "rel=last"
        });
        if !has_last_rel {
            return None;
        }

        let start = part.find('<')? + 1;
        let end = part[start..].find('>')? + start;
        Some(part[start..end].to_owned())
    })
}

fn github_token(github: &GitHubCfg) -> Result<String> {
    let auth = github.auth.as_ref();
    if let Some(token) = auth.and_then(|a| a.token.as_ref()) {
        if !token.trim().is_empty() {
            return Ok(token.clone());
        }
    }

    let env_name = auth
        .and_then(|a| a.token_env.as_deref())
        .unwrap_or("GITHUB_TOKEN");
    if let Ok(token) = std::env::var(env_name).or_else(|_| std::env::var("GH_TOKEN")) {
        if !token.trim().is_empty() {
            return Ok(token);
        }
    }

    if auth.map(has_github_app_auth).unwrap_or(false) {
        return github_installation_token(github);
    }

    Err(anyhow::anyhow!(
        "token GitHub ausente: defina {env_name} ou GH_TOKEN, ou configure GitHub App"
    ))
}

fn has_github_app_auth(auth: &GitHubAuthCfg) -> bool {
    auth.app_id
        .as_deref()
        .is_some_and(|value| !value.trim().is_empty())
        && auth
            .installation_id
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
        && (auth
            .private_key
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
            || auth
                .private_key_env
                .as_deref()
                .is_some_and(|value| !value.trim().is_empty())
            || auth
                .private_key_path
                .as_deref()
                .is_some_and(|value| !value.trim().is_empty()))
}

fn github_installation_token(github: &GitHubCfg) -> Result<String> {
    let auth = github
        .auth
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("GitHub App auth ausente"))?;
    let installation_id = required_auth_value(auth.installation_id.as_deref(), "installation_id")?;
    let jwt = github_app_jwt(auth)?;
    let url = format!(
        "https://api.github.com/app/installations/{}/access_tokens",
        installation_id
    );

    let resp = block_gate_http_client()?
        .post(&url)
        .bearer_auth(jwt)
        .header("Accept", "application/vnd.github+json")
        .header("X-GitHub-Api-Version", "2026-03-10")
        .header("User-Agent", "sdd-layer")
        .send()?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().unwrap_or_default();
        anyhow::bail!(
            "GitHub App retornou status {} ({}) ao gerar installation token: {}",
            status,
            http_error_kind(status),
            sanitize_error_body(&body)
        );
    }

    let body: GitHubInstallationTokenResponse = resp.json()?;
    Ok(body.token)
}

fn github_app_jwt(auth: &GitHubAuthCfg) -> Result<String> {
    let app_id = required_auth_value(auth.app_id.as_deref(), "app_id")?;
    let private_key = github_private_key(auth)?;
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("relógio do sistema antes do UNIX_EPOCH")?
        .as_secs();
    let claims = GitHubAppClaims {
        iat: now.saturating_sub(60),
        exp: now + 540,
        iss: app_id.to_string(),
    };
    let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
    header.typ = Some("JWT".to_string());
    jsonwebtoken::encode(
        &header,
        &claims,
        &jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_bytes())
            .context("GitHub App private key inválida para RS256")?,
    )
    .context("falha ao assinar JWT do GitHub App")
}

fn github_private_key(auth: &GitHubAuthCfg) -> Result<String> {
    let raw = auth
        .private_key
        .clone()
        .or_else(|| {
            auth.private_key_env
                .as_ref()
                .and_then(|name| std::env::var(name).ok())
        })
        .or_else(|| {
            auth.private_key_path
                .as_ref()
                .and_then(|path| std::fs::read_to_string(path).ok())
        })
        .ok_or_else(|| anyhow::anyhow!("GitHub App private key ausente"))?;
    Ok(raw.replace("\\n", "\n"))
}

fn required_auth_value<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> {
    value
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| anyhow::anyhow!("GitHub App auth sem {field}"))
}

fn block_gate_http_client() -> Result<reqwest::blocking::Client> {
    reqwest::blocking::Client::builder()
        .timeout(BLOCK_GATE_HTTP_TIMEOUT)
        .build()
        .context("falha ao criar HTTP client do block gate")
}

fn send_read_request(
    request: reqwest::blocking::RequestBuilder,
) -> Result<reqwest::blocking::Response> {
    let max_attempts = 2;
    for attempt in 0..max_attempts {
        let cloned = request
            .try_clone()
            .ok_or_else(|| anyhow::anyhow!("HTTP request do block gate não pode ser clonada"))?;
        match cloned.send() {
            Ok(response)
                if attempt + 1 < max_attempts && should_retry_http_status(response.status()) =>
            {
                std::thread::sleep(Duration::from_millis(200));
            }
            Ok(response) => return Ok(response),
            Err(error)
                if attempt + 1 < max_attempts && (error.is_timeout() || error.is_connect()) =>
            {
                std::thread::sleep(Duration::from_millis(200));
            }
            Err(error) => return Err(error).context("falha HTTP no block gate"),
        }
    }
    unreachable!("loop de retry do block gate sempre retorna")
}

fn should_retry_http_status(status: StatusCode) -> bool {
    status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
}

fn http_error_kind(status: StatusCode) -> &'static str {
    match status.as_u16() {
        401 | 403 => "auth_forbidden",
        404 => "not_found",
        429 => "rate_limited",
        500..=599 => "server_error",
        _ => "http_error",
    }
}

/// Sanitiza o body de erro HTTP para evitar vazamento de credenciais em logs.
/// Trunca a 200 chars e remove possíveis prefixos Basic auth.
fn sanitize_error_body(body: &str) -> String {
    let redacted = redact_token_like(&body.replace("Basic ", "Basic [REDACTED] "));
    redacted.chars().take(200).collect()
}

fn redact_token_like(body: &str) -> String {
    let mut out = String::with_capacity(body.len());
    let mut index = 0;
    while index < body.len() {
        let rest = &body[index..];
        let matched = ["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"]
            .into_iter()
            .find(|prefix| rest.starts_with(prefix));
        if let Some(prefix) = matched {
            out.push_str(prefix);
            out.push_str("[REDACTED]");
            index += prefix.len();
            while index < body.len() {
                let ch = body[index..].chars().next().unwrap();
                if ch.is_ascii_alphanumeric() || ch == '_' {
                    index += ch.len_utf8();
                } else {
                    break;
                }
            }
        } else {
            let ch = rest.chars().next().unwrap();
            out.push(ch);
            index += ch.len_utf8();
        }
    }
    out
}

/// Extrai texto plano de um nó ADF (Atlassian Document Format) de forma recursiva.
fn extract_adf_text(node: Option<&serde_json::Value>) -> String {
    let node = match node {
        Some(n) => n,
        None => return String::new(),
    };

    // Nó de texto simples
    if let Some(text) = node.get("text").and_then(|t| t.as_str()) {
        return text.to_owned();
    }

    // Nó composto: percorre `content`
    if let Some(children) = node.get("content").and_then(|c| c.as_array()) {
        return children
            .iter()
            .map(|child| extract_adf_text(Some(child)))
            .collect::<Vec<_>>()
            .join("");
    }

    String::new()
}

pub fn parse_github_issue_number(card_id: &str) -> Option<u64> {
    let trimmed = card_id.trim();
    if let Some(rest) = trimmed.strip_prefix("GH-") {
        return rest.parse::<u64>().ok();
    }

    if let Some((_, number)) = trimmed.rsplit_once('#') {
        if trimmed.contains('/') {
            return number.parse::<u64>().ok();
        }
    }

    let marker = "github.com/";
    let after_host = trimmed.strip_prefix("https://").and_then(|value| {
        value
            .strip_prefix(marker)
            .or_else(|| value.strip_prefix("www.github.com/"))
    })?;
    let parts = after_host.split(['/', '?', '#']).collect::<Vec<_>>();
    if parts.len() >= 4 && parts[2] == "issues" {
        return parts[3].parse::<u64>().ok();
    }
    None
}

// ─── C. Gate público ─────────────────────────────────────────────────────────

/// Verifica se o card está bloqueado consultando o último comentário do provider configurado.
///
/// - Sem provider configurado: gate inativo — retorna `Ok(())` sem bloquear.
/// - Com config: busca o último comentário; se for `/bloqueado`, retorna erro.
pub fn ensure_not_blocked(root: &Path, card_id: &str) -> Result<()> {
    let Some(cfg) = load_bot_config(root) else {
        // Sem config externa: gate inativo — não bloqueia.
        return Ok(());
    };

    let selected = cfg
        .integrations
        .as_ref()
        .and_then(|i| i.work_tracker.as_deref());

    let last = match selected {
        Some("github") => {
            let github_cfg = cfg.github.as_ref().ok_or_else(|| {
                anyhow::anyhow!("block gate configurado para GitHub, mas seção github ausente")
            })?;
            let issue_number = parse_github_issue_number(card_id).ok_or_else(|| {
                anyhow::anyhow!(
                    "block gate GitHub não reconhece o card id `{}`; use GH-123, owner/repo#123 ou URL de issue GitHub",
                    card_id
                )
            })?;
            fetch_github_last_comment(github_cfg, issue_number)?
        }
        Some("jira") => {
            let jira_cfg = cfg.jira.as_ref().ok_or_else(|| {
                anyhow::anyhow!("block gate configurado para Jira, mas seção jira ausente")
            })?;
            fetch_last_comment(jira_cfg, card_id)?
        }
        Some(other) => anyhow::bail!("work_tracker desconhecido no block gate: {other}"),
        None if cfg.github.is_some() && parse_github_issue_number(card_id).is_some() => {
            let github_cfg = cfg.github.as_ref().expect("checked above");
            let issue_number = parse_github_issue_number(card_id).expect("checked above");
            fetch_github_last_comment(github_cfg, issue_number)?
        }
        None if cfg.jira.is_some() => {
            let jira_cfg = cfg.jira.as_ref().expect("checked above");
            fetch_last_comment(jira_cfg, card_id)?
        }
        None => return Ok(()),
    };

    if is_blocked_marker(last.as_deref()) {
        anyhow::bail!(
            "card {} está bloqueado (último comentário do work tracker: /bloqueado). \
             Remova o bloqueio antes de orquestrar.",
            card_id
        );
    }
    Ok(())
}

// ─── Unit tests ───────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::{
        github_last_link_url, http_error_kind, is_blocked_marker, parse_github_issue_number,
        sanitize_error_body, should_retry_http_status,
    };
    use reqwest::StatusCode;

    #[test]
    fn bloqueado_minusculo_bloqueia() {
        assert!(is_blocked_marker(Some("/bloqueado")));
    }

    #[test]
    fn bloqueado_maiusculo_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("/BLOQUEADO")));
    }

    #[test]
    fn bloqueado_mixed_case_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("/BloquEado")));
    }

    #[test]
    fn bloqueado_com_espacos_bloqueia() {
        assert!(is_blocked_marker(Some("  /bloqueado  ")));
    }

    #[test]
    fn aprovado_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("/aprovado")));
    }

    #[test]
    fn none_nao_bloqueia() {
        assert!(!is_blocked_marker(None));
    }

    #[test]
    fn vazio_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("")));
    }

    #[test]
    fn texto_parcial_nao_bloqueia() {
        assert!(!is_blocked_marker(Some("card bloqueado")));
    }

    #[test]
    fn parseia_ids_github_suportados() {
        assert_eq!(parse_github_issue_number("GH-123"), Some(123));
        assert_eq!(parse_github_issue_number("owner/repo#456"), Some(456));
        assert_eq!(
            parse_github_issue_number("https://github.com/owner/repo/issues/789"),
            Some(789)
        );
        assert_eq!(
            parse_github_issue_number("https://github.com/owner/repo/issues/789?x=1"),
            Some(789)
        );
    }

    #[test]
    fn nao_parseia_ids_nao_github() {
        assert_eq!(parse_github_issue_number("PROJ-1"), None);
        assert_eq!(
            parse_github_issue_number("https://gitlab.com/owner/repo/issues/1"),
            None
        );
    }

    #[test]
    fn extrai_rel_last_do_header_link_github() {
        let header = r#"<https://api.github.com/repos/acme/api/issues/1/comments?page=2&per_page=100>; rel="next", <https://api.github.com/repos/acme/api/issues/1/comments?page=4&per_page=100>; rel="last""#;
        assert_eq!(
            github_last_link_url(header),
            Some(
                "https://api.github.com/repos/acme/api/issues/1/comments?page=4&per_page=100"
                    .to_string()
            )
        );
    }

    #[test]
    fn sem_rel_last_no_header_link_github() {
        let header =
            r#"<https://api.github.com/repos/acme/api/issues/1/comments?page=2>; rel="next""#;
        assert_eq!(github_last_link_url(header), None);
    }

    #[test]
    fn classifica_status_http_externo() {
        assert_eq!(http_error_kind(StatusCode::UNAUTHORIZED), "auth_forbidden");
        assert_eq!(http_error_kind(StatusCode::FORBIDDEN), "auth_forbidden");
        assert_eq!(http_error_kind(StatusCode::NOT_FOUND), "not_found");
        assert_eq!(
            http_error_kind(StatusCode::TOO_MANY_REQUESTS),
            "rate_limited"
        );
        assert_eq!(
            http_error_kind(StatusCode::INTERNAL_SERVER_ERROR),
            "server_error"
        );
    }

    #[test]
    fn retry_somente_para_rate_limit_ou_5xx() {
        assert!(should_retry_http_status(StatusCode::TOO_MANY_REQUESTS));
        assert!(should_retry_http_status(StatusCode::BAD_GATEWAY));
        assert!(!should_retry_http_status(StatusCode::UNAUTHORIZED));
        assert!(!should_retry_http_status(StatusCode::FORBIDDEN));
        assert!(!should_retry_http_status(StatusCode::NOT_FOUND));
    }

    #[test]
    fn github_configurado_rejeita_card_id_nao_github() {
        let tmp = std::env::temp_dir().join("sdd_test_github_invalid_card_block_gate");
        std::fs::remove_dir_all(&tmp).ok();
        let config_dir = tmp.join(".sdd/bot");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("sdd-bot.config.yaml"),
            r#"
integrations:
  work_tracker: github
github:
  owner: acme
  repo: api
  auth:
    token: ghp_test
"#,
        )
        .unwrap();

        let err = super::ensure_not_blocked(&tmp, "PROJ-1").unwrap_err();
        assert!(format!("{err:#}").contains("não reconhece o card id"));
        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn jira_configurado_nao_faz_fallback_para_github() {
        let tmp = std::env::temp_dir().join("sdd_test_jira_no_github_fallback_block_gate");
        std::fs::remove_dir_all(&tmp).ok();
        let config_dir = tmp.join(".sdd/bot");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("sdd-bot.config.yaml"),
            r#"
integrations:
  work_tracker: jira
github:
  owner: acme
  repo: api
  auth:
    token: ghp_test
"#,
        )
        .unwrap();

        let err =
            super::ensure_not_blocked(&tmp, "https://github.com/acme/api/issues/42").unwrap_err();
        assert!(format!("{err:#}").contains("seção jira ausente"));
        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn sanitize_error_body_redige_tokens_e_trunca() {
        let body = format!("token ghp_{} Basic abc {}", "a".repeat(40), "x".repeat(260));
        let sanitized = sanitize_error_body(&body);
        assert!(sanitized.contains("ghp_[REDACTED]"));
        assert!(sanitized.contains("Basic [REDACTED]"));
        assert!(!sanitized.contains(&"a".repeat(40)));
        assert!(sanitized.chars().count() <= 200);
    }

    #[test]
    fn sem_config_jira_nao_bloqueia() {
        let tmp = std::env::temp_dir().join("sdd_test_no_config_block_gate");
        std::fs::create_dir_all(&tmp).unwrap();
        // Diretório sem .sdd/bot/sdd-bot.config.yaml → gate inativo
        let result = super::ensure_not_blocked(&tmp, "PROJ-1");
        assert!(
            result.is_ok(),
            "Esperado Ok(()), obtido: {:?}",
            result.err()
        );
        std::fs::remove_dir_all(&tmp).ok();
    }
}
