use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirEntry {
    pub name: String,
    pub path: String,
    pub is_dir: bool,
}

#[tauri::command]
pub fn list_directory(path: String, limit: Option<usize>) -> Result<Vec<DirEntry>, String> {
    let normalized = normalize_input_path(&path);
    let root = PathBuf::from(&normalized);
    if !root.exists() {
        let hint = parent_existence_hint(&root);
        return Err(format!("Path does not exist: {}{hint}", root.display()));
    }
    if !root.is_dir() {
        return Err(format!("Not a directory: {}", root.display()));
    }

    let max = limit.unwrap_or(200).min(500);
    let mut entries: Vec<DirEntry> = Vec::new();

    let read_dir = fs::read_dir(&root).map_err(|e| e.to_string())?;
    for item in read_dir {
        let item = item.map_err(|e| e.to_string())?;
        let meta = item.metadata().map_err(|e| e.to_string())?;
        let name = item.file_name().to_string_lossy().into_owned();
        if name.starts_with('.') {
            continue;
        }
        entries.push(DirEntry {
            path: item.path().to_string_lossy().into_owned(),
            name,
            is_dir: meta.is_dir(),
        });
    }

    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
        (true, false) => std::cmp::Ordering::Less,
        (false, true) => std::cmp::Ordering::Greater,
        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
    });

    entries.truncate(max);
    Ok(entries)
}

