//! `rok make:crud` — full-stack CRUD scaffolding.

use std::{fs, io::Write as _, path::Path};

use console::style;
use heck::{ToSnakeCase, ToUpperCamelCase};

// ── Field types ───────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct FieldDef {
    pub name: String,
    pub field_type: String,
    /// True when the type ends with `?` (e.g. `description:text?`).
    /// Generates `Option<T>` in the model and drops NOT NULL in the migration.
    pub optional: bool,
}

impl FieldDef {
    pub fn parse_list(input: &str) -> Vec<Self> {
        input
            .split(',')
            .filter_map(|s| {
                let s = s.trim();
                let mut parts = s.splitn(2, ':');
                let name = parts.next()?.trim().to_snake_case();
                let raw_ft = parts.next()?.trim().to_lowercase();
                if name.is_empty() || raw_ft.is_empty() {
                    return None;
                }
                let optional = raw_ft.ends_with('?');
                let field_type = raw_ft.trim_end_matches('?').to_string();
                Some(FieldDef {
                    name,
                    field_type,
                    optional,
                })
            })
            .collect()
    }
}

struct TypeMap {
    rust_type: &'static str,
    sql_type: &'static str,
    validate_attr: &'static str,
    fake_val: &'static str,
    resource_type: &'static str,
}

fn type_map(ft: &str) -> TypeMap {
    match ft {
        "string" => TypeMap {
            rust_type: "String",
            sql_type: "VARCHAR(255)",
            validate_attr: "#[validate(required, max = 255)]",
            fake_val: "\"sample text\".to_string()",
            resource_type: "String",
        },
        "text" => TypeMap {
            rust_type: "String",
            sql_type: "TEXT",
            validate_attr: "#[validate(required)]",
            fake_val: "\"long form content here.\".to_string()",
            resource_type: "String",
        },
        "bool" => TypeMap {
            rust_type: "bool",
            sql_type: "BOOLEAN",
            validate_attr: "",
            fake_val: "false",
            resource_type: "bool",
        },
        "int" | "bigint" => TypeMap {
            rust_type: "i64",
            sql_type: "BIGINT",
            validate_attr: "#[validate(min = 0)]",
            fake_val: "0_i64",
            resource_type: "i64",
        },
        "float" => TypeMap {
            rust_type: "f64",
            sql_type: "DOUBLE PRECISION",
            validate_attr: "",
            fake_val: "0.0_f64",
            resource_type: "f64",
        },
        "decimal" => TypeMap {
            rust_type: "f64",
            sql_type: "DECIMAL(10, 2)",
            validate_attr: "",
            fake_val: "0.0_f64",
            resource_type: "f64",
        },
        "uuid" => TypeMap {
            rust_type: "String",
            sql_type: "UUID",
            validate_attr: "",
            fake_val: "\"00000000-0000-0000-0000-000000000000\".to_string()",
            resource_type: "String",
        },
        "date" => TypeMap {
            rust_type: "chrono::NaiveDate",
            sql_type: "DATE",
            validate_attr: "",
            fake_val: "chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()",
            resource_type: "String",
        },
        "datetime" => TypeMap {
            rust_type: "chrono::DateTime<chrono::Utc>",
            sql_type: "TIMESTAMPTZ",
            validate_attr: "",
            fake_val: "chrono::Utc::now()",
            resource_type: "String",
        },
        "json" => TypeMap {
            rust_type: "serde_json::Value",
            sql_type: "JSONB",
            validate_attr: "",
            fake_val: "serde_json::Value::Null",
            resource_type: "serde_json::Value",
        },
        _ => TypeMap {
            rust_type: "String",
            sql_type: "TEXT",
            validate_attr: "",
            fake_val: "String::new()",
            resource_type: "String",
        },
    }
}

// ── PK helpers ────────────────────────────────────────────────────────────────

fn pk_rust_type(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "Ulid",
        Some("cuid2") => "Cuid2",
        Some("uuid_v7") => "UuidV7",
        Some("snowflake") => "Snowflake",
        Some("nanoid") => "NanoId",
        _ => "i64",
    }
}

fn pk_use_import(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "use rok_ids::Ulid;\n",
        Some("cuid2") => "use rok_ids::Cuid2;\n",
        Some("uuid_v7") => "use rok_ids::UuidV7;\n",
        Some("snowflake") => "use rok_ids::Snowflake;\n",
        Some("nanoid") => "use rok_ids::NanoId;\n",
        _ => "",
    }
}

