// SPDX-License-Identifier: MIT
// Copyright (c) 2025-2026 SenWeaverCoding
// Licensed under the MIT License.

use anyhow::{Context, Result, bail};
use regex::Regex;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;

const MAX_TEXT_FILE_BYTES: u64 = 512 * 1024;

#[derive(Debug, Clone, Copy, Default)]
pub struct SkillAuditOptions {
    pub allow_scripts: bool,
}

#[derive(Debug, Clone, Default)]
pub struct SkillAuditReport {
    pub files_scanned: usize,
    pub findings: Vec<String>,
}

impl SkillAuditReport {
    pub fn is_clean(&self) -> bool {
        self.findings.is_empty()
    }

    pub fn summary(&self) -> String {
        self.findings.join("; ")
    }
}

pub fn audit_skill_directory_with_options(
    skill_dir: &Path,
    options: SkillAuditOptions,
) -> Result<SkillAuditReport> {
    if !skill_dir.exists() {
        bail!("Skill source does not exist: {}", skill_dir.display());
    }
    if !skill_dir.is_dir() {
        bail!("Skill source must be a directory: {}", skill_dir.display());
    }

    let canonical_root = skill_dir
        .canonicalize()
        .with_context(|| format!("failed to canonicalize {}", skill_dir.display()))?;
    let mut report = SkillAuditReport::default();

    let has_manifest =
        canonical_root.join("SKILL.md").is_file() || canonical_root.join("SKILL.toml").is_file();
    if !has_manifest {
        report.findings.push(
            "Skill root must include SKILL.md or SKILL.toml for deterministic auditing."
                .to_string(),
        );
    }

    for path in collect_paths_depth_first(&canonical_root)? {
        report.files_scanned += 1;
        audit_path(&canonical_root, &path, &mut report, options)?;
    }

    Ok(report)
}

pub fn audit_open_skill_markdown(path: &Path, repo_root: &Path) -> Result<SkillAuditReport> {
    if !path.exists() {
        bail!("Open-skill markdown not found: {}", path.display());
    }
    let canonical_repo = repo_root
        .canonicalize()
        .with_context(|| format!("failed to canonicalize {}", repo_root.display()))?;
    let canonical_path = path
        .canonicalize()
        .with_context(|| format!("failed to canonicalize {}", path.display()))?;
    if !canonical_path.starts_with(&canonical_repo) {
        bail!(
            "Open-skill markdown escapes repository root: {}",
            path.display()
        );
    }

    let mut report = SkillAuditReport {
        files_scanned: 1,
        findings: Vec::new(),
    };
    audit_markdown_file(&canonical_repo, &canonical_path, &mut report)?;
    Ok(report)
}

fn collect_paths_depth_first(root: &Path) -> Result<Vec<PathBuf>> {
    let mut stack = vec![root.to_path_buf()];
    let mut out = Vec::new();

    while let Some(current) = stack.pop() {
        out.push(current.clone());

        if !current.is_dir() {
            continue;
        }

        let mut children = Vec::new();
        for entry in fs::read_dir(&current)
            .with_context(|| format!("failed to read directory {}", current.display()))?
        {
            let entry = entry?;
            children.push(entry.path());
        }

        children.sort();
        for child in children.into_iter().rev() {
            stack.push(child);
        }
    }

    Ok(out)
}

fn audit_path(
    root: &Path,
    path: &Path,
    report: &mut SkillAuditReport,
    options: SkillAuditOptions,
) -> Result<()> {
    let metadata = fs::symlink_metadata(path)
        .with_context(|| format!("failed to read metadata for {}", path.display()))?;
    let rel = relative_display(root, path);

    if metadata.file_type().is_symlink() {
        report.findings.push(format!(
            "{rel}: symlinks are not allowed in installed skills."
        ));
        return Ok(());
    }

    if metadata.is_dir() {
        return Ok(());
    }

    if !options.allow_scripts && is_unsupported_script_file(path) {
        report.findings.push(format!(
            "{rel}: script-like files are blocked by skill security policy."
        ));
    }

    if metadata.len() > MAX_TEXT_FILE_BYTES && (is_markdown_file(path) || is_toml_file(path)) {
        report.findings.push(format!(
            "{rel}: file is too large for static audit (>{MAX_TEXT_FILE_BYTES} bytes)."
        ));
        return Ok(());
    }

    if is_markdown_file(path) {
        audit_markdown_file(root, path, report)?;
    } else if is_toml_file(path) {
        audit_manifest_file(root, path, report)?;
    }

    Ok(())
}

