xa 0.1.1

Execute Anything via LLM - A CLI tool for arbitrary text processing using LLMs
use crate::config::Config;
use crate::llm::process_with_llm;
use chrono::Utc;
use dirs::config_dir;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;

#[derive(Serialize, Deserialize, Clone, Default)]
pub struct StoreConfig {
    pub entries: Vec<StoreEntry>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct StoreEntry {
    pub id: u64,
    pub tag: String,
    pub note: String,
    pub secret: String,
    pub created_at: String,
}

pub async fn list_stores() -> Result<(), Box<dyn std::error::Error>> {
    let config_dir = config_dir()
        .ok_or("Could not determine config directory")?
        .join("xa");
    
    let store_file = config_dir.join("stores.toml");
    
    let store = load_store()?;
    
    println!("Stored secrets:");
    println!("Config directory: {:?}", config_dir);
    println!("Store file: {:?}", store_file);
    println!();
    
    if store.entries.is_empty() {
        println!("No secrets stored yet.");
        println!("Use 'xa add <secret> <note>' to add a secret.");
        return Ok(());
    }
    
    println!("Total entries: {}", store.entries.len());
    println!();
    
    for entry in &store.entries {
        println!("[{}]", entry.tag);
        println!("  Note: {}", entry.note);
        println!("  Created: {}", entry.created_at);
        println!("  Secret: ***hidden***");
        println!();
    }
    
    Ok(())
}

#[derive(Serialize, Deserialize)]
struct TagResponse {
    tag: String,
    reason: Option<String>,
}

#[derive(Serialize, Deserialize)]
struct SearchResponse {
    found: bool,
    id: Option<u64>,
    reason: Option<String>,
}

pub async fn add_secret_with_tag(
    config: &Config,
    secret: &str,
    note: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let secret = secret.trim();
    let note = note.trim();

    if secret.is_empty() {
        eprintln!("Error: secret cannot be empty.");
        return Ok(());
    }

    if note.is_empty() {
        eprintln!("Error: note/description cannot be empty.");
        return Ok(());
    }

    let mut store = load_store()?;

    let existing_tags: HashSet<String> = store
        .entries
        .iter()
        .map(|e| e.tag.to_lowercase())
        .collect();

    let prompt = build_tag_prompt(note, &existing_tags);
    let llm_response = process_with_llm(config, &prompt, false).await?;
    let mut tag = match parse_json::<TagResponse>(&llm_response) {
        Some(parsed) => parsed.tag,
        None => fallback_tag(note),
    };

    tag = sanitize_tag(&tag);
    if tag.is_empty() {
        tag = fallback_tag(note);
    }

    tag = ensure_unique_tag(&tag, &existing_tags);

    let entry = StoreEntry {
        id: Utc::now().timestamp_millis() as u64,
        tag: tag.clone(),
        note: note.to_string(),
        secret: secret.to_string(),
        created_at: Utc::now().to_rfc3339(),
    };

    store.entries.push(entry);
    save_store(&store)?;

    println!("Added secret with tag: {}", tag);
    Ok(())
}

pub async fn search_secret(
    config: &Config,
    query: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let query = query.trim();
    if query.is_empty() {
        eprintln!("Error: query cannot be empty.");
        return Ok(());
    }

    let store = load_store()?;
    if store.entries.is_empty() {
        println!("No found such thing.");
        return Ok(());
    }

    let masked_entries = build_masked_entries(&store.entries);
    let prompt = build_search_prompt(query, &masked_entries);
    let llm_response = process_with_llm(config, &prompt, false).await?;
    let parsed = parse_json::<SearchResponse>(&llm_response);

    if let Some(result) = parsed {
        if result.found {
            if let Some(id) = result.id {
                if let Some(entry) = store.entries.iter().find(|e| e.id == id) {
                    println!("{}", entry.secret);
                    return Ok(());
                }
            }
        }
    }

    println!("No found such thing.");
    Ok(())
}

fn load_store() -> Result<StoreConfig, Box<dyn std::error::Error>> {
    let config_dir = config_dir()
        .ok_or("Could not determine config directory")?
        .join("xa");
    let store_file = config_dir.join("stores.toml");

    if !store_file.exists() {
        return Ok(StoreConfig::default());
    }

    let content = fs::read_to_string(&store_file)?;
    match toml::from_str(&content) {
        Ok(parsed) => Ok(parsed),
        Err(_) => {
            let backup_path = store_file.with_extension("toml.backup");
            fs::rename(&store_file, &backup_path)?;
            eprintln!(
                "Warning: Corrupted stores.toml detected. Backed up to {:?} and created a new one.",
                backup_path
            );
            Ok(StoreConfig::default())
        }
    }
}

fn save_store(store: &StoreConfig) -> Result<(), Box<dyn std::error::Error>> {
    let config_dir = config_dir()
        .ok_or("Could not determine config directory")?
        .join("xa");
    fs::create_dir_all(&config_dir)?;
    let store_file = config_dir.join("stores.toml");
    let content = toml::to_string(store)?;
    fs::write(&store_file, content)?;
    Ok(())
}

fn build_tag_prompt(note: &str, existing_tags: &HashSet<String>) -> String {
    let mut existing: Vec<String> = existing_tags.iter().cloned().collect();
    existing.sort();

    format!(
        "You generate short, memorable tags for secret notes.\n\nRules:\n- Return JSON only.\n- JSON schema: {{\"tag\": string, \"reason\": string}}.\n- tag must be 2-4 words max, lowercase, use hyphens instead of spaces.\n- tag must not include any sensitive data (only use the note).\n- tag must not duplicate existing tags.\n\nExisting tags: {:?}\n\nNote: {}\n\nReturn JSON only.",
        existing,
        note
    )
}

fn build_search_prompt(query: &str, masked_entries: &[MaskedEntry]) -> String {
    let entries_json = serde_json::to_string_pretty(masked_entries).unwrap_or_else(|_| "[]".to_string());
    format!(
        "You are a secret locator. Given a user query and a list of entries, find the best matching entry.\n\nRules:\n- Return JSON only.\n- JSON schema: {{\"found\": boolean, \"id\": number|null, \"reason\": string}}.\n- If nothing matches well, set found=false and id=null.\n- Do not invent ids.\n\nEntries (secret is placeholder only):\n{}\n\nQuery: {}\n\nReturn JSON only.",
        entries_json,
        query
    )
}

fn fallback_tag(note: &str) -> String {
    let words: Vec<String> = note
        .split_whitespace()
        .filter(|w| !w.is_empty())
        .take(4)
        .map(|w| w.to_lowercase())
        .collect();
    if words.is_empty() {
        "untagged".to_string()
    } else {
        words.join("-")
    }
}

fn sanitize_tag(tag: &str) -> String {
    let mut out = String::new();
    let mut last_dash = false;

    for ch in tag.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            last_dash = false;
        } else if ch == '-' || ch.is_whitespace() {
            if !last_dash {
                out.push('-');
                last_dash = true;
            }
        }
    }

    while out.starts_with('-') {
        out.remove(0);
    }
    while out.ends_with('-') {
        out.pop();
    }

    out
}