fn pk_ddl(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "    \"id\" TEXT PRIMARY KEY NOT NULL",
        Some("cuid2") => "    \"id\" TEXT PRIMARY KEY NOT NULL",
        Some("uuid_v7") => "    \"id\" VARCHAR(36) PRIMARY KEY NOT NULL",
        Some("snowflake") => "    \"id\" BIGINT PRIMARY KEY NOT NULL",
        Some("nanoid") => "    \"id\" TEXT PRIMARY KEY NOT NULL",
        _ => "    \"id\" BIGSERIAL PRIMARY KEY",
    }
}

fn pk_id_attr(id: Option<&str>) -> &'static str {
    match id {
        Some("ulid") => "#[rok_orm(id = \"ulid\")]\n",
        Some("cuid2") => "#[rok_orm(id = \"cuid2\")]\n",
        Some("uuid_v7") => "#[rok_orm(id = \"uuid_v7\")]\n",
        Some("snowflake") => "#[rok_orm(id = \"snowflake\")]\n",
        Some("nanoid") => "#[rok_orm(id = \"nanoid\")]\n",
        _ => "",
    }
}

fn pk_to_string(id: Option<&str>) -> &'static str {
    match id {
        None => "m.id",          // i64 → i64, no conversion needed
        _ => "m.id.to_string()", // UUID / ULID / etc. → String
    }
}

// ── Migration folder helpers ──────────────────────────────────────────────────

/// Returns the migration directory, preferring `database/migrations/` (the
/// convention established by `rok new`) over the bare `migrations/` fallback.
fn migration_dir() -> &'static str {
    if Path::new("database/migrations").exists() {
        "database/migrations"
    } else {
        "migrations"
    }
}

/// Returns the next sequential migration index by counting existing `.sql`
/// files in the migration directory.
fn next_migration_index(dir: &str) -> u32 {
    fs::read_dir(dir)
        .map(|rd| {
            rd.filter_map(|e| e.ok())
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("sql"))
                .count() as u32
                + 1
        })
        .unwrap_or(1)
}

// ── Template generators ───────────────────────────────────────────────────────

fn gen_migration(
    snake: &str,
    fields: &[FieldDef],
    id: Option<&str>,
    soft_delete: bool,
    timestamps: bool,
) -> String {
    let pk = pk_ddl(id);
    let mut cols = vec![pk.to_string()];

    for f in fields {
        let tm = type_map(&f.field_type);
        let constraint = if f.optional { "" } else { " NOT NULL" };
        cols.push(format!("    \"{}\" {}{}", f.name, tm.sql_type, constraint));
    }
    if timestamps {
        cols.push("    \"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW()".to_string());
        cols.push("    \"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW()".to_string());
    }
    if soft_delete {
        cols.push("    \"deleted_at\" TIMESTAMPTZ".to_string());
    }

    let cols_sql = cols.join(",\n");
    let table = format!("{snake}s");

    format!(
        "-- up\nCREATE TABLE \"{table}\" (\n{cols_sql}\n);\n\n-- down\nDROP TABLE IF EXISTS \"{table}\";\n"
    )
}

fn gen_model(
    pascal: &str,
    snake: &str,
    fields: &[FieldDef],
    id: Option<&str>,
    soft_delete: bool,
    timestamps: bool,
) -> String {
    let pk_use = pk_use_import(id);
    let pk_type = pk_rust_type(id);
    let _id_attr = pk_id_attr(id);

    let mut needs_chrono = timestamps || soft_delete;
    let mut needs_json = false;

    for f in fields {
        match f.field_type.as_str() {
            "datetime" | "date" => needs_chrono = true,
            "json" => needs_json = true,
            _ => {}
        }
    }

    let mut use_lines = String::new();
    use_lines.push_str(pk_use);
    use_lines.push_str("use rok_orm::Model;\n");
    use_lines.push_str("use serde::{Deserialize, Serialize};\n");
    if needs_chrono {
        use_lines.push_str("use chrono::{DateTime, Utc};\n");
    }
    if needs_json {
        use_lines.push_str("use serde_json::Value;\n");
    }

    // orm attrs
    let mut orm_attrs = Vec::new();
    let table = format!("{snake}s");
    orm_attrs.push(format!("table = \"{table}\""));
    if let Some(it) = id {
        orm_attrs.push(format!("id = \"{it}\""));
    }
    if timestamps {
        orm_attrs.push("timestamps".to_string());
    }
    if soft_delete {
        orm_attrs.push("soft_delete".to_string());
    }
    let orm_attr_line = format!("#[rok_orm({})]\n", orm_attrs.join(", "));

    let mut field_lines = format!("    pub id: {},\n", pk_type);
    for f in fields {
        let tm = type_map(&f.field_type);
        let base_t = match f.field_type.as_str() {
            "datetime" => "DateTime<Utc>",
            "json" => "Value",
            _ => tm.rust_type,
        };
        let rust_t = if f.optional {
            format!("Option<{base_t}>")
        } else {
            base_t.to_string()
        };
        field_lines.push_str(&format!("    pub {}: {},\n", f.name, rust_t));
    }
    if timestamps {
        field_lines.push_str("    pub created_at: DateTime<Utc>,\n");
        field_lines.push_str("    pub updated_at: DateTime<Utc>,\n");
    }
    if soft_delete {
        field_lines.push_str("    pub deleted_at: Option<DateTime<Utc>>,\n");
    }

    format!(
        "{use_lines}\n#[derive(Debug, Clone, Model, Serialize, Deserialize, sqlx::FromRow)]\n{orm_attr_line}pub struct {pascal} {{\n{field_lines}}}\n"
    )
}

