//! Repo-local secret inventory for agent-facing tooling.
//!
//! `.tsafe/tooling/keys.ini` records secret slots, purpose, consumer, and
//! rotation intent. It never stores secret values. MCP and CLI callers use the
//! same parser/writer here so suggestions and validation share one contract.

use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use chrono::Utc;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;

use crate::vault::validate_secret_key;

pub const KEYS_SCHEMA: &str = "tsafe.tooling.keys.v1";
pub const POLICY_SCHEMA: &str = "tsafe.tooling_policy.v1";

pub type Result<T> = std::result::Result<T, ToolingInventoryError>;

#[derive(Debug, Error)]
pub enum ToolingInventoryError {
    #[error("{0}")]
    InvalidInput(String),
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InventoryEntry {
    pub section: String,
    pub key: String,
    pub purpose: String,
    pub consumer: String,
    pub rotation: String,
    pub line: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolingInitReport {
    pub created: bool,
    pub root: PathBuf,
    pub tooling_dir: PathBuf,
    pub keys_path: PathBuf,
    pub policy_path: PathBuf,
    pub readme_path: PathBuf,
    pub namespace: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryCheckReport {
    pub ok: bool,
    pub root: PathBuf,
    pub keys_path: PathBuf,
    pub namespace: Option<String>,
    pub entries: Vec<InventoryEntry>,
    pub warnings: Vec<String>,
    pub errors: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SuggestKey {
    pub key: String,
    pub purpose: String,
    pub consumer: String,
    pub rotation: String,
    pub section: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestKeysRequest {
    pub namespace: String,
    pub source: String,
    pub reason: String,
    pub apply: bool,
    pub keys: Vec<SuggestKey>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestKeysReport {
    pub suggestion_id: String,
    pub root: PathBuf,
    pub keys_path: PathBuf,
    pub suggestions_path: PathBuf,
    pub receipt_path: Option<PathBuf>,
    pub namespace: String,
    pub added_keys: Vec<String>,
    pub existing_keys: Vec<String>,
    pub preview_keys: Vec<String>,
    pub applied: bool,
    pub vault_values_written: bool,
}

#[derive(Debug, Clone)]
struct ParsedInventory {
    namespace: Option<String>,
    entries: Vec<InventoryEntry>,
    warnings: Vec<String>,
    errors: Vec<String>,
}

pub fn tooling_dir(root: &Path) -> PathBuf {
    root.join(".tsafe").join("tooling")
}

pub fn keys_path(root: &Path) -> PathBuf {
    tooling_dir(root).join("keys.ini")
}

pub fn policy_path(root: &Path) -> PathBuf {
    tooling_dir(root).join("policy.toml")
}

pub fn suggestions_path(root: &Path) -> PathBuf {
    tooling_dir(root).join("suggestions.jsonl")
}

pub fn receipts_dir(root: &Path) -> PathBuf {
    tooling_dir(root).join("receipts")
}

pub fn readme_path(root: &Path) -> PathBuf {
    tooling_dir(root).join("README.md")
}

pub fn init_tooling(
    root: &Path,
    namespace: Option<&str>,
    force: bool,
) -> Result<ToolingInitReport> {
    let root = normalize_root(root);
    let namespace = normalize_namespace(namespace.unwrap_or(&default_namespace(&root)))?;
    let tooling = tooling_dir(&root);
    fs::create_dir_all(&tooling)?;
    fs::create_dir_all(receipts_dir(&root))?;

    let keys = keys_path(&root);
    let policy = policy_path(&root);
    let readme = readme_path(&root);
    let mut created = false;

    if force || !keys.exists() {
        fs::write(&keys, render_keys_ini(&root, &namespace))?;
        created = true;
    }
    if force || !policy.exists() {
        fs::write(&policy, render_policy(&namespace))?;
        created = true;
    }
    if force || !readme.exists() {
        fs::write(&readme, render_readme())?;
        created = true;
    }

    Ok(ToolingInitReport {
        created,
        root,
        tooling_dir: tooling,
        keys_path: keys,
        policy_path: policy,
        readme_path: readme,
        namespace,
    })
}

pub fn check_inventory(root: &Path) -> Result<InventoryCheckReport> {
    let root = normalize_root(root);
    let keys = keys_path(&root);
    if !keys.exists() {
        return Ok(InventoryCheckReport {
            ok: false,
            root,
            keys_path: keys,
            namespace: None,
            entries: Vec::new(),
            warnings: Vec::new(),
            errors: vec!["missing .tsafe/tooling/keys.ini".to_string()],
        });
    }

    let text = fs::read_to_string(&keys)?;
    let parsed = parse_keys_ini(&text);
    let ok = parsed.errors.is_empty();

    Ok(InventoryCheckReport {
        ok,
        root,
        keys_path: keys,
        namespace: parsed.namespace,
        entries: parsed.entries,
        warnings: parsed.warnings,
        errors: parsed.errors,
    })
}

pub fn suggest_keys(root: &Path, request: SuggestKeysRequest) -> Result<SuggestKeysReport> {
    if request.keys.is_empty() {
        return Err(ToolingInventoryError::InvalidInput(
            "at least one suggested key is required".to_string(),
        ));
    }
    validate_comment_field("source", &request.source)?;
    validate_comment_field("reason", &request.reason)?;

    let root = normalize_root(root);
    let namespace = normalize_namespace(&request.namespace)?;
    if !keys_path(&root).exists() {
        init_tooling(&root, Some(&namespace), false)?;
    }

    let check = check_inventory(&root)?;
    if !check.ok {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "keys.ini is not valid: {}",
            check.errors.join("; ")
        )));
    }

    let existing: BTreeSet<String> = check
        .entries
        .iter()
        .map(|entry| entry.key.clone())
        .collect();
    let suggestion_id = format!("sug_{}", Uuid::new_v4().simple());
    let mut added = Vec::new();
    let mut existing_keys = Vec::new();
    let mut preview = Vec::new();
    let mut by_section: BTreeMap<String, Vec<(String, &SuggestKey)>> = BTreeMap::new();

    for item in &request.keys {
        let full_key = normalize_suggested_key(&namespace, &item.key)?;
        if has_plaintext_secret_shape(&item.purpose)
            || has_plaintext_secret_shape(&item.consumer)
            || has_plaintext_secret_shape(&item.rotation)
        {
            return Err(ToolingInventoryError::InvalidInput(
                "suggestions may describe secret slots but must not include secret values"
                    .to_string(),
            ));
        }
        validate_metadata_field("purpose", &item.purpose)?;
        validate_metadata_field("consumer", &item.consumer)?;
        validate_metadata_field("rotation", &item.rotation)?;
        if existing.contains(&full_key) {
            existing_keys.push(full_key);
            continue;
        }
        preview.push(full_key.clone());
        if request.apply {
            added.push(full_key.clone());
            let section = item
                .section
                .as_deref()
                .map(sanitize_section)
                .unwrap_or_else(|| "suggested".to_string());
            by_section
                .entry(section)
                .or_default()
                .push((full_key, item));
        }
    }

    if request.apply && !by_section.is_empty() {
        append_to_keys_ini(
            &root,
            &suggestion_id,
            &request.source,
            &request.reason,
            by_section,
        )?;
    }

    let suggestions = suggestions_path(&root);
    append_suggestion_record(
        &root,
        &suggestion_id,
        &request,
        &preview,
        &added,
        &existing_keys,
    )?;

    let receipt = if request.apply {
        Some(write_receipt(
            &root,
            &suggestion_id,
            &request,
            &added,
            &existing_keys,
        )?)
    } else {
        None
    };

    Ok(SuggestKeysReport {
        suggestion_id,
        root: root.clone(),
        keys_path: keys_path(&root),
        suggestions_path: suggestions,
        receipt_path: receipt,
        namespace,
        added_keys: added,
        existing_keys,
        preview_keys: preview,
        applied: request.apply,
        vault_values_written: false,
    })
}

fn normalize_root(root: &Path) -> PathBuf {
    if root.as_os_str().is_empty() {
        PathBuf::from(".")
    } else {
        root.to_path_buf()
    }
}

fn default_namespace(root: &Path) -> String {
    let name = root
        .file_name()
        .and_then(|s| s.to_str())
        .filter(|s| !s.trim().is_empty())
        .unwrap_or("repo");
    format!("{}/", sanitize_key_part(name))
}

fn normalize_namespace(raw: &str) -> Result<String> {
    let trimmed = raw.trim().trim_start_matches('/').replace('\\', "/");
    if trimmed.is_empty() {
        return Err(ToolingInventoryError::InvalidInput(
            "namespace must not be empty".to_string(),
        ));
    }
    let namespace = if trimmed.ends_with('/') {
        trimmed
    } else {
        format!("{trimmed}/")
    };
    if namespace.contains("//")
        || namespace
            .trim_end_matches('/')
            .split('/')
            .any(|part| part.trim().is_empty())
    {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "namespace '{namespace}' is not valid"
        )));
    }
    validate_secret_key(namespace.trim_end_matches('/')).map_err(|err| {
        ToolingInventoryError::InvalidInput(format!("namespace '{namespace}' is not valid: {err}"))
    })?;
    Ok(namespace)
}

