//! HDHomeRun discovery, lineup ingest, and stream scaffolding.

use anyhow::{anyhow, Context, Result};
use ec_core::{Channel, ChannelId, ChannelMetadata, DeviceId};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::Read;
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
use std::time::{Duration, Instant};

const DISCOVER_UDP_PORT: u16 = 65001;
const TYPE_DISCOVER_REQ: u16 = 0x0002;
const TYPE_DISCOVER_RPY: u16 = 0x0003;

const TAG_DEVICE_TYPE: u8 = 0x01;
const TAG_DEVICE_ID: u8 = 0x02;
const TAG_TUNER_COUNT: u8 = 0x10;
const TAG_DEVICE_AUTH_BIN: u8 = 0x29;
const TAG_BASE_URL: u8 = 0x2A;
const TAG_DEVICE_AUTH_STR: u8 = 0x2B;

const DEVICE_TYPE_TUNER: u32 = 0x00000001;
const DEVICE_ID_WILDCARD: u32 = 0xFFFFFFFF;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceField {
    pub key: String,
    pub value: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HdhomerunDevice {
    pub id: DeviceId,
    pub ip: String,
    pub tuner_count: u8,
    pub lineup_url: Option<String>,
    pub discover_url: Option<String>,
    pub base_url: Option<String>,
    pub device_auth: Option<String>,
    pub friendly_name: Option<String>,
    pub model_number: Option<String>,
    pub firmware_name: Option<String>,
    pub firmware_version: Option<String>,
    pub device_type: Option<String>,
    pub discovery_tags: Vec<DeviceField>,
    pub raw_discover_json: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineupEntry {
    pub channel: Channel,
    pub stream_url: String,
    pub tags: Vec<String>,
    pub raw: Value,
}

pub struct HdhomerunStream {
    pub url: String,
    reader: Box<dyn Read + Send>,
}

impl std::fmt::Debug for HdhomerunStream {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HdhomerunStream")
            .field("url", &self.url)
            .finish_non_exhaustive()
    }
}

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

#[derive(Debug, Clone, Deserialize)]
struct DiscoverJson {
    #[serde(rename = "DeviceID")]
    device_id: Option<String>,
    #[serde(rename = "DeviceAuth")]
    device_auth: Option<String>,
    #[serde(rename = "BaseURL")]
    base_url: Option<String>,
    #[serde(rename = "LineupURL")]
    lineup_url: Option<String>,
    #[serde(rename = "DiscoverURL")]
    discover_url: Option<String>,
    #[serde(rename = "FriendlyName")]
    friendly_name: Option<String>,
    #[serde(rename = "ModelNumber")]
    model_number: Option<String>,
    #[serde(rename = "FirmwareName")]
    firmware_name: Option<String>,
    #[serde(rename = "FirmwareVersion")]
    firmware_version: Option<String>,
    #[serde(rename = "DeviceType")]
    device_type: Option<String>,
    #[serde(rename = "TunerCount")]
    tuner_count: Option<u8>,
}

#[derive(Debug, Clone, Deserialize)]
struct LineupJsonEntry {
    #[serde(rename = "GuideNumber")]
    guide_number: Option<String>,
    #[serde(rename = "GuideName")]
    guide_name: Option<String>,
    #[serde(rename = "Tags")]
    tags: Option<String>,
    #[serde(rename = "URL")]
    url: Option<String>,
}

/// Discover devices using UDP broadcast, then hydrate with /discover.json when possible.
pub fn discover() -> Result<Vec<HdhomerunDevice>> {
    let mut devices = discover_udp(Duration::from_millis(400))?;

    if devices.is_empty() {
        if let Ok(device) = discover_from_host("hdhomerun.local") {
            devices.push(device);
        }
    }

    Ok(devices)
}

/// Discover a device by hostname or IP using the HTTP discover.json endpoint.
pub fn discover_from_host(host: &str) -> Result<HdhomerunDevice> {
    let base_url = format!("http://{host}");
    let discover_url = format!("{base_url}/discover.json");
    let json = fetch_json(&discover_url)?;
    let discover: DiscoverJson = serde_json::from_value(json.clone())
        .with_context(|| format!("invalid discover.json from {discover_url}"))?;

    let device = HdhomerunDevice {
        id: DeviceId(
            discover
                .device_id
                .clone()
                .unwrap_or_else(|| "unknown".to_string()),
        ),
        ip: host.to_string(),
        tuner_count: discover.tuner_count.unwrap_or(0),
        lineup_url: discover.lineup_url.clone(),
        discover_url: discover.discover_url.clone().or(Some(discover_url)),
        base_url: discover.base_url.clone().or(Some(base_url)),
        device_auth: discover.device_auth.clone(),
        friendly_name: discover.friendly_name.clone(),
        model_number: discover.model_number.clone(),
        firmware_name: discover.firmware_name.clone(),
        firmware_version: discover.firmware_version.clone(),
        device_type: discover.device_type.clone(),
        discovery_tags: Vec::new(),
        raw_discover_json: Some(json),
    };

    Ok(device)
}

/// Fetch and normalize lineup information for a device.
pub fn fetch_lineup(device: &HdhomerunDevice) -> Result<Vec<LineupEntry>> {
    let lineup_url = resolve_lineup_url(device)?;
    let json = fetch_json(&lineup_url)?;
    lineup_from_json_value(&json, Some(&device.id))
        .with_context(|| format!("invalid lineup.json from {lineup_url}"))
}

/// Parse a lineup.json file already loaded into memory.
pub fn lineup_from_json_bytes(
    bytes: &[u8],
    device_id: Option<&DeviceId>,
) -> Result<Vec<LineupEntry>> {
    let json: Value = serde_json::from_slice(bytes)?;
    lineup_from_json_value(&json, device_id)
}

/// Open a raw MPEG-TS stream by channel ID (lineup lookup required).
pub fn open_stream(device: &HdhomerunDevice, channel: &ChannelId) -> Result<HdhomerunStream> {
    let lineup = fetch_lineup(device)?;
    let entry = lineup
        .into_iter()
        .find(|entry| entry.channel.id == *channel)
        .ok_or_else(|| anyhow!("channel {} not found in lineup", channel.0))?;
    open_stream_entry(&entry, None)
}

/// Open a raw MPEG-TS stream from a lineup entry.
pub fn open_stream_entry(
    entry: &LineupEntry,
    duration_secs: Option<u32>,
) -> Result<HdhomerunStream> {
    open_stream_url(&entry.stream_url, duration_secs)
}

/// Open a raw MPEG-TS stream by URL.
pub fn open_stream_url(url: &str, duration_secs: Option<u32>) -> Result<HdhomerunStream> {
    let url = if let Some(duration) = duration_secs {
        append_query_param(url, "duration", &duration.to_string())
    } else {
        url.to_string()
    };

    // Streams can be long-lived. Only apply read timeout when the caller requests
    // `duration=...` (useful for tests and short captures).
    let mut agent_builder = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(3));
    if let Some(duration) = duration_secs {
        agent_builder = agent_builder.timeout_read(Duration::from_secs(duration as u64 + 10));
    }
    let agent = agent_builder.build();

    let response = agent
        .get(&url)
        .call()
        .with_context(|| format!("failed to open stream {url}"))?;
    if response.status() < 200 || response.status() >= 300 {
        return Err(anyhow!(
            "stream returned http {} for {}",
            response.status(),
            url
        ));
    }

    Ok(HdhomerunStream {
        url,
        reader: response.into_reader(),
    })
}

