//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a
//! `picloud.toml` describing a minimal working app (one `hello` endpoint +
//! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores
//! the project tool's `.picloud/` link-state directory.
//!
//! Offline by design — it never contacts the server. Run `pic plan` to
//! preview the create, then `pic apply` to deploy. Refuses to overwrite an
//! existing `picloud.toml` unless `--force`.

use std::fs;
use std::path::Path;

use anyhow::{bail, Context, Result};
use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind};

use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE};
use crate::output::{KvBlock, OutputMode};

const HEADER: &str = "\
# picloud project manifest — the declarative desired state for one app.
# Edit, then `pic plan` to preview changes and `pic apply` to reconcile.
# Scripts live under scripts/ and are referenced by `file`.
\n";

const EXAMPLES: &str = "\
\n# ---------------------------------------------------------------------------
# More to add (uncomment and adapt):
#
# [[scripts]]
# name = \"lib\"
# file = \"scripts/lib.rhai\"
# kind = \"module\"            # default: endpoint
#
# [[routes]]
# script = \"hello\"
# method = \"POST\"            # omit for ANY
# host_kind = \"any\"
# path_kind = \"param\"        # exact | prefix | param
# path = \"/hello/:name\"
#
# [[triggers.cron]]
# script = \"hello\"
# schedule = \"0 0 * * * *\"   # 6-field cron (seconds first)
# timezone = \"UTC\"
#
# [secrets]                   # names only — push values with `pic secret set`
# names = [\"STRIPE_KEY\"]
";

const HELLO_RHAI: &str = "\
// A minimal endpoint script. Its return value is the HTTP response body.
// `ctx` exposes the request; see the stdlib reference for the full SDK.
\"Hello from PiCloud!\"
";

const GITIGNORE_LINE: &str = ".picloud/";

pub fn run(
    dir: &Path,
    slug_arg: Option<&str>,
    name_arg: Option<&str>,
    force: bool,
    mode: OutputMode,
) -> Result<()> {
    let slug = resolve_slug(dir, slug_arg)?;
    let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string);

    let manifest_path = dir.join(MANIFEST_FILE);
    if manifest_path.exists() && !force {
        bail!(
            "{} already exists; refusing to overwrite (use --force)",
            manifest_path.display()
        );
    }

    let manifest = scaffold_manifest(&slug, &name);
    // Build the file as a commented header + the (valid, round-trippable)
    // active manifest + commented examples. Rendering the active part through
    // `to_toml` guarantees it parses and matches the wire model.
    let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?);

    fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?;
    let hello_path = dir.join("scripts/hello.rhai");
    let wrote_hello = !hello_path.exists();
    if wrote_hello {
        fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?;
    }
    fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
    ensure_gitignored(dir)?;

    let mut block = KvBlock::new();
    block
        .field("manifest", manifest_path.display().to_string())
        .field("app", slug)
        .field(
            "scripts",
            if wrote_hello {
                "scripts/hello.rhai"
            } else {
                "scripts/hello.rhai (kept existing)"
            }
            .to_string(),
        )
        .field("next", "pic plan   then   pic apply".to_string());
    block.print(mode);
    Ok(())
}

/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
    Manifest {
        app: Some(ManifestApp {
            slug: slug.to_string(),
            name: name.to_string(),
            description: None,
            extension_points: Vec::new(),
        }),
        group: None,
        project: None,
        scripts: vec![ManifestScript {
            name: "hello".into(),
            file: "scripts/hello.rhai".into(),
            kind: ScriptKind::Endpoint,
            description: None,
            timeout_seconds: None,
            memory_limit_mb: None,
            sandbox: None,
            enabled: true,
        }],
        routes: vec![ManifestRoute {
            script: "hello".into(),
            method: Some("GET".into()),
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: "/hello".into(),
            dispatch_mode: DispatchMode::Sync,
            enabled: true,
            sealed: false,
        }],
        triggers: crate::manifest::ManifestTriggers::default(),
        secrets: crate::manifest::ManifestSecrets::default(),
        vars: std::collections::BTreeMap::new(),
        suppress: crate::manifest::ManifestSuppress::default(),
        workflows: Vec::new(),
        interceptors: Vec::new(),
    }
}

