// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

//! APRS-IS IGate uplink — forwards RF-decoded APRS packets to APRS-IS (aprs.fi etc.).

use std::time::{SystemTime, UNIX_EPOCH};

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use tokio::time::{self, Duration, Instant};
use tracing::{debug, info, warn};

use trx_core::decode::{AprsPacket, DecodedMessage};

use crate::AprsFiConfig;

/// Compute the APRS-IS passcode for a callsign.
///
/// Algorithm matches the canonical JS/Python reference implementations:
/// - Strip SSID (everything from `-` onwards)
/// - Take the first 10 characters, uppercased
/// - XOR hash initialised at 0x73E2, processed in 2-byte pairs
/// - Mask result with 0x7FFF
pub fn compute_passcode(callsign: &str) -> u16 {
    // Strip SSID
    let base = callsign.split('-').next().unwrap_or(callsign);
    // First 10 chars, uppercase
    let upper: String = base
        .chars()
        .take(10)
        .map(|c| c.to_ascii_uppercase())
        .collect();
    let bytes = upper.as_bytes();

    let mut hash: u16 = 0x73e2;
    let mut i = 0;
    while i < bytes.len() {
        hash ^= (bytes[i] as u16) << 8;
        if i + 1 < bytes.len() {
            hash ^= bytes[i + 1] as u16;
        }
        i += 2;
    }
    hash & 0x7fff
}

/// Format an [`AprsPacket`] as a TNC2 line (CRLF-terminated) for APRS-IS,
/// appending the mandatory `,qAR,<igate_call>` q-construct that identifies
/// the RF entry point per the APRS-IS IGate specification.
fn format_tnc2(pkt: &AprsPacket, igate_call: &str) -> String {
    if pkt.path.is_empty() {
        format!(
            "{}>{},qAR,{}:{}\r\n",
            pkt.src_call, pkt.dest_call, igate_call, pkt.info
        )
    } else {
        format!(
            "{}>{},{},qAR,{}:{}\r\n",
            pkt.src_call, pkt.dest_call, pkt.path, igate_call, pkt.info
        )
    }
}

/// Format decimal-degree latitude as `DDMM.mmN` / `DDMM.mmS`.
fn format_aprs_lat(lat: f64) -> String {
    let ns = if lat >= 0.0 { 'N' } else { 'S' };
    let lat = lat.abs();
    let deg = lat as u32;
    let min = (lat - deg as f64) * 60.0;
    format!("{:02}{:05.2}{}", deg, min, ns)
}

/// Format decimal-degree longitude as `DDDMM.mmE` / `DDDMM.mmW`.
fn format_aprs_lon(lon: f64) -> String {
    let ew = if lon >= 0.0 { 'E' } else { 'W' };
    let lon = lon.abs();
    let deg = lon as u32;
    let min = (lon - deg as f64) * 60.0;
    format!("{:03}{:05.2}{}", deg, min, ew)
}

/// Build a position beacon TNC2 line for this IGate station.
///
/// Uses APRS uncompressed position format (`!`) with path `TCPIP*`.
/// The two-character `symbol` string sets the symbol-table and symbol-code
/// (e.g. `/-` = house, `/&` = diamond/gateway), followed by a software
/// comment identifying this IGate instance.
fn format_beacon(callsign: &str, lat: f64, lon: f64, sym_table: char, sym_code: char) -> String {
    let comment = format!("trx-rs v{} by SP2SJG", env!("CARGO_PKG_VERSION"));
    format!(
        "{}>APRS,TCPIP*:!{}{}{}{}{}\r\n",
        callsign,
        format_aprs_lat(lat),
        sym_table,
        format_aprs_lon(lon),
        sym_code,
        comment,
    )
}

