use std::ffi::OsStr;
use std::fs;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{bail, Result};
use serde::Serialize;

use crate::output::CommandReport;

pub(crate) const COMMANDS_DIR: &str = ".ccd/commands";
pub(crate) const CHECK_PREFIX: &str = "check-";
pub(crate) const REPO_NATIVE_CHECK_TIMEOUT_ENV: &str = "CCD_REPO_NATIVE_CHECK_TIMEOUT";

const DEFAULT_REPO_NATIVE_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
const REPO_NATIVE_CHECK_POLL_INTERVAL: Duration = Duration::from_millis(25);

#[derive(Serialize)]
pub struct CheckReport {
    pub(crate) command: &'static str,
    pub(crate) ok: bool,
    pub(crate) repo_root: String,
    pub(crate) surface: CheckSurfaceView,
    pub(crate) failures: usize,
    pub(crate) checks: Vec<RegisteredCheckReport>,
}

#[derive(Serialize)]
pub(crate) struct CheckSurfaceView {
    pub(crate) path: String,
    pub(crate) status: &'static str,
    pub(crate) prefix: &'static str,
    pub(crate) entries: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) note: Option<String>,
}

#[derive(Serialize)]
pub(crate) struct RegisteredCheckReport {
    pub(crate) id: String,
    pub(crate) path: String,
    pub(crate) status: &'static str,
    pub(crate) severity: &'static str,
    pub(crate) message: String,
    pub(crate) duration_ms: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) exit_code: Option<i32>,
    pub(crate) stdout: String,
    pub(crate) stderr: String,
}

struct RegisteredCheck {
    id: String,
    relative_path: String,
    absolute_path: PathBuf,
}

struct CheckRunOptions {
    timeout: Duration,
}

enum DiscoverChecksResult {
    Registered(Vec<RegisteredCheck>),
    Invalid(RegisteredCheckReport),
}

impl CommandReport for CheckReport {
    fn exit_code(&self) -> ExitCode {
        if self.failures > 0 {
            ExitCode::from(1)
        } else {
            ExitCode::SUCCESS
        }
    }

    fn render_text(&self) {
        if let Some(note) = &self.surface.note {
            println!("{note}");
        }

        for check in &self.checks {
            let label = match check.status {
                "pass" => "PASS",
                "warn" => "WARN",
                "fail" => "FAIL",
                _ => "INFO",
            };
            println!("[{label}] {}", check.message);

            if check.status == "fail" {
                let stdout = check.stdout.trim_end();
                if !stdout.is_empty() {
                    println!("stdout:");
                    println!("{stdout}");
                }

                let stderr = check.stderr.trim_end();
                if !stderr.is_empty() {
                    println!("stderr:");
                    println!("{stderr}");
                }
            }
        }

        println!(
            "Check summary: {} failure(s), {} registered check(s).",
            self.failures,
            self.surface.entries.len()
        );
    }
}

