//! TUI runtime logging. Initializes a `tracing-subscriber` that writes to a
//! per-process file under `~/.deepseek/logs/tui-YYYY-MM-DD-PID.log`, and (on
//! Unix) redirects the process's `stderr` fd to that same file for the lifetime
//! of the alt-screen TUI.
//!
//! Why this exists:
//!
//! The TUI runs inside an alt-screen buffer drawn by `ratatui` using an
//! incremental diff renderer. The renderer assumes nothing else is writing
//! to the terminal — its internal "current cells" model is the only source
//! of truth for what's on screen. If anything emits raw bytes to stdout or
//! stderr while the alt-screen is active (an `eprintln!` from a sub-agent,
//! a `tracing` warning that defaulted to `stderr`, a panic message, a
//! third-party crate's verbose output, …) those bytes land in the alt-screen
//! buffer at the current cursor position, scroll the buffer up, and leave
//! the renderer's model out of sync with reality. The visible symptom is
//! "scroll demon": the TUI content drifts down, leaving a band of blank
//! rows above the header. This was the regression in issue #1085 (fixed in
//! v0.8.18 by adding a viewport-reset path) and re-surfaced in v0.8.27
//! when the flicker fix dropped the `\x1b[2J\x1b[3J` deep-clear that had
//! been masking the underlying leak.
//!
//! Defence-in-depth:
//!   1. A `tracing-subscriber` writes formatted logs to
//!      `~/.deepseek/logs/tui-YYYY-MM-DD-PID.log` so `tracing::warn!` /
//!      `tracing::error!` calls go somewhere observable instead of
//!      disappearing into the void (the TUI previously had no global
//!      subscriber, so contributors reached for `eprintln!`).
//!   2. On Unix the process's stderr fd is redirected (via `dup2`) to the
//!      same log file for the lifetime of `TuiLogGuard`. Any raw stderr
//!      write — ours, a dependency's, a panic message — lands in the log
//!      file instead of the alt-screen. The guard restores the original
//!      stderr fd on drop so post-TUI shutdown messages still reach the
//!      user's terminal.
//!   3. Crate-level `#![deny(clippy::print_stderr, clippy::print_stdout)]`
//!      on the TUI runtime modules forbids new `eprintln!` / `println!`
//!      calls at compile time. CLI-output paths (`main.rs` eval, init,
//!      `runtime_api::print_*`, `logging::info`/`warn`) keep their existing
//!      prints via `#[allow(clippy::print_stderr)]` because they run before
//!      the alt-screen is entered.

use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};

const DEFAULT_LOG_RETENTION_DAYS: u64 = 7;
const LOG_RETENTION_ENV: &str = "DEEPSEEK_LOG_RETENTION_DAYS";
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;

/// Owns the active tracing subscriber and (on Unix) a saved copy of the
/// original `stderr` fd so it can be restored on drop. Dropped when the TUI
/// exits the alt-screen.
pub struct TuiLogGuard {
    #[cfg(unix)]
    saved_stderr_fd: Option<libc::c_int>,
    _file: File,
    // Exposed via `log_path()` for diagnostics (e.g. `/doctor`,
    // `--print-log-path`). Currently no caller — keep the accessor
    // wired up so adding one later doesn't require revisiting the
    // guard struct.
    #[allow(dead_code)]
    log_path: PathBuf,
}

impl TuiLogGuard {
    /// Path the subscriber is writing to.
    #[allow(dead_code)]
    #[must_use]
    pub fn log_path(&self) -> &std::path::Path {
        &self.log_path
    }
}

#[cfg(unix)]
impl Drop for TuiLogGuard {
    fn drop(&mut self) {
        if let Some(saved) = self.saved_stderr_fd.take() {
            // SAFETY: `saved` came from `libc::dup` of the original stderr
            // fd in `init`; calling `dup2` to restore it is the standard
            // pairing. If `dup2` fails we just leak the saved fd — the
            // process is exiting anyway.
            unsafe {
                let _ = libc::dup2(saved, libc::STDERR_FILENO);
                let _ = libc::close(saved);
            }
        }
    }
}

#[cfg(not(unix))]
impl Drop for TuiLogGuard {
    fn drop(&mut self) {}
}

