tina4 3.8.1

Tina4 — Unified CLI for Python, PHP, Ruby, and Node.js frameworks
use colored::Colorize;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, EventKind};
use std::path::Path;
use std::sync::mpsc;
use std::time::{Duration, Instant};

use crate::console::{icon_fail, icon_ok, icon_play};
use crate::detect::ProjectInfo;

/// Watch SCSS directory and recompile on changes. Blocks forever.
pub fn watch_scss(input_dir: &str, output_dir: &str, minify: bool) {
    let (tx, rx) = mpsc::channel();
    let config = Config::default().with_poll_interval(Duration::from_secs(2));

    let mut watcher: RecommendedWatcher =
        Watcher::new(tx, config).expect("Failed to create watcher");

    let input = Path::new(input_dir);
    if input.exists() {
        watcher
            .watch(input, RecursiveMode::Recursive)
            .expect("Failed to watch SCSS directory");
    }

    let mut last_compile = Instant::now();

    loop {
        match rx.recv() {
            Ok(_event) => {
                // Debounce: skip if less than 500ms since last compile
                if last_compile.elapsed() < Duration::from_millis(500) {
                    continue;
                }
                last_compile = Instant::now();

                println!(
                    "\n{} SCSS changed — recompiling...",
                    "".cyan()
                );
                crate::scss::compile_dir(input_dir, output_dir, minify);
            }
            Err(e) => {
                eprintln!("{} Watcher error: {}", icon_fail().red(), e);
                break;
            }
        }
    }
}

/// Watch src/, migrations/, .env for changes.
/// On SCSS changes: recompile. On code changes: restart the server.
pub fn watch_and_reload(
    scss_dir: &str,
    css_dir: &str,
    info: &ProjectInfo,
    port: u16,
    host: &str,
    server: &mut std::process::Child,
) {
    let (tx, rx) = mpsc::channel();
    let config = Config::default().with_poll_interval(Duration::from_secs(2));

    let mut watcher: RecommendedWatcher =
        Watcher::new(tx, config).expect("Failed to create watcher");

    // Watch directories that exist
    let watch_paths = ["src", "migrations"];
    for p in &watch_paths {
        let path = Path::new(p);
        if path.exists() {
            let _ = watcher.watch(path, RecursiveMode::Recursive);
        }
    }

    // Watch .env
    let env_path = Path::new(".env");
    if env_path.exists() {
        let _ = watcher.watch(env_path, RecursiveMode::NonRecursive);
    }

    let mut last_event = Instant::now();

    // Handle Ctrl+C gracefully
    let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
    let r = running.clone();
    ctrlc_handler(r);

    while running.load(std::sync::atomic::Ordering::Relaxed) {
        match rx.recv_timeout(Duration::from_secs(1)) {
            Ok(Ok(event)) => {
                // Only react to actual content changes, not access/metadata events.
                // PollWatcher on Linux can fire spurious events for unchanged files.
                match event.kind {
                    EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {}
                    _ => continue,
                }

                // Debounce
                if last_event.elapsed() < Duration::from_millis(500) {
                    continue;
                }
                last_event = Instant::now();

                let paths: Vec<String> = event
                    .paths
                    .iter()
                    .map(|p| p.display().to_string())
                    .collect();

                // Ignore generated/cache files that shouldn't trigger a restart
                let dominated_by_noise = paths.iter().all(|p| {
                    let lower = p.to_lowercase();
                    lower.contains("__pycache__")
                        || lower.ends_with(".pyc")
                        || lower.ends_with(".pyo")
                        || lower.ends_with(".db")
                        || lower.ends_with(".db-journal")
                        || lower.ends_with(".db-wal")
                        || lower.ends_with(".db-shm")
                        || lower.ends_with(".log")
                        || lower.ends_with(".pid")
                        || lower.ends_with(".tmp")
                        || lower.ends_with(".swp")
                        || lower.ends_with("~")
                        || lower.ends_with(".css")
                        || lower.contains("/data/")
                        || lower.contains("\\data\\")
                        || lower.contains("/logs/")
                        || lower.contains("\\logs\\")
                        || lower.contains("/secrets/")
                        || lower.contains("\\secrets\\")
                        || lower.contains("/public/")
                        || lower.contains("\\public\\")
                        || lower.contains("/node_modules/")
                        || lower.contains("\\node_modules\\")
                        || lower.contains("/.venv/")
                        || lower.contains("\\.venv\\")
                        || lower.contains("/vendor/")
                        || lower.contains("\\vendor\\")
                        || lower.contains("/.git/")
                        || lower.contains("\\.git\\")
                });

                if dominated_by_noise {
                    continue;
                }

                let is_scss = paths
                    .iter()
                    .any(|p| p.ends_with(".scss"));

                if is_scss {
                    println!("{} SCSS changed — recompiling", "".cyan());
                    crate::scss::compile_dir(scss_dir, css_dir, false);
                } else {
                    let changed = paths
                        .first()
                        .map(|p| p.as_str())
                        .unwrap_or("file");
                    println!(
                        "{} {} changed — restarting server...",
                        "".cyan(),
                        changed.dimmed()
                    );

                    // Kill old server and its entire process group
                    kill_server(server);

                    // Wait for the port to be released before restarting
                    wait_for_port(port, host);

                    match crate::start_language_server(info, port, host) {
                        Some(child) => *server = child,
                        None => {
                            eprintln!("{} Failed to restart server", icon_fail().red());
                        }
                    }
                }
            }
            Ok(Err(e)) => {
                eprintln!("{} Watch error: {:?}", icon_fail().red(), e);
            }
            Err(mpsc::RecvTimeoutError::Timeout) => continue,
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
        }
    }

    // Cleanup
    println!("\n{} Shutting down...", icon_play().yellow());
    kill_server(server);
    println!("{} Server stopped", icon_ok().green());
}