pub fn run(repo_root: &Path) -> Result<CheckReport> {
    let options = CheckRunOptions::from_env()?;
    let surface_path = repo_root.join(COMMANDS_DIR);
    if !surface_path.exists() {
        return Ok(CheckReport {
            command: "check",
            ok: true,
            repo_root: repo_root.display().to_string(),
            surface: CheckSurfaceView {
                path: surface_path.display().to_string(),
                status: "absent",
                prefix: CHECK_PREFIX,
                entries: Vec::new(),
                note: Some(format!(
                    "No repo-native `{CHECK_PREFIX}` commands registered under {COMMANDS_DIR}."
                )),
            },
            failures: 0,
            checks: Vec::new(),
        });
    }

    let surface_metadata = fs::symlink_metadata(&surface_path)?;
    if !surface_metadata.file_type().is_dir() {
        let note = format!(
            "Repo-native command surface must be a directory at {}; found a non-directory entry instead.",
            surface_path.display()
        );
        return Ok(CheckReport {
            command: "check",
            ok: false,
            repo_root: repo_root.display().to_string(),
            surface: CheckSurfaceView {
                path: surface_path.display().to_string(),
                status: "invalid",
                prefix: CHECK_PREFIX,
                entries: Vec::new(),
                note: None,
            },
            failures: 1,
            checks: vec![RegisteredCheckReport {
                id: "surface".to_owned(),
                path: surface_path.display().to_string(),
                status: "fail",
                severity: "error",
                message: note,
                duration_ms: 0,
                exit_code: None,
                stdout: String::new(),
                stderr: String::new(),
            }],
        });
    }

    let registered = match discover_checks(repo_root, &surface_path)? {
        DiscoverChecksResult::Registered(registered) => registered,
        DiscoverChecksResult::Invalid(failure) => {
            return Ok(CheckReport {
                command: "check",
                ok: false,
                repo_root: repo_root.display().to_string(),
                surface: CheckSurfaceView {
                    path: surface_path.display().to_string(),
                    status: "invalid",
                    prefix: CHECK_PREFIX,
                    entries: Vec::new(),
                    note: None,
                },
                failures: 1,
                checks: vec![failure],
            });
        }
    };
    let mut checks = Vec::new();
    for entry in &registered {
        checks.push(run_registered_check(repo_root, entry, options.timeout));
    }

    let failures = checks
        .iter()
        .filter(|check| check.severity == "error")
        .count();

    let note = if registered.is_empty() {
        Some(format!(
            "No repo-native `{CHECK_PREFIX}` commands registered under {COMMANDS_DIR}."
        ))
    } else {
        None
    };

    Ok(CheckReport {
        command: "check",
        ok: failures == 0,
        repo_root: repo_root.display().to_string(),
        surface: CheckSurfaceView {
            path: surface_path.display().to_string(),
            status: "loaded",
            prefix: CHECK_PREFIX,
            entries: registered
                .iter()
                .map(|entry| entry.relative_path.clone())
                .collect(),
            note,
        },
        failures,
        checks,
    })
}

fn discover_checks(repo_root: &Path, surface_path: &Path) -> Result<DiscoverChecksResult> {
    let mut registered = Vec::new();

    for entry in fs::read_dir(surface_path)? {
        let entry = entry?;
        let file_name = entry.file_name();
        match classify_check_entry_name(&file_name) {
            CheckEntryName::Ignore => continue,
            CheckEntryName::Invalid => {
                let absolute_path = entry.path();
                let relative_path = path_relative_to(repo_root, &absolute_path);
                return Ok(DiscoverChecksResult::Invalid(fail_report(
                    "surface",
                    &relative_path,
                    format!(
                        "Registered check filenames under {COMMANDS_DIR} must be valid UTF-8 without control characters; rename the invalid entry and rerun."
                    ),
                )));
            }
            CheckEntryName::Valid(id) => {
                let absolute_path = entry.path();
                let relative_path = path_relative_to(repo_root, &absolute_path);
                registered.push(RegisteredCheck {
                    id,
                    relative_path,
                    absolute_path,
                });
            }
        }
    }

    registered.sort_by(|left, right| left.id.cmp(&right.id));
    Ok(DiscoverChecksResult::Registered(registered))
}