fn gen_create_validator(pascal: &str, fields: &[FieldDef]) -> String {
    let mut field_lines = String::new();
    for f in fields {
        let tm = type_map(&f.field_type);
        let base_t = match f.field_type.as_str() {
            "datetime" | "date" => "String",
            "json" => "serde_json::Value",
            _ => tm.rust_type,
        };
        if f.optional {
            // Optional field: wrap in Option<>, use #[validate(optional, ...)]
            let opt_attr = tm
                .validate_attr
                .trim_start_matches("#[validate(")
                .trim_end_matches(")]");
            let attr = if opt_attr.is_empty() {
                String::new()
            } else {
                format!("    #[validate(optional, {opt_attr})]\n")
            };
            field_lines.push_str(&attr);
            field_lines.push_str(&format!("    pub {}: Option<{}>,\n", f.name, base_t));
        } else {
            if !tm.validate_attr.is_empty() {
                field_lines.push_str(&format!("    {}\n", tm.validate_attr));
            }
            field_lines.push_str(&format!("    pub {}: {},\n", f.name, base_t));
        }
    }

    format!(
        "use rok_validate::Validate;\nuse serde::Deserialize;\n\n#[derive(Deserialize, Validate)]\npub struct Create{pascal}Request {{\n{field_lines}}}\n"
    )
}

fn gen_update_validator(pascal: &str, fields: &[FieldDef]) -> String {
    let mut field_lines = String::new();
    for f in fields {
        let tm = type_map(&f.field_type);
        let rust_t = match f.field_type.as_str() {
            "datetime" => "String",
            "date" => "String",
            "json" => "serde_json::Value",
            _ => tm.rust_type,
        };
        field_lines.push_str(&format!("    pub {}: Option<{}>,\n", f.name, rust_t));
    }

    format!(
        "use rok_validate::Validate;\nuse serde::Deserialize;\n\n#[derive(Deserialize, Validate)]\npub struct Update{pascal}Request {{\n{field_lines}}}\n"
    )
}