fn audit_markdown_file(root: &Path, path: &Path, report: &mut SkillAuditReport) -> Result<()> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read markdown file {}", path.display()))?;
    let rel = relative_display(root, path);

    if let Some(pattern) = detect_high_risk_snippet(&content) {
        report.findings.push(format!(
            "{rel}: detected high-risk command pattern ({pattern})."
        ));
    }

    for raw_target in extract_markdown_links(&content) {
        audit_markdown_link_target(root, path, &raw_target, report);
    }

    Ok(())
}

fn audit_manifest_file(root: &Path, path: &Path, report: &mut SkillAuditReport) -> Result<()> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read TOML manifest {}", path.display()))?;
    let rel = relative_display(root, path);
    let parsed: toml::Value = match toml::from_str(&content) {
        Ok(value) => value,
        Err(err) => {
            report
                .findings
                .push(format!("{rel}: invalid TOML manifest ({err})."));
            return Ok(());
        }
    };

    if let Some(tools) = parsed.get("tools").and_then(toml::Value::as_array) {
        for (idx, tool) in tools.iter().enumerate() {
            let command = tool.get("command").and_then(toml::Value::as_str);
            let kind = tool
                .get("kind")
                .and_then(toml::Value::as_str)
                .unwrap_or("unknown");

            if let Some(command) = command {
                if contains_shell_chaining(command) {
                    report.findings.push(format!(
                        "{rel}: tools[{idx}].command uses shell chaining operators, which are blocked."
                    ));
                }
                if let Some(pattern) = detect_high_risk_snippet(command) {
                    report.findings.push(format!(
                        "{rel}: tools[{idx}].command matches high-risk pattern ({pattern})."
                    ));
                }
            } else {
                report
                    .findings
                    .push(format!("{rel}: tools[{idx}] is missing a command field."));
            }

            if (kind.eq_ignore_ascii_case("script") || kind.eq_ignore_ascii_case("shell"))
                && command.is_some_and(|value| value.trim().is_empty())
            {
                report
                    .findings
                    .push(format!("{rel}: tools[{idx}] has an empty {kind} command."));
            }
        }
    }

    if let Some(prompts) = parsed.get("prompts").and_then(toml::Value::as_array) {
        for (idx, prompt) in prompts.iter().enumerate() {
            if let Some(prompt) = prompt.as_str() {
                if let Some(pattern) = detect_high_risk_snippet(prompt) {
                    report.findings.push(format!(
                        "{rel}: prompts[{idx}] contains high-risk pattern ({pattern})."
                    ));
                }
            }
        }
    }

    Ok(())
}