pub fn find_lineup_entry_by_number<'a>(
    lineup: &'a [LineupEntry],
    guide_number: &str,
) -> Option<&'a LineupEntry> {
    lineup
        .iter()
        .find(|entry| entry.channel.number.as_deref() == Some(guide_number))
}

pub fn find_lineup_entry_by_name<'a>(
    lineup: &'a [LineupEntry],
    guide_name: &str,
) -> Option<&'a LineupEntry> {
    lineup.iter().find(|entry| entry.channel.name == guide_name)
}

fn discover_udp(timeout: Duration) -> Result<Vec<HdhomerunDevice>> {
    let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
    socket.set_broadcast(true)?;
    socket.set_read_timeout(Some(Duration::from_millis(100)))?;

    let packet = build_discover_packet()?;
    let broadcast_addr = SocketAddrV4::new(Ipv4Addr::BROADCAST, DISCOVER_UDP_PORT);
    socket.send_to(&packet, broadcast_addr)?;

    let mut devices = Vec::new();
    let start = Instant::now();
    let mut buf = [0u8; 2048];

    while start.elapsed() < timeout {
        match socket.recv_from(&mut buf) {
            Ok((len, addr)) => {
                if let Ok(device) = parse_discover_response(&buf[..len], addr.ip().to_string()) {
                    devices.push(device);
                }
            }
            Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => continue,
            Err(err) if err.kind() == std::io::ErrorKind::TimedOut => continue,
            Err(err) => return Err(err.into()),
        }
    }

    for device in devices.iter_mut() {
        if let Ok(json) = try_fetch_discover_json(&device.ip) {
            apply_discover_json(device, json);
        }
    }

    Ok(devices)
}