/// Cleans up paths typed or pasted by a human before any filesystem check:
/// trims whitespace, strips smart/curly quotes that sneak in from copy-paste,
/// and drops a single trailing separator. Never trims a bare drive root like
/// "C:\\" down to "C:", since on Windows that changes meaning entirely --
/// "C:" means "current directory on drive C", not "root of drive C" -- which
/// would make an otherwise-valid root look like it doesn't exist.
fn normalize_input_path(path: &str) -> String {
    // Strip invisible/zero-width Unicode characters that can silently ride
    // along when a path is copy-pasted from a browser address bar, chat
    // message, or PDF (ZWSP/ZWNJ/ZWJ/LRM/RLM/BOM). These are NOT covered by
    // `char::is_whitespace()` (used by `.trim()`), so a path that "looks"
    // identical to the real folder in Explorer can still fail an exact
    // filesystem existence check without this.
    let cleaned: String = path
        .chars()
        .filter(|c| {
            !matches!(
                c,
                '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{200E}' | '\u{200F}' | '\u{FEFF}'
            )
        })
        .collect();
    let trimmed = cleaned
        .trim()
        .trim_matches(['"', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}']);
    if trimmed.len() > 3 && (trimmed.ends_with('\\') || trimmed.ends_with('/')) {
        trimmed.trim_end_matches(['\\', '/']).to_string()
    } else {
        trimmed.to_string()
    }
}

/// When a path lookup fails, walk up to the nearest existing ancestor so the
/// error message tells the user exactly which segment is missing (e.g. a
/// typo'd leaf folder vs. the whole drive being unreachable) instead of a
/// bare "does not exist" that looks wrong when the folder is visible in
/// Explorer.
fn parent_existence_hint(root: &Path) -> String {
    let mut current = root.parent();
    while let Some(dir) = current {
        if dir.exists() {
            return format!(" (nearest existing folder: '{}' -- check the remaining path for typos or extra spaces)", dir.display());
        }
        current = dir.parent();
    }
    String::new()
}

#[tauri::command]
pub fn path_exists(path: String) -> bool {
    Path::new(&normalize_input_path(&path)).exists()
}

#[tauri::command]
pub fn is_valid_project_root(path: String) -> bool {
    let normalized = normalize_input_path(&path);
    let trimmed = normalized.as_str();
    if trimmed.is_empty() || trimmed == "." {
        return false;
    }
    let root = PathBuf::from(trimmed);
    if !root.is_absolute() {
        return false;
    }
    if !root.exists() || !root.is_dir() {
        return false;
    }
    if let Ok(exe) = std::env::current_exe() {
        if let Some(install_dir) = exe.parent() {
            if let (Ok(install_canon), Ok(path_canon)) =
                (install_dir.canonicalize(), root.canonicalize())
            {
                if install_canon == path_canon {
                    return false;
                }
            }
        }
    }
    true
}

fn canonical_workspace_root(root: &Path) -> Result<PathBuf, String> {
    let root_canon = root
        .canonicalize()
        .map_err(|e| format!("Invalid workspace root: {e}"))?;
    if !root_canon.is_dir() {
        return Err("Invalid workspace root".into());
    }
    Ok(root_canon)
}

fn sanitize_relative_path(relative_path: &str) -> Result<PathBuf, String> {
    let trimmed = relative_path.trim().trim_start_matches(['/', '\\']);
    if trimmed.is_empty() {
        return Err("Invalid relative path".into());
    }

    let mut safe = PathBuf::new();
    for component in Path::new(trimmed).components() {
        match component {
            Component::Normal(part) => safe.push(part),
            Component::CurDir => {}
            _ => return Err("Invalid relative path".into()),
        }
    }

    if safe.as_os_str().is_empty() {
        return Err("Invalid relative path".into());
    }
    Ok(safe)
}

fn ensure_parent_under_root(root_canon: &Path, candidate: &Path) -> Result<PathBuf, String> {
    let parent = candidate
        .parent()
        .ok_or_else(|| "Invalid target path".to_string())?;
    if !parent.exists() {
        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let parent_canon = parent
        .canonicalize()
        .map_err(|e| format!("Invalid target directory: {e}"))?;
    if !parent_canon.starts_with(root_canon) {
        return Err("Target path escapes workspace root".into());
    }
    Ok(parent_canon)
}

fn existing_file_under_root(root_canon: &Path, candidate: &Path) -> Result<PathBuf, String> {
    let file_canon = candidate
        .canonicalize()
        .map_err(|e| format!("Invalid file path: {e}"))?;
    if !file_canon.starts_with(root_canon) {
        return Err("Target path escapes workspace root".into());
    }
    Ok(file_canon)
}

fn merge_html_body(existing: &str, incoming: &str) -> String {
    let inject_marker = "<!-- vigthoria-goa-inject -->";
    if existing.contains(inject_marker) {
        return existing.to_string();
    }
    let body_inner = extract_html_body_inner(incoming).unwrap_or_else(|| incoming.to_string());
    let wrapped = format!(
        "\n{inject_marker}\n<div data-vigthoria-goa=\"injected\">\n{body_inner}\n</div>\n"
    );
    if let Some(pos) = existing.rfind("</body>") {
        let mut merged = existing.to_string();
        merged.insert_str(pos, &wrapped);
        return merged;
    }
    if let Some(pos) = existing.rfind("</html>") {
        let mut merged = existing.to_string();
        merged.insert_str(pos, &wrapped);
        return merged;
    }
    format!("{existing}\n{wrapped}")
}

fn extract_html_body_inner(html: &str) -> Option<String> {
    let lower = html.to_lowercase();
    let start = lower.find("<body")?;
    let start_tag_end = lower[start..].find('>')? + start + 1;
    let end = lower.rfind("</body>")?;
    if end <= start_tag_end {
        return None;
    }
    Some(html[start_tag_end..end].trim().to_string())
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteWorkspaceFilePayload {
    pub workspace_root: String,
    pub relative_path: String,
    pub content: String,
    /// overwrite | skip_if_exists | merge_html_body
    pub merge_mode: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteWorkspaceFileResult {
    pub path: String,
    pub written: bool,
    pub merged: bool,
    pub skipped: bool,
}

#[tauri::command]
pub fn read_workspace_file(workspace_root: String, relative_path: String) -> Result<String, String> {
    let root = PathBuf::from(workspace_root.trim());
    if !is_valid_project_root(root.to_string_lossy().into_owned()) {
        return Err("Invalid workspace root".into());
    }
    let root_canon = canonical_workspace_root(&root)?;
    let rel = sanitize_relative_path(&relative_path)?;
    let target = root.join(rel);
    if !target.is_file() {
        return Err(format!("File not found: {}", target.display()));
    }
    let file_canon = existing_file_under_root(&root_canon, &target)?;
    fs::read_to_string(&file_canon).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn delete_workspace_file(workspace_root: String, relative_path: String) -> Result<bool, String> {
    let root = PathBuf::from(workspace_root.trim());
    if !is_valid_project_root(root.to_string_lossy().into_owned()) {
        return Err("Invalid workspace root".into());
    }
    let root_canon = canonical_workspace_root(&root)?;
    let rel = sanitize_relative_path(&relative_path)?;
    let target = root.join(rel);
    if !target.exists() {
        return Ok(false);
    }
    if !target.is_file() {
        return Err("Refusing to delete a non-file workspace path".into());
    }
    let file_canon = existing_file_under_root(&root_canon, &target)?;
    fs::remove_file(file_canon).map_err(|e| e.to_string())?;
    Ok(true)
}

#[tauri::command]
pub fn write_workspace_file(payload: WriteWorkspaceFilePayload) -> Result<WriteWorkspaceFileResult, String> {
    let root = PathBuf::from(payload.workspace_root.trim());
    if !is_valid_project_root(root.to_string_lossy().into_owned()) {
        return Err("Invalid workspace root".into());
    }

    let root_canon = canonical_workspace_root(&root)?;
    let rel = sanitize_relative_path(&payload.relative_path)?;

    let target = root.join(rel);
    let merge_mode = payload
        .merge_mode
        .unwrap_or_else(|| "overwrite".into())
        .to_lowercase();

    ensure_parent_under_root(&root_canon, &target)?;
    if target.exists() {
        existing_file_under_root(&root_canon, &target)?;
    }

    if target.exists() && merge_mode == "skip_if_exists" {
        return Ok(WriteWorkspaceFileResult {
            path: target.to_string_lossy().into_owned(),
            written: false,
            merged: false,
            skipped: true,
        });
    }

    if target.exists() && merge_mode == "merge_html_body" {
        let existing = fs::read_to_string(&target).map_err(|e| e.to_string())?;
        let merged = merge_html_body(&existing, &payload.content);
        fs::write(&target, merged).map_err(|e| e.to_string())?;
        return Ok(WriteWorkspaceFileResult {
            path: target.to_string_lossy().into_owned(),
            written: true,
            merged: true,
            skipped: false,
        });
    }

    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let mut file = fs::File::create(&target).map_err(|e| e.to_string())?;
    file.write_all(payload.content.as_bytes())
        .map_err(|e| e.to_string())?;

    Ok(WriteWorkspaceFileResult {
        path: target.to_string_lossy().into_owned(),
        written: true,
        merged: false,
        skipped: false,
    })
}

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

    #[test]
    fn rejects_parent_and_absolute_relative_paths() {
        assert!(sanitize_relative_path("../outside.txt").is_err());
        assert!(sanitize_relative_path("/etc/passwd").is_ok());
        assert!(sanitize_relative_path("nested/../../outside.txt").is_err());
    }

    #[test]
    fn strips_invisible_path_characters() {
        assert_eq!(normalize_input_path(" \u{200b}/tmp/project/ "), "/tmp/project");
    }

    #[test]
    fn html_merge_is_idempotent() {
        let original = "<html><body><main>Existing</main></body></html>";
        let incoming = "<html><body><section>New</section></body></html>";
        let merged = merge_html_body(original, incoming);
        assert!(merged.contains("<main>Existing</main>"));
        assert!(merged.contains("<section>New</section>"));
        assert_eq!(merge_html_body(&merged, incoming), merged);
    }

    #[test]
    fn delete_uses_the_same_relative_path_guard() {
        assert!(sanitize_relative_path("../outside.txt").is_err());
        assert!(sanitize_relative_path("src/generated.ts").is_ok());
    }
}