fn audit_markdown_link_target(
    root: &Path,
    source: &Path,
    raw: &str,
    report: &mut SkillAuditReport,
) {
    let normalized = normalize_markdown_target(raw);
    if normalized.is_empty() || normalized.starts_with('#') {
        return;
    }

    let rel = relative_display(root, source);

    if let Some(scheme) = url_scheme(normalized) {
        if matches!(scheme, "http" | "https" | "mailto") {
            if has_markdown_suffix(normalized) {
                report.findings.push(format!(
                    "{rel}: remote markdown links are blocked by skill security audit ({normalized})."
                ));
            }
            return;
        }

        report.findings.push(format!(
            "{rel}: unsupported URL scheme in markdown link ({normalized})."
        ));
        return;
    }

    let stripped = strip_query_and_fragment(normalized);
    if stripped.is_empty() {
        return;
    }

    if looks_like_absolute_path(stripped) {
        report.findings.push(format!(
            "{rel}: absolute markdown link paths are not allowed ({normalized})."
        ));
        return;
    }

    if has_script_suffix(stripped) {
        report.findings.push(format!(
            "{rel}: markdown links to script files are blocked ({normalized})."
        ));
    }

    if !has_markdown_suffix(stripped) {
        return;
    }

    let Some(base_dir) = source.parent() else {
        report.findings.push(format!(
            "{rel}: failed to resolve parent directory for markdown link ({normalized})."
        ));
        return;
    };
    let linked_path = base_dir.join(stripped);

    match linked_path.canonicalize() {
        Ok(canonical_target) => {
            if !canonical_target.starts_with(root) {

                if let Some(skills_root) = skills_root_for(root) {
                    if canonical_target.starts_with(&skills_root) {

                        if !canonical_target.is_file() {
                            report.findings.push(format!(
                                "{rel}: markdown link must point to a file ({normalized})."
                            ));
                        }
                        return;
                    }
                }

                report.findings.push(format!(
                    "{rel}: markdown link escapes skill root ({normalized})."
                ));
                return;
            }
            if !canonical_target.is_file() {
                report.findings.push(format!(
                    "{rel}: markdown link must point to a file ({normalized})."
                ));
            }
        }
        Err(_) => {

            if is_cross_skill_reference(stripped) {

                return;
            }
            report.findings.push(format!(
                "{rel}: markdown link points to a missing file ({normalized})."
            ));
        }
    }
}

fn is_cross_skill_reference(target: &str) -> bool {
    let path = Path::new(target);

    if path
        .components()
        .any(|component| component == Component::ParentDir)
    {
        return true;
    }

    let stripped = target.strip_prefix("./").unwrap_or(target);

    !stripped.contains('/') && !stripped.contains('\\') && has_markdown_suffix(stripped)
}

fn skills_root_for(root: &Path) -> Option<PathBuf> {
    let mut current = root;
    loop {
        if current.file_name().is_some_and(|name| name == "skills") {
            return Some(current.to_path_buf());
        }
        current = current.parent()?;
    }
}

fn relative_display(root: &Path, path: &Path) -> String {
    if let Ok(rel) = path.strip_prefix(root) {
        if rel.as_os_str().is_empty() {
            return ".".to_string();
        }
        return rel.display().to_string();
    }
    path.display().to_string()
}

fn is_markdown_file(path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown"))
}

fn is_toml_file(path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
}

fn is_unsupported_script_file(path: &Path) -> bool {
    has_script_suffix(path.to_string_lossy().as_ref()) || has_shell_shebang(path)
}

fn has_script_suffix(raw: &str) -> bool {
    let lowered = raw.to_ascii_lowercase();
    let script_suffixes = [
        ".sh", ".bash", ".zsh", ".ksh", ".fish", ".ps1", ".bat", ".cmd",
    ];
    script_suffixes
        .iter()
        .any(|suffix| lowered.ends_with(suffix))
}

fn has_shell_shebang(path: &Path) -> bool {
    let Ok(content) = fs::read(path) else {
        return false;
    };
    let prefix = &content[..content.len().min(128)];
    let shebang_line = String::from_utf8_lossy(prefix)
        .lines()
        .next()
        .unwrap_or_default()
        .trim()
        .to_ascii_lowercase();
    let Some(interpreter) = shebang_interpreter(&shebang_line) else {
        return false;
    };

    matches!(
        interpreter,
        "sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell"
    )
}

fn shebang_interpreter(line: &str) -> Option<&str> {
    let shebang = line.strip_prefix("#!")?.trim();
    if shebang.is_empty() {
        return None;
    }

    let mut parts = shebang.split_whitespace();
    let first = parts.next()?;
    let first_basename = Path::new(first).file_name()?.to_str()?;

    if first_basename == "env" {
        for part in parts {
            if part.starts_with('-') {
                continue;
            }
            return Path::new(part).file_name()?.to_str();
        }
        return None;
    }

    Some(first_basename)
}