fn build_discover_packet() -> Result<Vec<u8>> {
    let mut payload = Vec::new();
    payload.extend(tlv(TAG_DEVICE_TYPE, &DEVICE_TYPE_TUNER.to_be_bytes()));
    payload.extend(tlv(TAG_DEVICE_ID, &DEVICE_ID_WILDCARD.to_be_bytes()));

    let mut packet = Vec::with_capacity(4 + payload.len() + 4);
    packet.extend(TYPE_DISCOVER_REQ.to_be_bytes());
    packet.extend((payload.len() as u16).to_be_bytes());
    packet.extend(payload);

    let crc = crc32fast::hash(&packet);
    packet.extend(crc.to_le_bytes());
    Ok(packet)
}

fn parse_discover_response(bytes: &[u8], ip: String) -> Result<HdhomerunDevice> {
    if bytes.len() < 8 {
        return Err(anyhow!("discover reply too short"));
    }

    let packet_type = u16::from_be_bytes([bytes[0], bytes[1]]);
    if packet_type != TYPE_DISCOVER_RPY {
        return Err(anyhow!("unexpected packet type"));
    }

    let payload_len = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
    if bytes.len() < 4 + payload_len + 4 {
        return Err(anyhow!("truncated discover reply"));
    }

    let payload = &bytes[4..4 + payload_len];
    let expected_crc = u32::from_le_bytes([
        bytes[4 + payload_len],
        bytes[4 + payload_len + 1],
        bytes[4 + payload_len + 2],
        bytes[4 + payload_len + 3],
    ]);
    let actual_crc = crc32fast::hash(&bytes[..4 + payload_len]);
    if expected_crc != actual_crc {
        return Err(anyhow!("bad crc"));
    }

    let mut cursor = 0usize;
    let mut device_id: Option<String> = None;
    let mut tuner_count: Option<u8> = None;
    let mut base_url: Option<String> = None;
    let mut device_auth: Option<String> = None;
    let mut tags: Vec<DeviceField> = Vec::new();

    while cursor < payload.len() {
        let tag = payload[cursor];
        cursor += 1;

        let (length, consumed) = read_varlen(&payload[cursor..])?;
        cursor += consumed;

        if cursor + length > payload.len() {
            return Err(anyhow!("discover TLV length overflow"));
        }

        let value = &payload[cursor..cursor + length];
        cursor += length;

        match tag {
            TAG_DEVICE_ID => {
                if value.len() == 4 {
                    let id = u32::from_be_bytes([value[0], value[1], value[2], value[3]]);
                    device_id = Some(format!("{id:08X}"));
                }
            }
            TAG_TUNER_COUNT => {
                if let Some(first) = value.first() {
                    tuner_count = Some(*first);
                }
            }
            TAG_BASE_URL => {
                if let Ok(text) = std::str::from_utf8(value) {
                    base_url = Some(text.trim_end_matches('\0').to_string());
                }
            }
            TAG_DEVICE_AUTH_STR => {
                if let Ok(text) = std::str::from_utf8(value) {
                    device_auth = Some(text.trim_end_matches('\0').to_string());
                }
            }
            TAG_DEVICE_AUTH_BIN => {
                tags.push(DeviceField {
                    key: "device_auth_bin".to_string(),
                    value: hex::encode(value),
                });
            }
            TAG_DEVICE_TYPE => {
                tags.push(DeviceField {
                    key: "device_type".to_string(),
                    value: hex::encode(value),
                });
            }
            other => {
                tags.push(DeviceField {
                    key: format!("tag_{other:02X}"),
                    value: hex::encode(value),
                });
            }
        }
    }

    let id = device_id.unwrap_or_else(|| "unknown".to_string());
    let device = HdhomerunDevice {
        id: DeviceId(id),
        ip,
        tuner_count: tuner_count.unwrap_or(0),
        lineup_url: None,
        discover_url: None,
        base_url,
        device_auth,
        friendly_name: None,
        model_number: None,
        firmware_name: None,
        firmware_version: None,
        device_type: None,
        discovery_tags: tags,
        raw_discover_json: None,
    };

    Ok(device)
}

