use std::path::PathBuf;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
	#[serde(rename = "job")]
	pub jobs: Vec<JobConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobConfig {
	pub name: String,
	pub cron: String,
	pub command: String,
	#[serde(default)]
	pub dir: Option<String>,
	#[serde(default)]
	pub on_error: ErrorPolicy,
	#[serde(default)]
	pub overlap: bool,
	#[serde(default)]
	pub timeout: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ErrorPolicy {
	#[default]
	Continue,
	Backoff,
	Stop,
}

impl Config {
	pub fn load(path: &PathBuf) -> Result<Self, String> {
		let content = std::fs::read_to_string(path)
			.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
		toml::from_str(&content)
			.map_err(|e| format!("failed to parse {}: {}", path.display(), e))
	}

	pub fn default_path() -> PathBuf {
		let paths = muzan::DaemonPaths::new("koku");
		paths.config_dir().join("config.toml")
	}

	pub fn validate(&self) -> Vec<String> {
		let mut errors = Vec::new();
		let mut names = std::collections::HashSet::new();

		for job in &self.jobs {
			if job.name.is_empty() {
				errors.push("job has empty name".to_string());
			}
			if !names.insert(&job.name) {
				errors.push(format!("duplicate job name: {}", job.name));
			}
			if let Err(e) = croner::Cron::new(&job.cron).parse() {
				errors.push(format!("job '{}': invalid cron expression '{}': {}", job.name, job.cron, e));
			}
			if job.command.is_empty() {
				errors.push(format!("job '{}': empty command", job.name));
			}
			if let Some(ref timeout) = job.timeout {
				if parse_duration(timeout).is_none() {
					errors.push(format!("job '{}': invalid timeout '{}'", job.name, timeout));
				}
			}
		}

		errors
	}
}

pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
	let s = s.trim();
	if s.is_empty() {
		return None;
	}

	let (num_str, unit) = if s.ends_with("ms") {
		(&s[..s.len() - 2], "ms")
	} else {
		let split = s.len() - 1;
		if split == 0 {
			return None;
		}
		(&s[..split], &s[split..])
	};

	let num: u64 = num_str.trim().parse().ok()?;

	match unit {
		"s" => Some(std::time::Duration::from_secs(num)),
		"m" => Some(std::time::Duration::from_secs(num * 60)),
		"h" => Some(std::time::Duration::from_secs(num * 3600)),
		"ms" => Some(std::time::Duration::from_millis(num)),
		_ => None,
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn parse_duration_seconds() {
		assert_eq!(parse_duration("30s"), Some(std::time::Duration::from_secs(30)));
	}

	#[test]
	fn parse_duration_minutes() {
		assert_eq!(parse_duration("5m"), Some(std::time::Duration::from_secs(300)));
	}

	#[test]
	fn parse_duration_hours() {
		assert_eq!(parse_duration("2h"), Some(std::time::Duration::from_secs(7200)));
	}

	#[test]
	fn parse_duration_millis() {
		assert_eq!(parse_duration("500ms"), Some(std::time::Duration::from_millis(500)));
	}

	#[test]
	fn parse_duration_with_whitespace() {
		assert_eq!(parse_duration("  10s  "), Some(std::time::Duration::from_secs(10)));
	}

	#[test]
	fn parse_duration_invalid_unit() {
		assert_eq!(parse_duration("10d"), None);
	}

	#[test]
	fn parse_duration_empty() {
		assert_eq!(parse_duration(""), None);
	}

	#[test]
	fn parse_duration_no_number() {
		assert_eq!(parse_duration("s"), None);
	}

	#[test]
	fn parse_duration_garbage() {
		assert_eq!(parse_duration("abc"), None);
	}

	fn make_job(name: &str, cron: &str, command: &str) -> JobConfig {
		JobConfig {
			name: name.to_string(),
			cron: cron.to_string(),
			command: command.to_string(),
			dir: None,
			on_error: ErrorPolicy::Continue,
			overlap: false,
			timeout: None,
		}
	}

	#[test]
	fn validate_valid_config() {
		let config = Config {
			jobs: vec![make_job("test", "* * * * *", "echo hi")],
		};
		assert!(config.validate().is_empty());
	}

	#[test]
	fn validate_empty_name() {
		let config = Config {
			jobs: vec![make_job("", "* * * * *", "echo hi")],
		};
		let errors = config.validate();
		assert!(errors.iter().any(|e| e.contains("empty name")));
	}

	#[test]
	fn validate_duplicate_names() {
		let config = Config {
			jobs: vec![
				make_job("dup", "* * * * *", "echo a"),
				make_job("dup", "* * * * *", "echo b"),
			],
		};
		let errors = config.validate();
		assert!(errors.iter().any(|e| e.contains("duplicate")));
	}

	#[test]
	fn validate_bad_cron() {
		let config = Config {
			jobs: vec![make_job("test", "not a cron", "echo hi")],
		};
		let errors = config.validate();
		assert!(errors.iter().any(|e| e.contains("invalid cron")));
	}

	#[test]
	fn validate_empty_command() {
		let config = Config {
			jobs: vec![make_job("test", "* * * * *", "")],
		};
		let errors = config.validate();
		assert!(errors.iter().any(|e| e.contains("empty command")));
	}

	#[test]
	fn validate_bad_timeout() {
		let mut job = make_job("test", "* * * * *", "echo hi");
		job.timeout = Some("invalid".to_string());
		let config = Config { jobs: vec![job] };
		let errors = config.validate();
		assert!(errors.iter().any(|e| e.contains("invalid timeout")));
	}

	#[test]
	fn validate_good_timeout() {
		let mut job = make_job("test", "* * * * *", "echo hi");
		job.timeout = Some("30m".to_string());
		let config = Config { jobs: vec![job] };
		assert!(config.validate().is_empty());
	}

	#[test]
	fn validate_multiple_errors() {
		let config = Config {
			jobs: vec![
				make_job("", "bad cron", ""),
			],
		};
		let errors = config.validate();
		assert!(errors.len() >= 3);
	}

	#[test]
	fn error_policy_default_is_continue() {
		assert_eq!(ErrorPolicy::default(), ErrorPolicy::Continue);
	}

	#[test]
	fn config_deserialize_toml() {
		let toml_str = r#"
[[job]]
name = "test"
cron = "* * * * *"
command = "echo hi"
on_error = "backoff"
overlap = true
timeout = "5m"
"#;
		let config: Config = toml::from_str(toml_str).unwrap();
		assert_eq!(config.jobs.len(), 1);
		assert_eq!(config.jobs[0].name, "test");
		assert_eq!(config.jobs[0].on_error, ErrorPolicy::Backoff);
		assert!(config.jobs[0].overlap);
		assert_eq!(config.jobs[0].timeout.as_deref(), Some("5m"));
	}

	#[test]
	fn config_deserialize_defaults() {
		let toml_str = r#"
[[job]]
name = "minimal"
cron = "0 * * * *"
command = "ls"
"#;
		let config: Config = toml::from_str(toml_str).unwrap();
		let job = &config.jobs[0];
		assert_eq!(job.on_error, ErrorPolicy::Continue);
		assert!(!job.overlap);
		assert!(job.timeout.is_none());
		assert!(job.dir.is_none());
	}
}

pub const INIT_CONFIG: &str = r#"# koku — cron job configuration
# Docs: see `ku check` to validate, `ku status` to view job states

# [[job]]
# name = "backup"
# cron = "0 2 * * *"           # daily at 2am
# command = "bash backup.sh"
# dir = "/home/user/scripts"   # working directory (optional)
# on_error = "backoff"         # continue | backoff | stop (default: continue)
# overlap = false              # allow concurrent runs (default: false)
# timeout = "30m"              # kill after duration (optional): 30s, 5m, 1h

# [[job]]
# name = "cleanup"
# cron = "*/15 * * * *"        # every 15 minutes
# command = "python3 cleanup.py"
"#;