fn normalize_suggested_key(namespace: &str, raw: &str) -> Result<String> {
    let key = raw.trim().trim_start_matches('/').replace('\\', "/");
    if key.is_empty() {
        return Err(ToolingInventoryError::InvalidInput(
            "suggested key must not be empty".to_string(),
        ));
    }
    let full = if key.starts_with(namespace) {
        key
    } else if key.contains('/') {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "suggested key '{key}' is outside namespace '{namespace}'"
        )));
    } else {
        format!("{namespace}{}", sanitize_key_part(&key))
    };
    if full.contains("//") || full.ends_with('/') {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "suggested key '{full}' is not valid"
        )));
    }
    validate_secret_key(&full).map_err(|err| {
        ToolingInventoryError::InvalidInput(format!("suggested key '{full}' is not valid: {err}"))
    })?;
    Ok(full)
}

fn sanitize_key_part(raw: &str) -> String {
    raw.chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
                ch
            } else {
                '_'
            }
        })
        .collect()
}

fn sanitize_section(raw: &str) -> String {
    let section: String = raw
        .trim()
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
                ch
            } else {
                '-'
            }
        })
        .filter(|ch| !ch.is_whitespace())
        .collect();
    if section.is_empty() {
        "suggested".to_string()
    } else {
        section
    }
}