/// Use the explicit slug if given, else derive one from the target
/// directory's name. Either way it must satisfy the app-slug rule.
fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result<String> {
    if let Some(s) = slug_arg {
        if !is_valid_slug(s) {
            bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)");
        }
        return Ok(s.to_string());
    }
    // Derive from the directory's own name. Use the path as given first —
    // `canonicalize` requires the dir to already exist, which breaks the
    // natural `pic init --dir new-project` flow. Fall back to canonicalizing
    // only when the path has no final component of its own (e.g. `.`).
    let base = dir
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .or_else(|| {
            dir.canonicalize()
                .ok()
                .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
        })
        .unwrap_or_default();
    let derived = slugify(&base);
    if !is_valid_slug(&derived) {
        bail!(
            "could not derive a valid app slug from directory `{base}`; \
             pass one explicitly, e.g. `pic init my-app`"
        );
    }
    Ok(derived)
}

/// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes.
fn slugify(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut prev_dash = false;
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

/// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`.
fn is_valid_slug(s: &str) -> bool {
    if s.is_empty() || s.len() > 63 {
        return false;
    }
    let mut chars = s.chars();
    let first = chars.next().expect("non-empty checked above");
    if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
        return false;
    }
    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}

/// Title-case a slug for a default display name: `my-blog` → `My Blog`.
fn title_from_slug(slug: &str) -> String {
    slug.split('-')
        .filter(|w| !w.is_empty())
        .map(|w| {
            let mut c = w.chars();
            c.next().map_or_else(String::new, |f| {
                f.to_ascii_uppercase().to_string() + c.as_str()
            })
        })
        .collect::<Vec<_>>()
        .join(" ")
}

/// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state).
/// Appends the line if missing; creates the file if absent. A repo without
/// git still gets a correct `.gitignore` for when it's initialized.
fn ensure_gitignored(dir: &Path) -> Result<()> {
    let path = dir.join(".gitignore");
    // Only a genuinely-absent file is treated as empty; an existing-but-
    // unreadable `.gitignore` must error rather than be silently clobbered.
    let existing = match fs::read_to_string(&path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(e) => return Err(e).context("reading .gitignore"),
    };
    if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) {
        return Ok(());
    }
    let mut next = existing;
    if !next.is_empty() && !next.ends_with('\n') {
        next.push('\n');
    }
    next.push_str(GITIGNORE_LINE);
    next.push('\n');
    fs::write(&path, next).context("updating .gitignore")?;
    Ok(())
}

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

    #[test]
    fn scaffold_is_valid_and_round_trips() {
        let m = scaffold_manifest("blog", "Blog");
        let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap());
        // The active manifest (header/examples are comments) must parse back
        // to exactly the scaffold — the commented examples are inert.
        let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML");
        assert_eq!(parsed, m);
        // And it's a deployable project: one endpoint + its route.
        assert_eq!(parsed.scripts.len(), 1);
        assert_eq!(parsed.routes.len(), 1);
        assert_eq!(parsed.routes[0].script, "hello");
    }

    #[test]
    fn slugify_and_validation() {
        assert_eq!(slugify("My Blog!"), "my-blog");
        assert_eq!(slugify("  weird__name  "), "weird-name");
        assert_eq!(slugify("Project (2026)"), "project-2026");
        assert!(is_valid_slug("blog"));
        assert!(is_valid_slug("a1-b2"));
        assert!(!is_valid_slug(""));
        assert!(!is_valid_slug("-leading"));
        assert!(!is_valid_slug(&"a".repeat(64)));
    }

    #[test]
    fn title_from_slug_humanizes() {
        assert_eq!(title_from_slug("my-blog"), "My Blog");
        assert_eq!(title_from_slug("api"), "Api");
    }
}
