use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

/// Where the version ledger lives, relative to a generated project's root.
/// Generator-only bookkeeping: `generate` and `add-version` read/write it,
/// but the generated project's own runtime code never parses it — the
/// version list it needs is instead baked into a handful of Tera-rendered
/// files each time `add-version` runs (mirroring how `auth_method_keys` is
/// already baked into `AuthMethodSchema` at generation time), so no target
/// language needs a JSON-manifest-parsing dependency at runtime.
pub const LEDGER_RELATIVE_PATH: &str = ".mcpify/versions.json";

const CURRENT_SCHEMA_VERSION: u32 = 1;

/// One version's on-disk artifacts, both relative to the project root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionEntry {
    pub db_file: String,
    pub schemas_file: String,
    /// The original `-i/--input` value (file path or URL) this version was
    /// ingested from, kept purely for operator reference.
    pub source: String,
    pub added_at: u64,
}

/// A generated project's multi-version bookkeeping. `language`,
/// `display_name`, and `project_name` are recorded once at `generate` time
/// and never overwritten by a later `add-version` call — a later spec's
/// `info.title` must not churn these across every `add-version` invocation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ledger {
    pub schema_version: u32,
    pub language: String,
    pub display_name: String,
    pub project_name: String,
    pub default_version: String,
    pub versions: IndexMap<String, VersionEntry>,
}

impl Ledger {
    pub fn new(
        language: impl Into<String>,
        display_name: impl Into<String>,
        project_name: impl Into<String>,
    ) -> Self {
        Ledger {
            schema_version: CURRENT_SCHEMA_VERSION,
            language: language.into(),
            display_name: display_name.into(),
            project_name: project_name.into(),
            default_version: String::new(),
            versions: IndexMap::new(),
        }
    }

    /// Version labels in insertion (first-added-first) order, for
    /// deterministic rendering into the version-aware templates and the
    /// `versions` CLI subcommand.
    pub fn version_labels(&self) -> Vec<String> {
        self.versions.keys().cloned().collect()
    }
}

pub fn path_for(project_dir: &Path) -> PathBuf {
    project_dir.join(LEDGER_RELATIVE_PATH)
}

/// Reads and validates a project's version ledger. A missing ledger means
/// either a pre-v8 project or one not generated by mcpify at all — mcpify
/// can't safely infer the original spec/version label after the fact, so
/// this is a hard error rather than a synthesized default.
pub async fn read(project_dir: &Path) -> Result<Ledger> {
    let path = path_for(project_dir);
    let text = tokio::fs::read_to_string(&path).await.with_context(|| {
        format!(
            "no version ledger found at '{}' — this project predates mcpify's multi-version \
             support (or wasn't generated by mcpify). `add-version` can only extend a project \
             whose ledger already exists; regenerate the project with a current version of \
             mcpify first.",
            path.display()
        )
    })?;
    let ledger: Ledger = serde_json::from_str(&text)
        .with_context(|| format!("failed to parse version ledger at '{}'", path.display()))?;
    if ledger.schema_version != CURRENT_SCHEMA_VERSION {
        anyhow::bail!(
            "version ledger at '{}' uses schema_version {} but this mcpify build only understands \
             schema_version {CURRENT_SCHEMA_VERSION}",
            path.display(),
            ledger.schema_version,
        );
    }
    Ok(ledger)
}

/// Writes the ledger atomically (write a sibling `.tmp` file, then rename)
/// so a crash mid-write never leaves a half-written, unparseable ledger
/// behind.
pub async fn write(project_dir: &Path, ledger: &Ledger) -> Result<()> {
    let path = path_for(project_dir);
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent)
            .await
            .context("failed to create .mcpify directory")?;
    }
    let json_text =
        serde_json::to_string_pretty(ledger).context("failed to serialize version ledger")?;
    let tmp_path = path.with_extension("json.tmp");
    tokio::fs::write(&tmp_path, json_text)
        .await
        .context("failed to write version ledger temp file")?;
    tokio::fs::rename(&tmp_path, &path)
        .await
        .context("failed to finalize version ledger")?;
    Ok(())
}

pub fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    fn sample_ledger() -> Ledger {
        let mut ledger = Ledger::new("typescript", "Jira Software", "jira-mcp");
        ledger.default_version = "11.3".to_string();
        ledger.versions.insert(
            "11.3".to_string(),
            VersionEntry {
                db_file: "mcp_store.db".to_string(),
                schemas_file: "src/validation/generated-schemas.json".to_string(),
                source: "https://example.com/11.3.json".to_string(),
                added_at: 1_700_000_000,
            },
        );
        ledger
    }

    #[tokio::test]
    async fn round_trips_through_read_and_write() {
        let dir = tempfile::tempdir().unwrap();
        let original = sample_ledger();

        write(dir.path(), &original).await.unwrap();
        let loaded = read(dir.path()).await.unwrap();

        assert_eq!(loaded, original);
    }

    #[tokio::test]
    async fn read_errors_clearly_when_ledger_is_missing() {
        let dir = tempfile::tempdir().unwrap();
        let err = read(dir.path()).await.unwrap_err();
        assert!(err.to_string().contains("no version ledger found"));
    }

    #[tokio::test]
    async fn read_rejects_a_future_schema_version() {
        let dir = tempfile::tempdir().unwrap();
        let mut ledger = sample_ledger();
        ledger.schema_version = CURRENT_SCHEMA_VERSION + 1;
        write(dir.path(), &ledger).await.unwrap();

        let err = read(dir.path()).await.unwrap_err();
        assert!(err.to_string().contains("schema_version"));
    }

    #[tokio::test]
    async fn preserves_insertion_order_across_a_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let mut ledger = sample_ledger();
        ledger.versions.insert(
            "11.2".to_string(),
            VersionEntry {
                db_file: "mcp_store_v11.2.db".to_string(),
                schemas_file: "src/validation/generated-schemas_v11.2.json".to_string(),
                source: "https://example.com/11.2.json".to_string(),
                added_at: 1_700_000_100,
            },
        );

        write(dir.path(), &ledger).await.unwrap();
        let loaded = read(dir.path()).await.unwrap();

        assert_eq!(loaded.version_labels(), vec!["11.3", "11.2"]);
    }

    #[test]
    fn path_for_points_at_dot_mcpify_versions_json() {
        let project_dir = PathBuf::from("/tmp/some-project");
        assert_eq!(
            path_for(&project_dir),
            PathBuf::from("/tmp/some-project/.mcpify/versions.json")
        );
    }
}