fn read_varlen(buf: &[u8]) -> Result<(usize, usize)> {
    if buf.is_empty() {
        return Err(anyhow!("missing varlen"));
    }

    let first = buf[0];
    if first & 0x80 == 0 {
        Ok((first as usize, 1))
    } else {
        if buf.len() < 2 {
            return Err(anyhow!("missing varlen second byte"));
        }
        let len = ((first & 0x7F) as usize) | ((buf[1] as usize) << 7);
        Ok((len, 2))
    }
}

fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(2 + value.len());
    out.push(tag);
    out.extend(encode_varlen(value.len()));
    out.extend(value);
    out
}

fn encode_varlen(len: usize) -> Vec<u8> {
    if len <= 0x7F {
        vec![len as u8]
    } else {
        vec![((len & 0x7F) as u8) | 0x80, (len >> 7) as u8]
    }
}

fn fetch_json(url: &str) -> Result<Value> {
    let agent = ureq::AgentBuilder::new()
        .timeout_connect(Duration::from_secs(3))
        .timeout_read(Duration::from_secs(6))
        .build();
    let response = agent
        .get(url)
        .call()
        .with_context(|| format!("request failed for {url}"))?;
    if response.status() < 200 || response.status() >= 300 {
        return Err(anyhow!("http {} for {url}", response.status()));
    }
    let mut body = String::new();
    response
        .into_reader()
        .read_to_string(&mut body)
        .with_context(|| format!("failed to read response body for {url}"))?;
    Ok(serde_json::from_str::<Value>(&body)
        .with_context(|| format!("invalid json body for {url}"))?)
}

fn try_fetch_discover_json(host: &str) -> Result<Value> {
    let url = format!("http://{host}/discover.json");
    fetch_json(&url)
}

fn apply_discover_json(device: &mut HdhomerunDevice, json: Value) {
    if let Ok(discover) = serde_json::from_value::<DiscoverJson>(json.clone()) {
        if let Some(device_id) = discover.device_id {
            device.id = DeviceId(device_id);
        }
        if let Some(tuner_count) = discover.tuner_count {
            device.tuner_count = tuner_count;
        }
        device.lineup_url = discover.lineup_url.or(device.lineup_url.take());
        device.discover_url = discover.discover_url.or(device.discover_url.take());
        device.base_url = discover.base_url.or(device.base_url.take());
        device.device_auth = discover.device_auth.or(device.device_auth.take());
        device.friendly_name = discover.friendly_name.or(device.friendly_name.take());
        device.model_number = discover.model_number.or(device.model_number.take());
        device.firmware_name = discover.firmware_name.or(device.firmware_name.take());
        device.firmware_version = discover.firmware_version.or(device.firmware_version.take());
        device.device_type = discover.device_type.or(device.device_type.take());
    }

    device.raw_discover_json = Some(json);
}

fn resolve_lineup_url(device: &HdhomerunDevice) -> Result<String> {
    if let Some(lineup_url) = device.lineup_url.as_ref() {
        return Ok(lineup_url.clone());
    }

    if let Some(base_url) = device.base_url.as_ref() {
        return Ok(format!("{base_url}/lineup.json"));
    }

    if !device.ip.is_empty() {
        return Ok(format!("http://{}/lineup.json", device.ip));
    }

    Err(anyhow!("no lineup URL available"))
}

fn append_query_param(url: &str, key: &str, value: &str) -> String {
    if url.contains('?') {
        format!("{url}&{key}={value}")
    } else {
        format!("{url}?{key}={value}")
    }
}