fn ensure_unique_tag(tag: &str, existing_tags: &HashSet<String>) -> String {
    if !existing_tags.contains(&tag.to_lowercase()) {
        return tag.to_string();
    }

    for i in 2..=99 {
        let candidate = format!("{}-{}", tag, i);
        if !existing_tags.contains(&candidate.to_lowercase()) {
            return candidate;
        }
    }

    format!("{}-{}", tag, Utc::now().timestamp_millis())
}

fn parse_json<T: for<'de> Deserialize<'de>>(input: &str) -> Option<T> {
    if let Ok(parsed) = serde_json::from_str::<T>(input) {
        return Some(parsed);
    }

    let start = input.find('{')?;
    let end = input.rfind('}')?;
    if start >= end {
        return None;
    }

    let slice = &input[start..=end];
    serde_json::from_str::<T>(slice).ok()
}

#[derive(Serialize)]
struct MaskedEntry {
    id: u64,
    tag: String,
    note: String,
    created_at: String,
    secret_placeholder: String,
}

fn build_masked_entries(entries: &[StoreEntry]) -> Vec<MaskedEntry> {
    entries
        .iter()
        .map(|e| MaskedEntry {
            id: e.id,
            tag: e.tag.clone(),
            note: e.note.clone(),
            created_at: e.created_at.clone(),
            secret_placeholder: format!("SECRET_{}", e.id),
        })
        .collect()
}