use std::path::Path;
use std::time::Duration;

use compliance_core::models::{Finding, ScanType, Severity};
use compliance_core::traits::{ScanOutput, Scanner};
use compliance_core::CoreError;
use tokio::process::Command;

use crate::pipeline::dedup;

/// Timeout for each individual lint command
const LINT_TIMEOUT: Duration = Duration::from_secs(120);

pub struct LintScanner;

impl Scanner for LintScanner {
    fn name(&self) -> &str {
        "lint"
    }

    fn scan_type(&self) -> ScanType {
        ScanType::Lint
    }

    #[tracing::instrument(skip_all)]
    async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
        let mut all_findings = Vec::new();

        // Detect which languages are present and run appropriate linters
        if has_rust_project(repo_path) {
            match run_clippy(repo_path, repo_id).await {
                Ok(findings) => all_findings.extend(findings),
                Err(e) => tracing::warn!("Clippy failed: {e}"),
            }
        }

        if has_js_project(repo_path) {
            match run_eslint(repo_path, repo_id).await {
                Ok(findings) => all_findings.extend(findings),
                Err(e) => tracing::warn!("ESLint failed: {e}"),
            }
        }

        if has_python_project(repo_path) {
            match run_ruff(repo_path, repo_id).await {
                Ok(findings) => all_findings.extend(findings),
                Err(e) => tracing::warn!("Ruff failed: {e}"),
            }
        }

        Ok(ScanOutput {
            findings: all_findings,
            sbom_entries: Vec::new(),
        })
    }
}

fn has_rust_project(repo_path: &Path) -> bool {
    repo_path.join("Cargo.toml").exists()
}

fn has_js_project(repo_path: &Path) -> bool {
    // Only run if eslint is actually installed in the project
    repo_path.join("package.json").exists() && repo_path.join("node_modules/.bin/eslint").exists()
}

fn has_python_project(repo_path: &Path) -> bool {
    repo_path.join("pyproject.toml").exists()
        || repo_path.join("setup.py").exists()
        || repo_path.join("requirements.txt").exists()
}

/// Run a command with a timeout, returning its output or an error
async fn run_with_timeout(
    child: tokio::process::Child,
    scanner_name: &str,
) -> Result<std::process::Output, CoreError> {
    let result = tokio::time::timeout(LINT_TIMEOUT, child.wait_with_output()).await;
    match result {
        Ok(Ok(output)) => Ok(output),
        Ok(Err(e)) => Err(CoreError::Scanner {
            scanner: scanner_name.to_string(),
            source: Box::new(e),
        }),
        Err(_) => {
            // Process is dropped here which sends SIGKILL on Unix
            Err(CoreError::Scanner {
                scanner: scanner_name.to_string(),
                source: Box::new(std::io::Error::new(
                    std::io::ErrorKind::TimedOut,
                    format!("{scanner_name} timed out after {}s", LINT_TIMEOUT.as_secs()),
                )),
            })
        }
    }
}

// ── Clippy ──────────────────────────────────────────────

async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, CoreError> {
    let child = Command::new("cargo")
        .args([
            "clippy",
            "--message-format=json",
            "--quiet",
            "--",
            "-W",
            "clippy::all",
        ])
        .current_dir(repo_path)
        .env("RUSTC_WRAPPER", "")
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| CoreError::Scanner {
            scanner: "clippy".to_string(),
            source: Box::new(e),
        })?;

    let output = run_with_timeout(child, "clippy").await?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut findings = Vec::new();

    for line in stdout.lines() {
        let msg: serde_json::Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };

        if msg.get("reason").and_then(|v| v.as_str()) != Some("compiler-message") {
            continue;
        }

        let message = match msg.get("message") {
            Some(m) => m,
            None => continue,
        };

        let level = message.get("level").and_then(|v| v.as_str()).unwrap_or("");

        if level != "warning" && level != "error" {
            continue;
        }

        let text = message
            .get("message")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let code = message
            .get("code")
            .and_then(|v| v.get("code"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        if text.starts_with("aborting due to") || code.is_empty() {
            continue;
        }

        let (file_path, line_number) = extract_primary_span(message);

        let severity = if level == "error" {
            Severity::High
        } else {
            Severity::Low
        };

        let fingerprint = dedup::compute_fingerprint(&[
            repo_id,
            "clippy",
            &code,
            &file_path,
            &line_number.to_string(),
        ]);

        let mut finding = Finding::new(
            repo_id.to_string(),
            fingerprint,
            "clippy".to_string(),
            ScanType::Lint,
            format!("[clippy] {text}"),
            text,
            severity,
        );
        finding.rule_id = Some(code);
        if !file_path.is_empty() {
            finding.file_path = Some(file_path);
        }
        if line_number > 0 {
            finding.line_number = Some(line_number);
        }
        findings.push(finding);
    }

    Ok(findings)
}