fn validate_metadata_field(label: &str, raw: &str) -> Result<()> {
    let value = raw.trim();
    if value.is_empty() {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "{label} must not be empty"
        )));
    }
    if value.contains('|') || value.chars().any(|ch| ch.is_control()) {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "{label} must not contain control characters or `|`"
        )));
    }
    Ok(())
}

fn validate_comment_field(label: &str, raw: &str) -> Result<()> {
    let value = raw.trim();
    if value.is_empty() {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "{label} must not be empty"
        )));
    }
    if value.chars().any(|ch| ch.is_control()) {
        return Err(ToolingInventoryError::InvalidInput(format!(
            "{label} must not contain control characters"
        )));
    }
    Ok(())
}

fn render_keys_ini(root: &Path, namespace: &str) -> String {
    let repo = root.file_name().and_then(|s| s.to_str()).unwrap_or("repo");
    format!(
        r#"# tsafe secret inventory for {repo}
# Namespace: {namespace}
# Format: key = purpose | consumer | rotation
# Values never belong in this file.

[inventory]
schema = {KEYS_SCHEMA}
namespace = {namespace}

[ci-cd-spn]
# key/name = purpose | consumer | rotation
# {namespace}ci_cd_app_id = SPN app ID | CI service connection | static

[sql-bridge]
# {namespace}sql_password = SQL password | runtime bridge | 365d KV policy

[scim]
# {namespace}scim_pat = SCIM PAT | provisioning app | manual rotation on expiry

[ops-read-only]
# {namespace}readonly_app_id = read-only SPN app ID | discovery scripts | static
"#
    )
}

fn render_policy(namespace: &str) -> String {
    format!(
        r#"schema = "{POLICY_SCHEMA}"

[namespace]
default = "{namespace}"
allow = ["{namespace}"]

[mcp]
suggest_enabled = true
auto_write_keys_ini = true
auto_write_vault_values = false
require_receipt = true
reject_plaintext_values = true
"#
    )
}

fn render_readme() -> &'static str {
    r#"# tsafe tooling inventory

This folder records secret slots for this repository. It is safe to commit when
it contains only key names, purpose, consumer, and rotation metadata.

- `keys.ini` is the human inventory.
- `policy.toml` controls agent/MCP suggestion behavior.
- `suggestions.jsonl` is an append-only suggestion log.
- `receipts/` contains one JSON receipt per applied suggestion.

Do not put secret values, tokens, private keys, vault files, or dotenv files in
this folder.
"#
}