/// Initialize the TUI logging subsystem. Idempotent across re-entry by way
/// of `set_default` — if a global subscriber is already set we still install
/// the stderr redirect.
///
/// Returns a guard that must outlive the alt-screen session. Drop it after
/// `LeaveAlternateScreen` so any shutdown messages reach the user.
pub fn init() -> Result<TuiLogGuard> {
    let log_dir = log_directory().context("could not resolve TUI log directory")?;
    fs::create_dir_all(&log_dir)
        .with_context(|| format!("failed to create {}", log_dir.display()))?;
    let _ = prune_old_logs(&log_dir, log_retention_days());

    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
    let log_path = log_dir.join(log_file_name(&date, std::process::id()));

    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_path)
        .with_context(|| format!("failed to open {}", log_path.display()))?;

    // The tracing-subscriber consumes a clone of the file handle for its
    // writer. We keep our own handle for the dup2 redirect below — we need
    // the same on-disk file but a separate fd so the subscriber's writes
    // and the raw-stderr writes don't fight over the same kernel offset.
    let subscriber_file = file
        .try_clone()
        .context("failed to clone log file handle for subscriber")?;

    let env_filter = EnvFilter::try_from_default_env()
        .or_else(|_| EnvFilter::try_new("info"))
        .unwrap_or_else(|_| EnvFilter::new("info"));

    let subscriber = tracing_subscriber::registry().with(env_filter).with(
        fmt::layer()
            .with_writer(move || {
                subscriber_file
                    .try_clone()
                    .expect("clone log file handle for tracing writer")
            })
            .with_ansi(false)
            .with_target(true)
            .with_thread_ids(false),
    );

    // Best-effort: if a subscriber is already set (e.g., re-entry, or a
    // host process installed one), we skip ours rather than panic. The
    // stderr redirect below still happens.
    let _ = tracing::subscriber::set_global_default(subscriber);

    #[cfg(unix)]
    let saved_stderr_fd = redirect_stderr_to(&file).ok();

    Ok(TuiLogGuard {
        #[cfg(unix)]
        saved_stderr_fd,
        _file: file,
        log_path,
    })
}

fn log_directory() -> Option<PathBuf> {
    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
        && !home.as_os_str().is_empty()
    {
        return Some(home.join(".deepseek").join("logs"));
    }
    if let Some(userprofile) = std::env::var_os("USERPROFILE").map(PathBuf::from)
        && !userprofile.as_os_str().is_empty()
    {
        return Some(userprofile.join(".deepseek").join("logs"));
    }
    dirs::home_dir().map(|h| h.join(".deepseek").join("logs"))
}

fn log_file_name(date: &str, pid: u32) -> String {
    format!("tui-{date}-{pid}.log")
}

fn log_retention_days() -> u64 {
    std::env::var(LOG_RETENTION_ENV)
        .ok()
        .and_then(|raw| raw.trim().parse::<u64>().ok())
        .filter(|days| *days > 0)
        .unwrap_or(DEFAULT_LOG_RETENTION_DAYS)
}

fn prune_old_logs(log_dir: &Path, retention_days: u64) -> std::io::Result<usize> {
    let retention = Duration::from_secs(retention_days.saturating_mul(SECONDS_PER_DAY));
    let cutoff = SystemTime::now()
        .checked_sub(retention)
        .unwrap_or(SystemTime::UNIX_EPOCH);
    let mut removed = 0usize;

    for entry in fs::read_dir(log_dir)? {
        let entry = entry?;
        if !is_tui_log_file_name(&entry.file_name()) {
            continue;
        }
        let metadata = match entry.metadata() {
            Ok(metadata) if metadata.is_file() => metadata,
            _ => continue,
        };
        let modified = match metadata.modified() {
            Ok(modified) => modified,
            Err(_) => continue,
        };
        if modified < cutoff && fs::remove_file(entry.path()).is_ok() {
            removed += 1;
        }
    }

    Ok(removed)
}

fn is_tui_log_file_name(file_name: &std::ffi::OsStr) -> bool {
    file_name
        .to_str()
        .is_some_and(|name| name.starts_with("tui-") && name.ends_with(".log"))
}