fn gen_resource(
    pascal: &str,
    snake: &str,
    fields: &[FieldDef],
    id: Option<&str>,
    timestamps: bool,
) -> String {
    let pk_type = pk_rust_type(id);
    let id_resource_type = match id {
        Some(_) => "String",
        None => "i64",
    };
    let id_from = pk_to_string(id);

    let mut struct_fields = format!("    pub id: {},\n", id_resource_type);
    let mut from_fields = format!("            id: {},\n", id_from);

    for f in fields {
        let tm = type_map(&f.field_type);
        let base_res_t = match f.field_type.as_str() {
            "datetime" | "date" => "String",
            "json" => "serde_json::Value",
            _ => tm.resource_type,
        };
        let (res_t, from_expr) = if f.optional {
            let opt_t = format!("Option<{base_res_t}>");
            let expr = match f.field_type.as_str() {
                "datetime" => format!("m.{}.map(|v| v.to_rfc3339())", f.name),
                "date" => format!("m.{}.map(|v| v.to_string())", f.name),
                _ => format!("m.{}", f.name),
            };
            (opt_t, expr)
        } else {
            let expr = match f.field_type.as_str() {
                "datetime" => format!("m.{}.to_rfc3339()", f.name),
                "date" => format!("m.{}.to_string()", f.name),
                _ => format!("m.{}", f.name),
            };
            (base_res_t.to_string(), expr)
        };
        struct_fields.push_str(&format!("    pub {}: {},\n", f.name, res_t));
        from_fields.push_str(&format!("            {}: {},\n", f.name, from_expr));
    }

    if timestamps {
        struct_fields.push_str("    pub created_at: String,\n");
        struct_fields.push_str("    pub updated_at: String,\n");
        from_fields.push_str("            created_at: m.created_at.to_rfc3339(),\n");
        from_fields.push_str("            updated_at: m.updated_at.to_rfc3339(),\n");
    }

    let mut uses = String::new();
    uses.push_str("use serde::Serialize;\n");

    let pk_use = pk_use_import(id);
    if !pk_use.is_empty() {
        uses.push_str(pk_use);
    }

    let _ = snake;
    let _ = pk_type;

    format!(
        "{uses}\n#[derive(Debug, Serialize)]\npub struct {pascal}Resource {{\n{struct_fields}}}\n\nimpl {pascal}Resource {{\n    pub fn from(m: super::super::models::{pascal}) -> Self {{\n        Self {{\n{from_fields}        }}\n    }}\n}}\n"
    )
}

fn gen_policy(pascal: &str, snake: &str) -> String {
    let _ = snake;
    format!(
        "pub struct {pascal}Policy;\n\nimpl {pascal}Policy {{\n    pub fn view() -> bool {{ true }}\n    pub fn create() -> bool {{ true }}\n    pub fn update() -> bool {{ true }}\n    pub fn delete() -> bool {{ true }}\n}}\n"
    )
}

fn gen_controller(
    pascal: &str,
    snake: &str,
    id: Option<&str>,
    auth: bool,
    _paginate: bool,
) -> String {
    let id_type = pk_rust_type(id);
    let id_param_type = match id {
        Some("snowflake") => "i64",
        Some(_) => "String",
        None => "i64",
    };

    let auth_guard = if auth {
        "    ctx.require_auth()?;\n        "
    } else {
        ""
    };

    let _ = snake;
    let _ = id_type;

    format!(
        r#"use axum::{{
    extract::{{Path, State}},
    response::IntoResponse,
    Json,
}};
use rok_auth::axum::{{Ctx, Response}};
use rok_auth_macros::controller;
use crate::app::validators::{{create_{snake}_request::Create{pascal}Request, update_{snake}_request::Update{pascal}Request}};
use crate::state::AppState;

#[controller]
pub struct {pascal}Controller;

impl {pascal}Controller {{
    pub async fn index(ctx: Ctx, State(_state): State<AppState>) -> impl IntoResponse {{
        {auth_guard}Response::json(serde_json::json!({{ "data": [] }}))
    }}

    pub async fn store(
        ctx: Ctx,
        State(_state): State<AppState>,
        Json(_body): Json<Create{pascal}Request>,
    ) -> impl IntoResponse {{
        {auth_guard}Response::json(serde_json::json!({{ "message": "created" }}))
    }}

    pub async fn show(
        ctx: Ctx,
        State(_state): State<AppState>,
        Path(id): Path<{id_param_type}>,
    ) -> impl IntoResponse {{
        {auth_guard}Response::json(serde_json::json!({{ "id": id.to_string() }}))
    }}

    pub async fn update(
        ctx: Ctx,
        State(_state): State<AppState>,
        Path(id): Path<{id_param_type}>,
        Json(_body): Json<Update{pascal}Request>,
    ) -> impl IntoResponse {{
        {auth_guard}Response::json(serde_json::json!({{ "id": id.to_string(), "message": "updated" }}))
    }}

    pub async fn destroy(
        ctx: Ctx,
        State(_state): State<AppState>,
        Path(id): Path<{id_param_type}>,
    ) -> impl IntoResponse {{
        {auth_guard}let _ = id;
        Response::no_content()
    }}
}}
"#
    )
}