fn parse_keys_ini(text: &str) -> ParsedInventory {
    let mut section: Option<String> = None;
    let mut namespace = None;
    let mut entries = Vec::new();
    let mut warnings = Vec::new();
    let mut errors = Vec::new();
    let mut seen = BTreeSet::new();

    for (idx, raw_line) in text.lines().enumerate() {
        let line_no = idx + 1;
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
            continue;
        }
        if line.starts_with('[') && line.ends_with(']') {
            let name = line.trim_start_matches('[').trim_end_matches(']').trim();
            if name.is_empty() {
                errors.push(format!("line {line_no}: empty section name"));
                section = None;
            } else {
                section = Some(name.to_string());
            }
            continue;
        }

        let current_section = section.clone().unwrap_or_else(|| "default".to_string());
        let Some((lhs, rhs)) = line.split_once('=') else {
            errors.push(format!(
                "line {line_no}: expected `key = purpose | consumer | rotation`"
            ));
            continue;
        };
        let key = lhs.trim();
        let value = rhs.trim();

        if current_section == "inventory" {
            match key {
                "schema" if value != KEYS_SCHEMA => warnings.push(format!(
                    "line {line_no}: schema '{value}' is not the current {KEYS_SCHEMA}"
                )),
                "namespace" => match normalize_namespace(value) {
                    Ok(ns) => namespace = Some(ns),
                    Err(err) => errors.push(format!("line {line_no}: {err}")),
                },
                _ => {}
            }
            continue;
        }

        let parts: Vec<&str> = value.split('|').map(str::trim).collect();
        if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
            errors.push(format!(
                "line {line_no}: expected `key = purpose | consumer | rotation`"
            ));
            continue;
        }
        if key.is_empty() || !key.contains('/') {
            errors.push(format!(
                "line {line_no}: key '{key}' must include a namespace prefix"
            ));
            continue;
        }
        if let Err(err) = validate_secret_key(key) {
            errors.push(format!("line {line_no}: key '{key}' is not valid: {err}"));
            continue;
        }
        if has_plaintext_secret_shape(value) {
            errors.push(format!(
                "line {line_no}: row appears to contain a plaintext secret value"
            ));
            continue;
        }
        if !seen.insert(key.to_string()) {
            errors.push(format!("line {line_no}: duplicate key '{key}'"));
        }
        if let Some(ns) = namespace.as_deref() {
            if !key.starts_with(ns) {
                errors.push(format!(
                    "line {line_no}: key '{key}' is outside namespace '{ns}'"
                ));
            }
        }

        entries.push(InventoryEntry {
            section: current_section,
            key: key.to_string(),
            purpose: parts[0].to_string(),
            consumer: parts[1].to_string(),
            rotation: parts[2].to_string(),
            line: line_no,
        });
    }

    if namespace.is_none() {
        warnings.push("missing [inventory] namespace".to_string());
    }

    ParsedInventory {
        namespace,
        entries,
        warnings,
        errors,
    }
}

fn has_plaintext_secret_shape(value: &str) -> bool {
    let v = value.trim();
    if v.contains("-----BEGIN ") || v.contains(" PRIVATE KEY-----") {
        return true;
    }
    let lower = v.to_ascii_lowercase();
    if lower.contains("github_pat_") || lower.contains("ghp_") || lower.contains("sk-") {
        return true;
    }
    v.split(|ch: char| !ch.is_ascii_alphanumeric())
        .any(|part| part.starts_with("AKIA") && part.len() >= 16)
}

fn append_to_keys_ini(
    root: &Path,
    suggestion_id: &str,
    source: &str,
    reason: &str,
    by_section: BTreeMap<String, Vec<(String, &SuggestKey)>>,
) -> Result<()> {
    let path = keys_path(root);
    let mut file = fs::OpenOptions::new()
        .append(true)
        .create(true)
        .open(&path)?;
    writeln!(
        file,
        "\n# tsafe suggestion {suggestion_id}: source={source}; reason={reason}"
    )?;
    for (section, rows) in by_section {
        writeln!(file, "\n[{section}]")?;
        for (full_key, item) in rows {
            writeln!(
                file,
                "{full_key} = {} | {} | {}",
                item.purpose.trim(),
                item.consumer.trim(),
                item.rotation.trim()
            )?;
        }
    }
    Ok(())
}

fn append_suggestion_record(
    root: &Path,
    suggestion_id: &str,
    request: &SuggestKeysRequest,
    preview: &[String],
    added: &[String],
    existing: &[String],
) -> Result<()> {
    let path = suggestions_path(root);
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;
    let record = serde_json::json!({
        "schema": "tsafe.tooling.suggestion.v1",
        "suggestion_id": suggestion_id,
        "created_at": Utc::now().to_rfc3339(),
        "source": request.source,
        "reason": request.reason,
        "apply": request.apply,
        "preview_keys": preview,
        "added_keys": added,
        "existing_keys": existing,
        "vault_values_written": false,
    });
    writeln!(file, "{}", serde_json::to_string(&record)?)?;
    Ok(())
}

fn write_receipt(
    root: &Path,
    suggestion_id: &str,
    request: &SuggestKeysRequest,
    added: &[String],
    existing: &[String],
) -> Result<PathBuf> {
    let dir = receipts_dir(root);
    fs::create_dir_all(&dir)?;
    let path = dir.join(format!("{suggestion_id}.json"));
    let receipt = serde_json::json!({
        "schema": "tsafe.tooling.receipt.v1",
        "suggestion_id": suggestion_id,
        "created_at": Utc::now().to_rfc3339(),
        "source": request.source,
        "reason": request.reason,
        "added_keys": added,
        "existing_keys": existing,
        "keys_ini": keys_path(root),
        "vault_values_written": false,
    });
    fs::write(&path, serde_json::to_string_pretty(&receipt)?)?;
    Ok(path)
}