#[cfg(unix)]
fn redirect_stderr_to(file: &File) -> Result<libc::c_int> {
    use std::os::fd::AsRawFd;
    let target = file.as_raw_fd();
    // SAFETY: `libc::dup` and `libc::dup2` are the documented fd-management
    // primitives. We save the current stderr fd before reassigning so the
    // guard can restore it on drop.
    unsafe {
        let saved = libc::dup(libc::STDERR_FILENO);
        if saved < 0 {
            return Err(
                anyhow::Error::from(std::io::Error::last_os_error()).context("dup(STDERR_FILENO)")
            );
        }
        if libc::dup2(target, libc::STDERR_FILENO) < 0 {
            let err = std::io::Error::last_os_error();
            let _ = libc::close(saved);
            return Err(anyhow::Error::from(err).context("dup2(log_file, STDERR_FILENO)"));
        }
        Ok(saved)
    }
}

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

    fn set_modified(path: &Path, modified: SystemTime) {
        let file = OpenOptions::new().write(true).open(path).unwrap();
        file.set_times(FileTimes::new().set_modified(modified))
            .unwrap();
    }

    #[test]
    fn log_directory_prefers_home() {
        let _lock = crate::test_support::lock_test_env();
        let tmp = tempfile::TempDir::new().unwrap();
        let prev_home = std::env::var_os("HOME");
        let prev_userprofile = std::env::var_os("USERPROFILE");
        // SAFETY: serialised by lock_test_env.
        unsafe {
            std::env::set_var("HOME", tmp.path());
            std::env::set_var("USERPROFILE", "");
        }

        let resolved = log_directory().expect("log_directory should resolve");
        assert_eq!(resolved, tmp.path().join(".deepseek").join("logs"));

        // SAFETY: cleanup under the same lock.
        unsafe {
            match prev_home {
                Some(v) => std::env::set_var("HOME", v),
                None => std::env::remove_var("HOME"),
            }
            match prev_userprofile {
                Some(v) => std::env::set_var("USERPROFILE", v),
                None => std::env::remove_var("USERPROFILE"),
            }
        }
    }

    #[test]
    fn log_file_name_includes_pid() {
        assert_eq!(
            log_file_name("2026-05-18", 12345),
            "tui-2026-05-18-12345.log"
        );
    }

    #[test]
    fn log_retention_days_uses_positive_env_override() {
        let _lock = crate::test_support::lock_test_env();
        let previous = std::env::var_os(LOG_RETENTION_ENV);

        // SAFETY: serialised by lock_test_env.
        unsafe {
            std::env::set_var(LOG_RETENTION_ENV, "14");
        }
        assert_eq!(log_retention_days(), 14);

        // SAFETY: serialised by lock_test_env.
        unsafe {
            std::env::set_var(LOG_RETENTION_ENV, "0");
        }
        assert_eq!(log_retention_days(), DEFAULT_LOG_RETENTION_DAYS);

        // SAFETY: cleanup under the same lock.
        unsafe {
            match previous {
                Some(value) => std::env::set_var(LOG_RETENTION_ENV, value),
                None => std::env::remove_var(LOG_RETENTION_ENV),
            }
        }
    }

    #[test]
    fn prune_old_logs_drops_only_stale_tui_logs() {
        let tmp = tempfile::TempDir::new().unwrap();
        let fresh = tmp.path().join("tui-2026-05-18-1.log");
        let stale = tmp.path().join("tui-2026-05-01-2.log");
        let legacy_stale = tmp.path().join("tui-2026-05-01.log");
        let unrelated = tmp.path().join("agent-2026-05-01.log");

        fs::write(&fresh, "fresh").unwrap();
        fs::write(&stale, "stale").unwrap();
        fs::write(&legacy_stale, "legacy").unwrap();
        fs::write(&unrelated, "other").unwrap();

        let now = SystemTime::now();
        let old = now - Duration::from_secs(10 * SECONDS_PER_DAY);
        set_modified(&stale, old);
        set_modified(&legacy_stale, old);
        set_modified(&unrelated, old);

        let removed = prune_old_logs(tmp.path(), 7).unwrap();

        assert_eq!(removed, 2);
        assert!(fresh.exists());
        assert!(!stale.exists());
        assert!(!legacy_stale.exists());
        assert!(unrelated.exists());
    }
}