fn run_registered_check(
    repo_root: &Path,
    entry: &RegisteredCheck,
    timeout: Duration,
) -> RegisteredCheckReport {
    match fs::symlink_metadata(&entry.absolute_path) {
        Ok(metadata) => {
            if !metadata.file_type().is_file() {
                return fail_report(
                    &entry.id,
                    &entry.relative_path,
                    format!(
                        "Registered check `{}` must be a regular executable file under {COMMANDS_DIR}.",
                        entry.id
                    ),
                );
            }

            if metadata.permissions().mode() & 0o111 == 0 {
                return fail_report(
                    &entry.id,
                    &entry.relative_path,
                    format!("Registered check `{}` is not executable.", entry.id),
                );
            }

            let started = Instant::now();
            match run_check_process(repo_root, entry, timeout, started) {
                Ok(output) => {
                    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
                    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
                    if output.status.success() {
                        RegisteredCheckReport {
                            id: entry.id.clone(),
                            path: entry.relative_path.clone(),
                            status: "pass",
                            severity: "info",
                            message: format!(
                                "Repo-native check `{}` passed ({} ms).",
                                entry.id, output.duration_ms
                            ),
                            duration_ms: output.duration_ms,
                            exit_code: output.status.code(),
                            stdout,
                            stderr,
                        }
                    } else {
                        let status_suffix = match output.status.code() {
                            Some(code) => format!("exit code {code}"),
                            None => "termination without an exit code".to_owned(),
                        };
                        RegisteredCheckReport {
                            id: entry.id.clone(),
                            path: entry.relative_path.clone(),
                            status: "fail",
                            severity: "error",
                            message: format!(
                                "Repo-native check `{}` failed with {} ({} ms).",
                                entry.id, status_suffix, output.duration_ms
                            ),
                            duration_ms: output.duration_ms,
                            exit_code: output.status.code(),
                            stdout,
                            stderr,
                        }
                    }
                }
                Err(CheckExecutionError::TimedOut {
                    output,
                    duration_ms,
                }) => RegisteredCheckReport {
                    id: entry.id.clone(),
                    path: entry.relative_path.clone(),
                    status: "fail",
                    severity: "error",
                    message: format!(
                        "Repo-native check `{}` timed out after {} ({} ms).",
                        entry.id,
                        format_duration(timeout),
                        duration_ms
                    ),
                    duration_ms,
                    exit_code: output.status.code(),
                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
                },
                Err(CheckExecutionError::Io(error)) => fail_report(
                    &entry.id,
                    &entry.relative_path,
                    format!(
                        "Failed to execute repo-native check `{}`: {error}",
                        entry.id
                    ),
                ),
            }
        }
        Err(error) => fail_report(
            &entry.id,
            &entry.relative_path,
            format!(
                "Failed to inspect repo-native check `{}`: {error}",
                entry.id
            ),
        ),
    }
}

fn fail_report(id: &str, path: &str, message: String) -> RegisteredCheckReport {
    RegisteredCheckReport {
        id: id.to_owned(),
        path: path.to_owned(),
        status: "fail",
        severity: "error",
        message,
        duration_ms: 0,
        exit_code: None,
        stdout: String::new(),
        stderr: String::new(),
    }
}

fn path_relative_to(repo_root: &Path, path: &Path) -> String {
    path.strip_prefix(repo_root)
        .unwrap_or(path)
        .display()
        .to_string()
}

enum CheckEntryName {
    Ignore,
    Valid(String),
    Invalid,
}

fn classify_check_entry_name(file_name: &OsStr) -> CheckEntryName {
    let bytes = file_name.as_bytes();
    if bytes.starts_with(b".") || !bytes.starts_with(CHECK_PREFIX.as_bytes()) {
        return CheckEntryName::Ignore;
    }

    match std::str::from_utf8(bytes) {
        Ok(value) if value.chars().any(|ch| ch.is_control()) => CheckEntryName::Invalid,
        Ok(value) => CheckEntryName::Valid(value.to_owned()),
        Err(_) => CheckEntryName::Invalid,
    }
}

impl CheckRunOptions {
    fn from_env() -> Result<Self> {
        Ok(Self {
            timeout: repo_native_check_timeout()?,
        })
    }
}

struct CompletedCheckOutput {
    status: std::process::ExitStatus,
    stdout: Vec<u8>,
    stderr: Vec<u8>,
    duration_ms: u64,
}

enum CheckExecutionError {
    Io(io::Error),
    TimedOut {
        output: std::process::Output,
        duration_ms: u64,
    },
}