fn extract_primary_span(message: &serde_json::Value) -> (String, u32) {
    let spans = match message.get("spans").and_then(|v| v.as_array()) {
        Some(s) => s,
        None => return (String::new(), 0),
    };

    for span in spans {
        if span.get("is_primary").and_then(|v| v.as_bool()) == Some(true) {
            let file = span
                .get("file_name")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let line = span.get("line_start").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
            return (file, line);
        }
    }

    (String::new(), 0)
}

// ── ESLint ──────────────────────────────────────────────

async fn run_eslint(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, CoreError> {
    // Use the project-local eslint binary directly, not npx (which can hang downloading)
    let eslint_bin = repo_path.join("node_modules/.bin/eslint");
    let child = Command::new(eslint_bin)
        .args([".", "--format", "json", "--no-error-on-unmatched-pattern"])
        .current_dir(repo_path)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| CoreError::Scanner {
            scanner: "eslint".to_string(),
            source: Box::new(e),
        })?;

    let output = run_with_timeout(child, "eslint").await?;

    if output.stdout.is_empty() {
        return Ok(Vec::new());
    }

    let results: Vec<EslintFileResult> = serde_json::from_slice(&output.stdout).unwrap_or_default();

    let mut findings = Vec::new();
    for file_result in results {
        for msg in file_result.messages {
            let severity = match msg.severity {
                2 => Severity::Medium,
                _ => Severity::Low,
            };

            let rule_id = msg.rule_id.unwrap_or_default();
            let fingerprint = dedup::compute_fingerprint(&[
                repo_id,
                "eslint",
                &rule_id,
                &file_result.file_path,
                &msg.line.to_string(),
            ]);

            let mut finding = Finding::new(
                repo_id.to_string(),
                fingerprint,
                "eslint".to_string(),
                ScanType::Lint,
                format!("[eslint] {}", msg.message),
                msg.message,
                severity,
            );
            finding.rule_id = Some(rule_id);
            finding.file_path = Some(file_result.file_path.clone());
            finding.line_number = Some(msg.line);
            findings.push(finding);
        }
    }

    Ok(findings)
}

#[derive(serde::Deserialize)]
struct EslintFileResult {
    #[serde(rename = "filePath")]
    file_path: String,
    messages: Vec<EslintMessage>,
}

#[derive(serde::Deserialize)]
struct EslintMessage {
    #[serde(rename = "ruleId")]
    rule_id: Option<String>,
    severity: u8,
    message: String,
    line: u32,
}

// ── Ruff ────────────────────────────────────────────────

async fn run_ruff(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, CoreError> {
    let child = Command::new("ruff")
        .args(["check", ".", "--output-format", "json", "--exit-zero"])
        .current_dir(repo_path)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| CoreError::Scanner {
            scanner: "ruff".to_string(),
            source: Box::new(e),
        })?;

    let output = run_with_timeout(child, "ruff").await?;

    if output.stdout.is_empty() {
        return Ok(Vec::new());
    }

    let results: Vec<RuffResult> = serde_json::from_slice(&output.stdout).unwrap_or_default();

    let findings = results
        .into_iter()
        .map(|r| {
            let severity = if r.code.starts_with('E') || r.code.starts_with('F') {
                Severity::Medium
            } else {
                Severity::Low
            };

            let fingerprint = dedup::compute_fingerprint(&[
                repo_id,
                "ruff",
                &r.code,
                &r.filename,
                &r.location.row.to_string(),
            ]);

            let mut finding = Finding::new(
                repo_id.to_string(),
                fingerprint,
                "ruff".to_string(),
                ScanType::Lint,
                format!("[ruff] {}: {}", r.code, r.message),
                r.message,
                severity,
            );
            finding.rule_id = Some(r.code);
            finding.file_path = Some(r.filename);
            finding.line_number = Some(r.location.row);
            finding
        })
        .collect();

    Ok(findings)
}

#[derive(serde::Deserialize)]
struct RuffResult {
    code: String,
    message: String,
    filename: String,
    location: RuffLocation,
}

#[derive(serde::Deserialize)]
struct RuffLocation {
    row: u32,
}