/// Run the APRS-IS IGate uplink task.
///
/// Subscribes to the decoded-message broadcast channel and forwards every
/// CRC-valid APRS packet to the configured APRS-IS server as a TNC2 line.
/// Reconnects automatically with exponential backoff (1 s → 2 s → … → 60 s).
pub async fn run_aprsfi_uplink(
    cfg: AprsFiConfig,
    callsign: String,
    latitude: Option<f64>,
    longitude: Option<f64>,
    mut decode_rx: broadcast::Receiver<DecodedMessage>,
) {
    let passcode: u16 = if cfg.passcode == -1 {
        compute_passcode(&callsign)
    } else {
        (cfg.passcode as u16) & 0x7fff
    };

    // Resolve coordinates: [aprsfi] overrides [general].
    let coords = cfg
        .latitude
        .zip(cfg.longitude)
        .or_else(|| latitude.zip(longitude));

    // Pre-build the beacon packet (None if beaconing disabled or no coords).
    let beacon_packet: Option<String> = if cfg.beacon {
        match coords {
            Some((lat, lon)) => Some(format_beacon(
                &callsign,
                lat,
                lon,
                cfg.beacon_symbol_table,
                cfg.beacon_symbol_code,
            )),
            None => {
                warn!(
                    "APRS-IS IGate: beacon enabled but no coordinates available \
                     (set [aprsfi].latitude/longitude or [general].latitude/longitude)"
                );
                None
            }
        }
    } else {
        None
    };

    let beacon_interval = Duration::from_secs(cfg.beacon_interval_secs.max(60));

    let mut stats_received: u64 = 0;
    let mut stats_forwarded: u64 = 0;
    let mut stats_skipped: u64 = 0;
    let mut stats_write_errors: u64 = 0;
    let mut stats_reconnects: u64 = 0;
    let mut backoff_secs: u64 = 1;

    'reconnect: loop {
        // ----------------------------------------------------------------
        // TCP connect
        // ----------------------------------------------------------------
        let stream = match TcpStream::connect((cfg.host.as_str(), cfg.port)).await {
            Ok(s) => s,
            Err(e) => {
                warn!(
                    "APRS-IS IGate: connection to {}:{} failed: {}, retrying in {}s",
                    cfg.host, cfg.port, e, backoff_secs
                );
                time::sleep(Duration::from_secs(backoff_secs)).await;
                backoff_secs = (backoff_secs * 2).min(60);
                stats_reconnects += 1;
                continue 'reconnect;
            }
        };

        // Disable Nagle algorithm for low-latency packet forwarding.
        if let Err(e) = stream.set_nodelay(true) {
            warn!("APRS-IS IGate: set_nodelay failed: {}", e);
        }

        let (read_half, mut write_half) = stream.into_split();
        let mut reader = BufReader::new(read_half);

        // ----------------------------------------------------------------
        // Login
        // ----------------------------------------------------------------
        let login = format!(
            "user {} pass {} vers trx-rs {}\r\n",
            callsign,
            passcode,
            env!("CARGO_PKG_VERSION")
        );
        if let Err(e) = write_half.write_all(login.as_bytes()).await {
            warn!(
                "APRS-IS IGate: login write to {}:{} failed: {}, retrying in {}s",
                cfg.host, cfg.port, e, backoff_secs
            );
            time::sleep(Duration::from_secs(backoff_secs)).await;
            backoff_secs = (backoff_secs * 2).min(60);
            stats_reconnects += 1;
            continue 'reconnect;
        }

        // ----------------------------------------------------------------
        // Read logresp (up to 10 lines)
        // ----------------------------------------------------------------
        let mut verified = false;
        let mut got_logresp = false;
        let mut line = String::new();
        for _ in 0..10 {
            line.clear();
            match reader.read_line(&mut line).await {
                Ok(0) => {
                    warn!(
                        "APRS-IS IGate: connection closed before logresp from {}:{}",
                        cfg.host, cfg.port
                    );
                    break;
                }
                Ok(_) => {
                    if line.starts_with("# logresp") {
                        verified = !line.contains("unverified");
                        got_logresp = true;
                        break;
                    }
                }
                Err(e) => {
                    warn!(
                        "APRS-IS IGate: error reading logresp from {}:{}: {}",
                        cfg.host, cfg.port, e
                    );
                    break;
                }
            }
        }

        if !got_logresp {
            warn!(
                "APRS-IS IGate: no logresp from {}:{}, retrying in {}s",
                cfg.host, cfg.port, backoff_secs
            );
            time::sleep(Duration::from_secs(backoff_secs)).await;
            backoff_secs = (backoff_secs * 2).min(60);
            stats_reconnects += 1;
            continue 'reconnect;
        }

        info!(
            "APRS-IS IGate connected ({}:{} as {}, {})",
            cfg.host,
            cfg.port,
            callsign,
            if verified { "verified" } else { "unverified" }
        );

        // Successful connection — reset backoff
        backoff_secs = 1;

        // ----------------------------------------------------------------
        // Forward loop
        // ----------------------------------------------------------------
        let period = Duration::from_secs(60);
        let first_at = Instant::now() + period;
        let mut keepalive_tick = time::interval_at(first_at, period);
        let mut stats_tick = time::interval_at(first_at, period);
        // Beacon fires immediately on connect, then every beacon_interval.
        let mut beacon_tick = time::interval_at(Instant::now(), beacon_interval);
        // Reuse a single allocation for draining server-sent lines.
        let mut server_line = String::new();

        'forward: loop {
            tokio::select! {
                _ = keepalive_tick.tick() => {
                    if let Err(e) = write_half.write_all(b"# trx-rs keepalive\r\n").await {
                        warn!("APRS-IS IGate: keepalive write failed: {}", e);
                        stats_write_errors += 1;
                        break 'forward;
                    }
                }

                _ = stats_tick.tick() => {
                    info!(
                        "APRS-IS stats: received={}, forwarded={}, skipped={}, write_errors={}, reconnects={}",
                        stats_received, stats_forwarded, stats_skipped,
                        stats_write_errors, stats_reconnects
                    );
                }

                _ = beacon_tick.tick() => {
                    if let Some(pkt) = &beacon_packet {
                        if let Err(e) = write_half.write_all(pkt.as_bytes()).await {
                            warn!("APRS-IS IGate: beacon write failed: {}", e);
                            stats_write_errors += 1;
                            break 'forward;
                        }
                        debug!("APRS-IS IGate: sent position beacon");
                    }
                }

                // Drain the server feed. The server sends a full APRS stream;
                // if we never read it the TCP receive buffer fills and stalls
                // the connection via flow control. EOF triggers reconnect.
                result = reader.read_line(&mut server_line) => {
                    server_line.clear();
                    match result {
                        Ok(0) | Err(_) => {
                            warn!("APRS-IS IGate: server closed connection");
                            break 'forward;
                        }
                        Ok(_) => {} // discard — we do not gate IS→RF
                    }
                }

                recv = decode_rx.recv() => {
                    match recv {
                        Ok(DecodedMessage::Aprs(pkt)) | Ok(DecodedMessage::HfAprs(pkt)) => {
                            stats_received += 1;
                            if !pkt.crc_ok {
                                stats_skipped += 1;
                                continue 'forward;
                            }
                            // Guard against history replays: skip packets whose timestamp
                            // indicates they are older than 2 minutes. Live RF-decoded
                            // packets arrive within milliseconds; history items can be
                            // up to 24 hours old.
                            if let Some(ts_ms) = pkt.ts_ms {
                                let now_ms = SystemTime::now()
                                    .duration_since(UNIX_EPOCH)
                                    .map(|d| d.as_millis() as i64)
                                    .unwrap_or(0);
                                if now_ms.saturating_sub(ts_ms) > 120_000 {
                                    stats_skipped += 1;
                                    continue 'forward;
                                }
                            }
                            // Loop prevention: do not re-gate packets that already
                            // passed through APRS-IS (TCPIP or TCPXX in path).
                            if pkt.path.contains("TCPIP") || pkt.path.contains("TCPXX") {
                                stats_skipped += 1;
                                continue 'forward;
                            }
                            let tnc2 = format_tnc2(&pkt, &callsign);
                            debug!("APRS-IS: forwarded {}>{},...", pkt.src_call, pkt.dest_call);
                            if let Err(e) = write_half.write_all(tnc2.as_bytes()).await {
                                warn!("APRS-IS IGate: packet write failed: {}", e);
                                stats_write_errors += 1;
                                break 'forward;
                            }
                            stats_forwarded += 1;
                        }
                        Ok(_) => {
                            // Non-APRS messages (FT8, WSPR, CW) are silently skipped
                        }
                        Err(broadcast::error::RecvError::Lagged(n)) => {
                            warn!("APRS-IS IGate: dropped {} decode events (channel lagged)", n);
                        }
                        Err(broadcast::error::RecvError::Closed) => {
                            return;
                        }
                    }
                }
            }
        }

        // Forward loop exited due to a write error or server EOF — reconnect with backoff
        stats_reconnects += 1;
        warn!(
            "APRS-IS IGate: disconnected from {}:{}, reconnecting in {}s",
            cfg.host, cfg.port, backoff_secs
        );
        time::sleep(Duration::from_secs(backoff_secs)).await;
        backoff_secs = (backoff_secs * 2).min(60);
    }
}

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

    fn make_pkt(src: &str, dest: &str, path: &str, info: &str, crc_ok: bool) -> AprsPacket {
        AprsPacket {
            rig_id: None,
            ts_ms: None,
            src_call: src.to_string(),
            dest_call: dest.to_string(),
            path: path.to_string(),
            info: info.to_string(),
            info_bytes: vec![],
            packet_type: "Unknown".to_string(),
            crc_ok,
            lat: None,
            lon: None,
            symbol_table: None,
            symbol_code: None,
        }
    }

    #[test]
    fn passcode_result_in_valid_range() {
        assert!(compute_passcode("N0CALL") <= 0x7fff);
        assert!(compute_passcode("W1AW") <= 0x7fff);
        assert!(compute_passcode("SP2SJG") <= 0x7fff);
    }

    #[test]
    fn passcode_strips_ssid() {
        assert_eq!(compute_passcode("N0CALL"), compute_passcode("N0CALL-9"));
        assert_eq!(compute_passcode("W1AW"), compute_passcode("W1AW-5"));
        assert_eq!(compute_passcode("SP2SJG"), compute_passcode("SP2SJG-15"));
    }

    #[test]
    fn passcode_case_insensitive() {
        assert_eq!(compute_passcode("n0call"), compute_passcode("N0CALL"));
        assert_eq!(compute_passcode("sp2sjg"), compute_passcode("SP2SJG"));
    }

    #[test]
    fn passcode_truncates_to_ten_chars() {
        // Callsigns are at most 10 chars after stripping SSID; extra chars must be ignored
        assert_eq!(
            compute_passcode("ABCDEFGHIJ"),
            compute_passcode("ABCDEFGHIJKL")
        );
    }

    #[test]
    fn tnc2_with_path_adds_qar() {
        let pkt = make_pkt(
            "N0CALL-9",
            "APRS",
            "WIDE1-1,WIDE2-1",
            "!1234.56N/01234.56E-Test",
            true,
        );
        assert_eq!(
            format_tnc2(&pkt, "SP2SJG"),
            "N0CALL-9>APRS,WIDE1-1,WIDE2-1,qAR,SP2SJG:!1234.56N/01234.56E-Test\r\n"
        );
    }

    #[test]
    fn tnc2_without_path_adds_qar() {
        let pkt = make_pkt("W1AW", "BEACON", "", ">Test status", true);
        assert_eq!(
            format_tnc2(&pkt, "SP2SJG"),
            "W1AW>BEACON,qAR,SP2SJG:>Test status\r\n"
        );
    }

    #[test]
    fn beacon_format_house_symbol() {
        let s = format_beacon("SP2SJG-10", 52.2297, 21.0122, '/', '-');
        assert_eq!(
            s,
            format!(
                "SP2SJG-10>APRS,TCPIP*:!5213.78N/02100.73E-trx-rs v{} by SP2SJG\r\n",
                env!("CARGO_PKG_VERSION")
            )
        );
    }

    #[test]
    fn beacon_format_southern_western() {
        let s = format_beacon("VK2ABC-10", -33.8688, 151.2093, '/', '-');
        assert!(s.contains('S'));
        assert!(s.contains('E'));
        assert!(s.contains("trx-rs v"));
    }

    #[test]
    fn aprs_lat_format() {
        assert_eq!(format_aprs_lat(52.2297), "5213.78N");
        assert_eq!(format_aprs_lat(-33.8688), "3352.13S");
    }

    #[test]
    fn aprs_lon_format() {
        assert_eq!(format_aprs_lon(21.0122), "02100.73E");
        assert_eq!(format_aprs_lon(-87.6298), "08737.79W");
    }
}
