portal-handler 0.1.0

System URI handler for portal.nvim — forwards nvim:// URIs to a running Neovim instance via RPC
//! Parsing des URIs nvim:// et envoi à Neovim via RPC (nvim-rs)

use anyhow::{anyhow, Result};
use nvim_rs::{create::tokio as create, rpc::handler::Dummy};
use url::Url;

use crate::socket;

/// Paramètres extraits d'une URI nvim://open?...
#[derive(Debug)]
struct OpenParams {
    file:  String,
    line:  Option<u64>,
    col:   Option<u64>,
    split: Option<String>,
    tab:   bool,
}

/// Parse une URI nvim:// et extrait les paramètres
fn parse_uri(raw: &str) -> Result<OpenParams> {
    let url = Url::parse(raw)
        .map_err(|e| anyhow!("URI invalide : {}", e))?;

    if url.scheme() != "nvim" {
        return Err(anyhow!(
            "schéma URI incorrect : attendu 'nvim', reçu '{}'",
            url.scheme()
        ));
    }

    let host = url.host_str().unwrap_or("");

    // Format LocatorJS : nvim://file/PATH:LINE:COL
    if host == "file" {
        return parse_file_uri(&url);
    }

    let command = if host.is_empty() || host == "open" {
        "open"
    } else {
        host
    };

    if command != "open" {
        return Err(anyhow!(
            "commande inconnue : '{}' (seul 'open' est supporté en v0.1)",
            command
        ));
    }

    let mut file  = String::new();
    let mut line  = None::<u64>;
    let mut col   = None::<u64>;
    let mut split = None::<String>;
    let mut tab   = false;

    for (key, value) in url.query_pairs() {
        match key.as_ref() {
            "file"  => file  = value.into_owned(),
            "line"  => line  = value.parse().ok(),
            "col"   => col   = value.parse().ok(),
            "split" => split = Some(value.into_owned()),
            "tab"   => tab   = value == "true" || value == "1",
            _       => {} // paramètres inconnus ignorés silencieusement
        }
    }

    if file.is_empty() {
        return Err(anyhow!("paramètre 'file' manquant dans l'URI"));
    }

    validate_file_path(&file)?;

    Ok(OpenParams { file, line, col, split, tab })
}

/// Parse le format nvim://file/PATH:LINE:COL
/// url.path() renvoie quelque chose comme "//home/user/foo.lua:42:7" —
/// on retire le "/" de tête ajouté par le parser d'URL puis on extrait
/// les entiers trailing de droite à gauche : line d'abord, puis col.
fn parse_file_uri(url: &Url) -> Result<OpenParams> {
    // url.path() for "nvim://file//home/user/foo.lua:42:7" → "//home/user/foo.lua:42:7"
    // Strip the single leading "/" that the URL library prefixes.
    let raw_path = url.path().trim_start_matches('/');

    // Re-add the leading "/" that belongs to the actual filesystem path.
    let full_path = format!("/{}", raw_path);

    let parts: Vec<&str> = full_path.split(':').collect();
    let n = parts.len();

    let (file, line, col) = match n {
        0 => return Err(anyhow!("chemin de fichier manquant dans l'URI nvim://file/")),
        1 => (parts[0].to_string(), None, None),
        2 => {
            // PATH:LINE  — only if the last segment is a pure integer
            if parts[1].chars().all(|c| c.is_ascii_digit()) {
                let line: u64 = parts[1].parse().map_err(|_| anyhow!("valeur line invalide"))?;
                (parts[0].to_string(), Some(line), None)
            } else {
                // Not an integer → treat the whole thing as the file path (colon in name)
                (full_path.clone(), None, None)
            }
        }
        _ => {
            // Try to peel two trailing integers
            let maybe_col  = parts[n - 1];
            let maybe_line = parts[n - 2];

            if maybe_col.chars().all(|c| c.is_ascii_digit())
                && maybe_line.chars().all(|c| c.is_ascii_digit())
            {
                let file_str = parts[..n - 2].join(":");
                let line_val: u64 = maybe_line.parse().map_err(|_| anyhow!("valeur line invalide"))?;
                let col_val:  u64 = maybe_col.parse().map_err(|_| anyhow!("valeur col invalide"))?;
                (file_str, Some(line_val), Some(col_val))
            } else if maybe_col.chars().all(|c| c.is_ascii_digit()) {
                // Only one trailing integer → line
                let file_str = parts[..n - 1].join(":");
                let line_val: u64 = maybe_col.parse().map_err(|_| anyhow!("valeur line invalide"))?;
                (file_str, Some(line_val), None)
            } else {
                // No trailing integers → entire string is the path
                (full_path.clone(), None, None)
            }
        }
    };

    if file.is_empty() {
        return Err(anyhow!("chemin de fichier manquant dans l'URI nvim://file/"));
    }

    validate_file_path(&file)?;

    Ok(OpenParams { file, line, col, split: None, tab: false })
}

/// Vérifie qu'un chemin de fichier ne contient pas de caractères dangereux
fn validate_file_path(file: &str) -> Result<()> {
    if file.contains('\0') {
        return Err(anyhow!("chemin invalide : contient un null byte"));
    }
    if file.contains(';') || file.contains('|') || file.contains('`')
        || file.contains('&') || file.contains('$')
        || file.contains('<') || file.contains('>')
    {
        return Err(anyhow!("chemin invalide : contient des métacaractères dangereux"));
    }
    Ok(())
}

/// Construit le code Lua à envoyer à Neovim via nvim_exec_lua
fn build_lua(params: &OpenParams) -> String {
    let mut uri = format!("nvim://open?file={}", url_encode(&params.file));
    if let Some(ln) = params.line {
        uri.push_str(&format!("&line={}", ln));
    }
    if let Some(c) = params.col {
        uri.push_str(&format!("&col={}", c));
    }
    if let Some(ref s) = params.split {
        uri.push_str(&format!("&split={}", s));
    }
    if params.tab {
        uri.push_str("&tab=true");
    }

    format!("require('portal').handle('{}')", uri.replace('\'', "\\'"))
}

/// Encode une chaîne en percent-encoding (pour reconstruire l'URI côté Lua)
fn url_encode(s: &str) -> String {
    let mut encoded = String::with_capacity(s.len() * 3);
    for byte in s.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
            | b'-' | b'_' | b'.' | b'~' | b'/' => {
                encoded.push(byte as char);
            }
            _ => {
                encoded.push_str(&format!("%{:02X}", byte));
            }
        }
    }
    encoded
}

/// Point d'entrée : parse l'URI et l'envoie à Neovim via RPC
pub async fn handle(raw_uri: &str, explicit_socket: Option<&str>) -> Result<()> {
    let params = parse_uri(raw_uri)?;

    let socket_path = if let Some(s) = explicit_socket {
        std::path::PathBuf::from(s)
    } else {
        socket::find_best().ok_or_else(|| {
            anyhow!(
                "Aucune instance Neovim active trouvée.\n\
                 Astuce : démarrer Neovim avec `nvim --listen /tmp/nvim.sock`\n\
                 ou ajouter `auto_server = true` dans votre setup()."
            )
        })?
    };

    let lua_code = build_lua(&params);

    let (nvim, _io) = create::new_path(&socket_path, Dummy::new())
        .await
        .map_err(|e| anyhow!(
            "connexion au socket '{}' échouée : {}",
            socket_path.display(), e
        ))?;

    nvim.exec_lua(&lua_code, vec![])
        .await
        .map_err(|e| anyhow!("nvim_exec_lua échoué : {}", e))?;

    Ok(())
}