fn lineup_from_json_value(json: &Value, device_id: Option<&DeviceId>) -> Result<Vec<LineupEntry>> {
    let entries = json
        .as_array()
        .ok_or_else(|| anyhow!("lineup json is not an array"))?;

    let mut output = Vec::with_capacity(entries.len());

    for (index, entry) in entries.iter().enumerate() {
        let parsed: LineupJsonEntry = serde_json::from_value(entry.clone())
            .with_context(|| format!("invalid lineup entry at index {index}"))?;

        let guide_number = parsed.guide_number.clone();
        let guide_name = parsed
            .guide_name
            .clone()
            .or_else(|| guide_number.clone())
            .unwrap_or_else(|| format!("Channel {index}"));
        let tags = parsed
            .tags
            .unwrap_or_default()
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect::<Vec<_>>();
        let url = parsed.url.clone().unwrap_or_else(|| "".to_string());

        let id = match (device_id, guide_number.as_ref()) {
            (Some(device_id), Some(guide_number)) => {
                ChannelId(format!("hdhr:{}:{}", device_id.0, guide_number))
            }
            (_, Some(guide_number)) => ChannelId(guide_number.clone()),
            (_, None) => ChannelId(format!("hdhr:unknown:{index}")),
        };

        let mut metadata = Vec::new();
        for tag in &tags {
            metadata.push(ChannelMetadata::Extra("tag".to_string(), tag.clone()));
        }

        if let Some(guide_number) = guide_number.clone() {
            metadata.push(ChannelMetadata::Extra(
                "guide_number".to_string(),
                guide_number,
            ));
        }

        if let Some(callsign) = json_string_field(entry, &["CallSign", "Callsign", "CallSignRaw"]) {
            metadata.push(ChannelMetadata::Callsign(callsign));
        }
        if let Some(network) = json_string_field(entry, &["Network"]) {
            metadata.push(ChannelMetadata::Network(network));
        }
        if let Some(region) = json_string_field(entry, &["Region", "Market"]) {
            metadata.push(ChannelMetadata::Region(region));
        }
        if let Some(frequency) = json_string_field(entry, &["Frequency", "FrequencyHz"]) {
            metadata.push(ChannelMetadata::Frequency(frequency));
        }

        let program_id = json_u16_field(entry, &["ProgramNumber", "ProgramID", "Program"]);

        if let Some(obj) = entry.as_object() {
            for (key, value) in obj.iter() {
                if key == "GuideNumber"
                    || key == "GuideName"
                    || key == "Tags"
                    || key == "URL"
                    || key == "CallSign"
                    || key == "Callsign"
                    || key == "CallSignRaw"
                    || key == "Network"
                    || key == "Region"
                    || key == "Market"
                    || key == "Frequency"
                    || key == "FrequencyHz"
                    || key == "ProgramNumber"
                    || key == "ProgramID"
                    || key == "Program"
                {
                    continue;
                }
                metadata.push(ChannelMetadata::Extra(key.clone(), value.to_string()));
            }
        }

        let channel = Channel {
            id,
            name: guide_name,
            number: parsed.guide_number,
            program_id,
            metadata,
        };

        output.push(LineupEntry {
            channel,
            stream_url: url,
            tags,
            raw: entry.clone(),
        });
    }

    Ok(output)
}

fn json_string_field(value: &Value, keys: &[&str]) -> Option<String> {
    let obj = value.as_object()?;
    for key in keys {
        let Some(value) = obj.get(*key) else {
            continue;
        };
        let text = match value {
            Value::String(text) => text.trim().to_string(),
            Value::Number(number) => number.to_string(),
            _ => continue,
        };
        if !text.is_empty() {
            return Some(text);
        }
    }
    None
}