/// Kill the server process and all its children.
/// On Unix, kills the entire process group to prevent orphaned workers
/// from holding the port (EADDRINUSE / errno 98).
fn kill_server(server: &mut std::process::Child) {
    #[cfg(unix)]
    {
        // Kill the entire process group: negative PID = process group
        let pid = server.id() as i32;
        unsafe {
            libc::kill(-pid, libc::SIGTERM);
        }
        // Give processes a moment to exit gracefully
        std::thread::sleep(Duration::from_millis(300));
        // Force kill if still alive
        unsafe {
            libc::kill(-pid, libc::SIGKILL);
        }
    }

    #[cfg(not(unix))]
    {
        let _ = server.kill();
    }

    let _ = server.wait();
}

/// Wait until nothing is listening on the given port, up to 5 seconds.
fn wait_for_port(port: u16, _host: &str) {
    use std::net::TcpStream;

    // Connect to localhost — if connection is refused, port is free.
    let addr = format!("127.0.0.1:{}", port);
    for i in 0..10 {
        match TcpStream::connect_timeout(
            &addr.parse().unwrap(),
            Duration::from_millis(200),
        ) {
            Ok(_) => {
                // Something is still listening — wait and retry
                if i == 0 {
                    println!(
                        "  {} Waiting for port {} to be released...",
                        icon_play().yellow(),
                        port.to_string().cyan()
                    );
                }
                std::thread::sleep(Duration::from_millis(500));
            }
            Err(_) => {
                // Connection refused — port is free
                return;
            }
        }
    }
    eprintln!(
        "  {} Port {} still in use — restarting anyway",
        icon_fail().yellow(),
        port
    );
}

fn ctrlc_handler(running: std::sync::Arc<std::sync::atomic::AtomicBool>) {
    let _ = ctrlc::set_handler(move || {
        running.store(false, std::sync::atomic::Ordering::Relaxed);
    });
}