fn gen_factory(
    pascal: &str,
    snake: &str,
    fields: &[FieldDef],
    id: Option<&str>,
    timestamps: bool,
    soft_delete: bool,
) -> String {
    let pk_type = pk_rust_type(id);
    let pk_use = pk_use_import(id);

    // Generate id field explicitly so the struct literal is complete (no ..Default::default()).
    let id_fake = match id {
        None => "0_i64".to_string(),
        _ => format!("{}::default()", pk_type),
    };

    let mut field_defaults = format!("            id: {id_fake},\n");
    for f in fields {
        let val = if f.optional {
            "None".to_string()
        } else {
            let tm = type_map(&f.field_type);
            tm.fake_val.to_string()
        };
        field_defaults.push_str(&format!("            {}: {},\n", f.name, val));
    }
    if timestamps {
        field_defaults.push_str("            created_at: chrono::Utc::now(),\n");
        field_defaults.push_str("            updated_at: chrono::Utc::now(),\n");
    }
    if soft_delete {
        field_defaults.push_str("            deleted_at: None,\n");
    }

    let _ = snake;
    let _ = pk_type;

    let mut uses = String::new();
    uses.push_str(pk_use);

    format!(
        "{uses}use crate::app::models::{pascal};\n\npub struct {pascal}Factory;\n\nimpl {pascal}Factory {{\n    pub fn build() -> {pascal} {{\n        {pascal} {{\n{field_defaults}        }}\n    }}\n}}\n"
    )
}

fn gen_routes(pascal: &str, snake: &str, id: Option<&str>) -> String {
    let id_param = match id {
        Some("snowflake") => ":id",
        _ => ":id",
    };
    format!(
        r#"use axum::{{routing::get, Router}};
use crate::app::controllers::{snake}_controller::{pascal}Controller;
use crate::state::AppState;

pub fn {snake}_routes() -> Router<AppState> {{
    Router::new()
        .route("/{snake}s", get({pascal}Controller::index).post({pascal}Controller::store))
        .route("/{snake}s/{id_param}", get({pascal}Controller::show).put({pascal}Controller::update).delete({pascal}Controller::destroy))
}}
"#
    )
}

fn gen_test(_pascal: &str, snake: &str) -> String {
    format!(
        r#"mod common;

#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_index() {{
    let app = common::TestApp::boot().await;
    let res = app.client.get("/api/v1/{snake}s").await;
    res.assert_status(200);
}}

#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_show() {{
    let app = common::TestApp::boot().await;
    let res = app.client.get("/api/v1/{snake}s/1").await;
    res.assert_status(200);
}}

#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_store() {{
    let app = common::TestApp::boot().await;
    let res = app.client.post("/api/v1/{snake}s", serde_json::json!({{}})).await;
    res.assert_status(201);
}}

#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_update() {{
    let app = common::TestApp::boot().await;
    let res = app.client.put("/api/v1/{snake}s/1", serde_json::json!({{}})).await;
    res.assert_status(200);
}}

#[tokio::test]
#[ignore = "requires running database"]
async fn test_{snake}_destroy() {{
    let app = common::TestApp::boot().await;
    let res = app.client.delete("/api/v1/{snake}s/1").await;
    res.assert_status(204);
}}
"#
    )
}

// ── File-system helpers ───────────────────────────────────────────────────────

fn write_new(path: &str, content: &str, force: bool) -> anyhow::Result<bool> {
    let p = Path::new(path);
    if p.exists() && !force {
        println!(
            "  {} {path} (already exists, use --force to overwrite)",
            style("skip").yellow()
        );
        return Ok(false);
    }
    if let Some(parent) = p.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(p, content)?;
    println!("  {} {path}", style("create").green().bold());
    Ok(true)
}

fn append_line(path: &str, line: &str) -> anyhow::Result<()> {
    if !Path::new(path).exists() {
        fs::create_dir_all(Path::new(path).parent().unwrap_or(Path::new(".")))?;
        fs::write(path, format!("{line}\n"))?;
        println!("  {} {path}", style("create").green().bold());
        return Ok(());
    }
    let existing = fs::read_to_string(path)?;
    if existing.contains(line.trim()) {
        return Ok(());
    }
    let mut f = fs::OpenOptions::new().append(true).open(path)?;
    writeln!(f, "{line}")?;
    println!("  {} {path} ← {line}", style("update").cyan().bold());
    Ok(())
}