fn json_u16_field(value: &Value, keys: &[&str]) -> Option<u16> {
    let obj = value.as_object()?;
    for key in keys {
        let Some(value) = obj.get(*key) else {
            continue;
        };
        let parsed = match value {
            Value::Number(number) => number
                .as_u64()
                .and_then(|number| u16::try_from(number).ok()),
            Value::String(text) => text.trim().parse::<u16>().ok(),
            _ => None,
        };
        if parsed.is_some() {
            return parsed;
        }
    }
    None
}

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

    #[test]
    fn varlen_roundtrip_small_and_large() {
        for len in [0usize, 1, 10, 127, 128, 200, 1024] {
            let enc = encode_varlen(len);
            let (decoded, consumed) = read_varlen(&enc).unwrap();
            assert_eq!(decoded, len);
            assert_eq!(consumed, enc.len());
        }
    }

    #[test]
    fn parse_discover_response_happy_path() {
        let device_id = 0x10ACEBB9u32;
        let ip = "192.0.2.10"; // RFC 5737 TEST-NET-1
        let mut payload = Vec::new();
        payload.extend(tlv(TAG_DEVICE_ID, &device_id.to_be_bytes()));
        payload.extend(tlv(TAG_TUNER_COUNT, &[4u8]));
        payload.extend(tlv(TAG_BASE_URL, b"http://192.0.2.10\0"));
        payload.extend(tlv(TAG_DEVICE_AUTH_STR, b"auth-token\0"));
        payload.extend(tlv(0x99, b"unknown"));

        let mut packet = Vec::new();
        packet.extend(TYPE_DISCOVER_RPY.to_be_bytes());
        packet.extend((payload.len() as u16).to_be_bytes());
        packet.extend(&payload);
        let crc = crc32fast::hash(&packet);
        packet.extend(crc.to_le_bytes());

        let dev = parse_discover_response(&packet, ip.to_string()).unwrap();
        assert_eq!(dev.id.0, "10ACEBB9");
        assert_eq!(dev.ip, ip);
        assert_eq!(dev.tuner_count, 4);
        assert_eq!(dev.base_url.as_deref(), Some("http://192.0.2.10"));
        assert_eq!(dev.device_auth.as_deref(), Some("auth-token"));
        assert!(dev.discovery_tags.iter().any(|t| t.key == "tag_99"));
    }

    #[test]
    fn parse_discover_response_rejects_bad_crc() {
        let mut payload = Vec::new();
        payload.extend(tlv(TAG_TUNER_COUNT, &[2u8]));
        let mut packet = Vec::new();
        packet.extend(TYPE_DISCOVER_RPY.to_be_bytes());
        packet.extend((payload.len() as u16).to_be_bytes());
        packet.extend(&payload);
        let crc = crc32fast::hash(&packet);
        packet.extend(crc.to_le_bytes());
        // corrupt the last byte
        *packet.last_mut().unwrap() ^= 0xFF;
        assert!(parse_discover_response(&packet, "1.2.3.4".to_string()).is_err());
    }

    #[test]
    fn lineup_parsing_generates_channel_ids_and_metadata() {
        let device_id = DeviceId("ABCDEF01".to_string());
        let json = serde_json::json!([
            {
                "GuideNumber": "2.1",
                "GuideName": "KCBS-HD",
                "Tags": "drm,encrypted,",
                "URL": "http://hdhr/auto/v2.1",
                "CallSign": "KCBS",
                "ProgramNumber": "3",
                "Frequency": "573000000",
                "Foo": "Bar"
            },
            {
                "GuideNumber": "2.2",
                "GuideName": "StartTV",
                "Tags": "",
                "URL": "http://hdhr/auto/v2.2"
            }
        ]);
        let entries = lineup_from_json_value(&json, Some(&device_id)).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].channel.id.0, "hdhr:ABCDEF01:2.1");
        assert_eq!(entries[0].channel.name, "KCBS-HD");
        assert_eq!(entries[0].channel.number.as_deref(), Some("2.1"));
        assert_eq!(entries[0].channel.program_id, Some(3));
        assert_eq!(entries[0].stream_url, "http://hdhr/auto/v2.1");
        assert!(entries[0].tags.iter().any(|t| t == "drm"));
        assert!(entries[0].channel.metadata.iter().any(|m| match m {
            ChannelMetadata::Callsign(value) => value == "KCBS",
            _ => false,
        }));
        assert!(entries[0].channel.metadata.iter().any(|m| match m {
            ChannelMetadata::Frequency(value) => value == "573000000",
            _ => false,
        }));
        assert!(entries[0].channel.metadata.iter().any(|m| match m {
            ChannelMetadata::Extra(key, value) => key == "guide_number" && value == "2.1",
            _ => false,
        }));
        assert!(entries[0].channel.metadata.iter().any(|m| match m {
            ChannelMetadata::Extra(key, _) => key == "Foo",
            _ => false,
        }));
    }
}
