//! Linux IPTV (LinuxDVB) ingest scaffolding.

use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Child;

#[cfg(target_os = "linux")]
use std::{process::Command, time::Duration};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinuxDvbConfig {
    pub adapter: u32,
    pub frontend: u32,
    pub dvr: u32,
    pub tune_command: Option<Vec<String>>,
    pub tune_timeout_ms: Option<u64>,
}

#[derive(Debug)]
pub struct LinuxDvbStream {
    file: File,
    _tuner: Option<Child>,
    pub path: PathBuf,
}

impl Read for LinuxDvbStream {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        self.file.read(buf)
    }
}

/// Open the Linux DVB DVR device. Optionally spawns a tune command (like dvbv5-zap).
#[cfg(target_os = "linux")]
pub fn open_stream(config: &LinuxDvbConfig) -> Result<LinuxDvbStream> {
    let tuner = if let Some(cmd) = config.tune_command.clone() {
        spawn_tune_command(cmd, config.tune_timeout_ms)?
    } else {
        None
    };

    let path = dvb_path(config.adapter, config.dvr);
    let file =
        File::open(&path).map_err(|err| anyhow!("failed to open {}: {err}", path.display()))?;

    Ok(LinuxDvbStream {
        file,
        _tuner: tuner,
        path,
    })
}

#[cfg(not(target_os = "linux"))]
pub fn open_stream(_config: &LinuxDvbConfig) -> Result<LinuxDvbStream> {
    Err(anyhow!("Linux DVB support requires Linux"))
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinuxDvbAdapterInfo {
    pub adapter: u32,
    pub dvrs: Vec<u32>,
    pub frontends: Vec<u32>,
}

pub fn list_adapters() -> Result<Vec<LinuxDvbAdapterInfo>> {
    list_adapters_in(Path::new("/dev/dvb"))
}

fn list_adapters_in(root: &Path) -> Result<Vec<LinuxDvbAdapterInfo>> {
    if !root.exists() {
        return Ok(Vec::new());
    }

    let mut adapters = Vec::new();
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if !name.starts_with("adapter") {
            continue;
        }
        let Ok(adapter) = name.trim_start_matches("adapter").parse::<u32>() else {
            continue;
        };
        let path = entry.path();
        let mut dvrs = BTreeSet::new();
        let mut frontends = BTreeSet::new();
        for dev in fs::read_dir(&path)? {
            let dev = dev?;
            let dev_name = dev.file_name().to_string_lossy().to_string();
            if dev_name.starts_with("dvr") {
                if let Ok(idx) = dev_name.trim_start_matches("dvr").parse::<u32>() {
                    dvrs.insert(idx);
                }
            } else if dev_name.starts_with("frontend") {
                if let Ok(idx) = dev_name.trim_start_matches("frontend").parse::<u32>() {
                    frontends.insert(idx);
                }
            }
        }
        adapters.push(LinuxDvbAdapterInfo {
            adapter,
            dvrs: dvrs.into_iter().collect(),
            frontends: frontends.into_iter().collect(),
        });
    }

    adapters.sort_by_key(|info| info.adapter);
    Ok(adapters)
}

pub fn channels_conf_candidates() -> Vec<PathBuf> {
    // Prefer an explicit path for determinism and testability.
    if let Ok(value) = std::env::var("EVERY_CHANNEL_DVB_CHANNELS_CONF") {
        let value = value.trim();
        if !value.is_empty() {
            return vec![PathBuf::from(value)];
        }
    }

    let home = std::env::var("HOME").ok().map(PathBuf::from);
    let mut out = Vec::new();
    if let Some(home) = home {
        out.push(home.join(".dvb").join("channels.conf"));
        out.push(home.join(".config").join("dvb").join("channels.conf"));
    }
    out.push(PathBuf::from("/etc/dvb/channels.conf"));
    out
}

pub fn find_channels_conf() -> Option<PathBuf> {
    for candidate in channels_conf_candidates() {
        if candidate.exists() {
            return Some(candidate);
        }
    }
    None
}

pub fn parse_channels_conf(path: &Path) -> Result<Vec<String>> {
    let text = fs::read_to_string(path)
        .map_err(|err| anyhow!("failed to read {}: {err}", path.display()))?;
    let mut channels = BTreeSet::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((name, _)) = line.split_once(':') {
            let name = name.trim();
            if !name.is_empty() {
                channels.insert(name.to_string());
            }
        }
    }
    Ok(channels.into_iter().collect())
}