fn extract_markdown_links(content: &str) -> Vec<String> {
    static MARKDOWN_LINK_RE: OnceLock<Regex> = OnceLock::new();
    let regex = MARKDOWN_LINK_RE.get_or_init(|| {
        Regex::new(r"\[[^\]]*\]\(([^)]+)\)").expect("markdown link regex must compile")
    });

    regex
        .captures_iter(content)
        .filter_map(|capture| capture.get(1))
        .map(|target| target.as_str().trim().to_string())
        .collect()
}

fn normalize_markdown_target(raw_target: &str) -> &str {
    let trimmed = raw_target.trim();
    let trimmed = trimmed.strip_prefix('<').unwrap_or(trimmed);
    let trimmed = trimmed.strip_suffix('>').unwrap_or(trimmed);
    trimmed.split_whitespace().next().unwrap_or_default()
}

fn strip_query_and_fragment(input: &str) -> &str {
    let mut end = input.len();
    if let Some(idx) = input.find('#') {
        end = end.min(idx);
    }
    if let Some(idx) = input.find('?') {
        end = end.min(idx);
    }
    &input[..end]
}

fn url_scheme(target: &str) -> Option<&str> {
    let (scheme, rest) = target.split_once(':')?;
    if scheme.is_empty() || rest.is_empty() {
        return None;
    }
    if !scheme
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
    {
        return None;
    }
    Some(scheme)
}

fn looks_like_absolute_path(target: &str) -> bool {
    let path = Path::new(target);
    if path.is_absolute() {
        return true;
    }

    let bytes = target.as_bytes();
    if bytes.len() >= 3
        && bytes[0].is_ascii_alphabetic()
        && bytes[1] == b':'
        && (bytes[2] == b'\\' || bytes[2] == b'/')
    {
        return true;
    }

    if target.starts_with("~/") {
        return true;
    }

    false
}

fn has_markdown_suffix(target: &str) -> bool {
    let lowered = target.to_ascii_lowercase();
    lowered.ends_with(".md") || lowered.ends_with(".markdown")
}

fn contains_shell_chaining(command: &str) -> bool {
    ["&&", "||", ";", "\n", "\r", "`", "$("]
        .iter()
        .any(|needle| command.contains(needle))
}

fn detect_high_risk_snippet(content: &str) -> Option<&'static str> {
    static HIGH_RISK_PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
    let patterns = HIGH_RISK_PATTERNS.get_or_init(|| {
        vec![
            (
                Regex::new(r"(?im)\bcurl\b[^\n|]{0,200}\|\s*(?:sh|bash|zsh)\b").expect("regex"),
                "curl-pipe-shell",
            ),
            (
                Regex::new(r"(?im)\bwget\b[^\n|]{0,200}\|\s*(?:sh|bash|zsh)\b").expect("regex"),
                "wget-pipe-shell",
            ),
            (
                Regex::new(r"(?im)\b(?:invoke-expression|iex)\b").expect("regex"),
                "powershell-iex",
            ),
            (
                Regex::new(r"(?im)\brm\s+-rf\s+/").expect("regex"),
                "destructive-rm-rf-root",
            ),
            (
                Regex::new(r"(?im)\bnc(?:at)?\b[^\n]{0,120}\s-e\b").expect("regex"),
                "netcat-remote-exec",
            ),
            (
                Regex::new(r"(?im)\bdd\s+if=").expect("regex"),
                "disk-overwrite-dd",
            ),
            (
                Regex::new(r"(?im)\bmkfs(?:\.[a-z0-9]+)?\b").expect("regex"),
                "filesystem-format",
            ),
            (
                Regex::new(r"(?im):\(\)\s*\{\s*:\|\:&\s*\};:").expect("regex"),
                "fork-bomb",
            ),
        ]
    });

    patterns
        .iter()
        .find_map(|(regex, label)| regex.is_match(content).then_some(*label))
}