fn repo_native_check_timeout() -> Result<Duration> {
    let raw = match std::env::var(REPO_NATIVE_CHECK_TIMEOUT_ENV) {
        Ok(value) => value,
        Err(std::env::VarError::NotPresent) => return Ok(DEFAULT_REPO_NATIVE_CHECK_TIMEOUT),
        Err(std::env::VarError::NotUnicode(_)) => {
            bail!(
                "invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV}: expected valid UTF-8 with an optional `ms`, `s`, or `m` suffix"
            )
        }
    };

    parse_repo_native_check_timeout(&raw)
}

fn parse_repo_native_check_timeout(raw: &str) -> Result<Duration> {
    let value = raw.trim();
    if value.is_empty() {
        bail!(
            "invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV}: expected a positive integer with an optional `ms`, `s`, or `m` suffix"
        );
    }

    let (digits, unit) = if let Some(stripped) = value.strip_suffix("ms") {
        (stripped, "ms")
    } else if let Some(stripped) = value.strip_suffix('s') {
        (stripped, "s")
    } else if let Some(stripped) = value.strip_suffix('m') {
        (stripped, "m")
    } else {
        (value, "s")
    };

    let amount = digits.parse::<u64>().map_err(|_| {
        anyhow::anyhow!(
            "invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV} value `{value}`: expected a positive integer with an optional `ms`, `s`, or `m` suffix"
        )
    })?;
    if amount == 0 {
        bail!(
            "invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV} value `{value}`: timeout must be greater than zero"
        );
    }

    match unit {
        "ms" => Ok(Duration::from_millis(amount)),
        "s" => Ok(Duration::from_secs(amount)),
        "m" => Ok(Duration::from_secs(amount.checked_mul(60).ok_or_else(
            || {
                anyhow::anyhow!(
                    "invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV} value `{value}`: timeout is too large"
                )
            },
        )?)),
        _ => unreachable!(),
    }
}

fn run_check_process(
    repo_root: &Path,
    entry: &RegisteredCheck,
    timeout: Duration,
    started: Instant,
) -> std::result::Result<CompletedCheckOutput, CheckExecutionError> {
    let mut child = Command::new(&entry.absolute_path)
        .current_dir(repo_root)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(CheckExecutionError::Io)?;

    loop {
        match child.try_wait() {
            Ok(Some(_)) => {
                let output = child.wait_with_output().map_err(CheckExecutionError::Io)?;
                return Ok(CompletedCheckOutput {
                    status: output.status,
                    stdout: output.stdout,
                    stderr: output.stderr,
                    duration_ms: elapsed_ms(started),
                });
            }
            Ok(None) if started.elapsed() >= timeout => {
                let _ = child.kill();
                let output = child.wait_with_output().map_err(CheckExecutionError::Io)?;
                return Err(CheckExecutionError::TimedOut {
                    output,
                    duration_ms: elapsed_ms(started),
                });
            }
            Ok(None) => thread::sleep(next_poll_interval(started.elapsed(), timeout)),
            Err(error) => return Err(CheckExecutionError::Io(error)),
        }
    }
}

fn next_poll_interval(elapsed: Duration, timeout: Duration) -> Duration {
    timeout
        .saturating_sub(elapsed)
        .min(REPO_NATIVE_CHECK_POLL_INTERVAL)
}

fn elapsed_ms(started: Instant) -> u64 {
    started.elapsed().as_millis().try_into().unwrap_or(u64::MAX)
}

fn format_duration(duration: Duration) -> String {
    if duration.subsec_nanos() == 0 && duration.as_secs() > 0 {
        if duration.as_secs().is_multiple_of(60) {
            format!("{}m", duration.as_secs() / 60)
        } else {
            format!("{}s", duration.as_secs())
        }
    } else {
        format!("{}ms", duration.as_millis())
    }
}