// ── Main entry point ──────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
pub fn crud(
    name: &str,
    fields_str: Option<&str>,
    id_type: Option<&str>,
    auth: bool,
    soft_delete: bool,
    paginate: bool,
    timestamps: bool,
    force: bool,
) -> anyhow::Result<()> {
    // Wizard mode when --fields omitted
    let fields_input: String = match fields_str {
        Some(s) => s.to_owned(),
        None => {
            use dialoguer::Input;
            println!("{}", style("rok make:crud — interactive wizard").bold());
            Input::<String>::new()
                .with_prompt("Fields (e.g. title:string,body:text,published:bool)")
                .interact_text()?
        }
    };

    let fields = FieldDef::parse_list(&fields_input);
    if fields.is_empty() {
        anyhow::bail!("No valid fields parsed. Use format: name:type,name:type");
    }

    let pascal = name.to_upper_camel_case();
    let snake = name.to_snake_case();

    println!(
        "\n{} Scaffolding {pascal} CRUD...\n",
        style("rok").green().bold()
    );

    // ── 1. Migration ──────────────────────────────────────────────────────────
    let mig_dir = migration_dir();
    let mig_idx = next_migration_index(mig_dir);
    let mig_path = format!("{mig_dir}/{mig_idx:03}_create_{snake}s_table.sql");
    write_new(
        &mig_path,
        &gen_migration(&snake, &fields, id_type, soft_delete, timestamps),
        force,
    )?;

    // ── 2. Model ──────────────────────────────────────────────────────────────
    let model_path = format!("src/app/models/{snake}.rs");
    write_new(
        &model_path,
        &gen_model(&pascal, &snake, &fields, id_type, soft_delete, timestamps),
        force,
    )?;
    append_line("src/app/models/mod.rs", &format!("pub mod {snake};"))?;
    append_line(
        "src/app/models/mod.rs",
        &format!("pub use {snake}::{pascal};"),
    )?;

    // ── 3. Validators ─────────────────────────────────────────────────────────
    let cv_path = format!("src/app/validators/create_{snake}_request.rs");
    write_new(&cv_path, &gen_create_validator(&pascal, &fields), force)?;
    let uv_path = format!("src/app/validators/update_{snake}_request.rs");
    write_new(&uv_path, &gen_update_validator(&pascal, &fields), force)?;
    append_line(
        "src/app/validators/mod.rs",
        &format!("pub mod create_{snake}_request;"),
    )?;
    append_line(
        "src/app/validators/mod.rs",
        &format!("pub mod update_{snake}_request;"),
    )?;

    // ── 4. Resource ───────────────────────────────────────────────────────────
    let res_path = format!("src/app/resources/{snake}_resource.rs");
    write_new(
        &res_path,
        &gen_resource(&pascal, &snake, &fields, id_type, timestamps),
        force,
    )?;
    append_line(
        "src/app/resources/mod.rs",
        &format!("pub mod {snake}_resource;"),
    )?;
    append_line(
        "src/app/resources/mod.rs",
        &format!("pub use {snake}_resource::{pascal}Resource;"),
    )?;

    // ── 5. Policy ─────────────────────────────────────────────────────────────
    let pol_path = format!("src/app/policies/{snake}_policy.rs");
    write_new(&pol_path, &gen_policy(&pascal, &snake), force)?;
    append_line(
        "src/app/policies/mod.rs",
        &format!("pub mod {snake}_policy;"),
    )?;

    // ── 6. Controller ─────────────────────────────────────────────────────────
    let ctrl_path = format!("src/app/controllers/{snake}_controller.rs");
    write_new(
        &ctrl_path,
        &gen_controller(&pascal, &snake, id_type, auth, paginate),
        force,
    )?;
    append_line(
        "src/app/controllers/mod.rs",
        &format!("pub mod {snake}_controller;"),
    )?;

    // ── 7. Factory ────────────────────────────────────────────────────────────
    let fac_path = format!("src/database/factories/{snake}_factory.rs");
    write_new(
        &fac_path,
        &gen_factory(&pascal, &snake, &fields, id_type, timestamps, soft_delete),
        force,
    )?;
    append_line(
        "src/database/factories/mod.rs",
        &format!("pub mod {snake}_factory;"),
    )?;

    // ── 8. Routes ─────────────────────────────────────────────────────────────
    let route_path = format!("src/routes/{snake}s.rs");
    write_new(&route_path, &gen_routes(&pascal, &snake, id_type), force)?;
    append_line("src/routes/mod.rs", &format!("pub mod {snake}s;"))?;

    // ── 9. Test ───────────────────────────────────────────────────────────────
    let test_path = format!("tests/{snake}_test.rs");
    write_new(&test_path, &gen_test(&pascal, &snake), force)?;

    // ── Summary ───────────────────────────────────────────────────────────────
    println!(
        "\n{} Done! Add to api_router() in src/routes/api.rs:",
        style("→").bold()
    );
    println!("    .merge({snake}s::{snake}_routes())\n");

    Ok(())
}