pub fn default_zap_tune_command(adapter: u32, channels_conf: &Path, channel: &str) -> Vec<String> {
    vec![
        "dvbv5-zap".to_string(),
        "-a".to_string(),
        adapter.to_string(),
        "-c".to_string(),
        channels_conf.display().to_string(),
        "-r".to_string(),
        channel.to_string(),
    ]
}

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

    #[test]
    fn parse_channels_conf_extracts_names() {
        let dir = std::env::temp_dir().join(format!("ec-channels-{}", std::process::id()));
        let _ = fs::create_dir_all(&dir);
        let path = dir.join("channels.conf");
        fs::write(
            &path,
            "\
# comment
KQED:foo
KQED:duplicate

KCBS-HD:bar
",
        )
        .unwrap();

        let channels = parse_channels_conf(&path).unwrap();
        assert_eq!(channels, vec!["KCBS-HD".to_string(), "KQED".to_string()]);
        let _ = fs::remove_file(&path);
    }

    #[test]
    fn default_zap_command_contains_adapter_and_channel() {
        let conf = Path::new("/tmp/channels.conf");
        let cmd = default_zap_tune_command(2, conf, "KQED");
        assert_eq!(cmd[0], "dvbv5-zap");
        assert!(cmd.iter().any(|arg| arg == "2"));
        assert!(cmd.iter().any(|arg| arg == "KQED"));
    }

    #[test]
    fn find_channels_conf_prefers_env_override() {
        let dir = std::env::temp_dir().join(format!("ec-channels-env-{}", std::process::id()));
        let _ = fs::create_dir_all(&dir);
        let path = dir.join("channels.conf");
        fs::write(&path, "KQED:foo\n").unwrap();

        let prev = std::env::var("EVERY_CHANNEL_DVB_CHANNELS_CONF").ok();
        std::env::set_var(
            "EVERY_CHANNEL_DVB_CHANNELS_CONF",
            path.display().to_string(),
        );
        let found = find_channels_conf().unwrap();
        assert_eq!(found, path);

        match prev {
            Some(value) => std::env::set_var("EVERY_CHANNEL_DVB_CHANNELS_CONF", value),
            None => std::env::remove_var("EVERY_CHANNEL_DVB_CHANNELS_CONF"),
        }
        let _ = fs::remove_file(&path);
    }

    #[test]
    fn list_adapters_parses_fake_dev_tree() {
        let root = std::env::temp_dir().join(format!("ec-dvb-root-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("adapter1")).unwrap();
        fs::create_dir_all(root.join("adapter0")).unwrap();
        fs::write(root.join("adapter0").join("dvr0"), "").unwrap();
        fs::write(root.join("adapter0").join("frontend0"), "").unwrap();
        fs::write(root.join("adapter1").join("dvr2"), "").unwrap();
        fs::write(root.join("adapter1").join("frontend0"), "").unwrap();
        fs::write(root.join("adapter1").join("frontend1"), "").unwrap();

        let list = list_adapters_in(&root).unwrap();
        assert_eq!(list.len(), 2);
        assert_eq!(list[0].adapter, 0);
        assert_eq!(list[0].dvrs, vec![0]);
        assert_eq!(list[0].frontends, vec![0]);
        assert_eq!(list[1].adapter, 1);
        assert_eq!(list[1].dvrs, vec![2]);
        assert_eq!(list[1].frontends, vec![0, 1]);

        let _ = fs::remove_dir_all(&root);
    }
}

#[cfg(target_os = "linux")]
fn spawn_tune_command(command: Vec<String>, tune_timeout_ms: Option<u64>) -> Result<Option<Child>> {
    if command.is_empty() {
        return Ok(None);
    }

    let mut cmd = Command::new(&command[0]);
    if command.len() > 1 {
        cmd.args(&command[1..]);
    }

    let child = cmd.spawn()?;

    if let Some(timeout_ms) = tune_timeout_ms {
        std::thread::sleep(Duration::from_millis(timeout_ms));
    }

    Ok(Some(child))
}

#[cfg(not(target_os = "linux"))]
fn spawn_tune_command(
    _command: Vec<String>,
    _tune_timeout_ms: Option<u64>,
) -> Result<Option<Child>> {
    Ok(None)
}

fn dvb_path(adapter: u32, dvr: u32) -> PathBuf {
    Path::new("/dev/dvb")
        .join(format!("adapter{adapter}"))
        .join(format!("dvr{dvr}"))
}
