//! `source2rosetta-gen` — the standalone generator. Reads the published `rosetta-<game>.json` and writes
//! the files one consumer needs: a framework's gamedata plus the typed call sites that resolve through it,
//! a typed schema SDK, or the script API the Dota ecosystem publishes.
//!
//! It touches only the `model` + `render` layers — no ELF reader, no ptrace, no disassembler — so a consumer
//! who "just wants the files" downloads one artifact and this small, rarely-changing binary and generates
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.

use anyhow::{Context, Result, bail};
use clap::Parser;
use source2rosetta_core::{model, render};
use std::path::{Path, PathBuf};

#[derive(Parser)]
#[command(
    name = "source2rosetta-gen",
    version,
    about = "Render a source2rosetta release into the files your framework reads"
)]
struct Cli {
    /// The published `rosetta-<game>.json` — one artifact holding every surface.
    #[arg(long)]
    from: PathBuf,
    /// Who the output is for, and it writes every file that consumer reads. FRAMEWORKS get a gamedata
    /// file and the typed call sites that resolve through it: cssharp | metamod (also SourceMod's VDF) |
    /// modsharp | swiftly | plugify. SCHEMA: cs-sdk (typed C# SDK) | netvars (flat offset map). SCRIPT
    /// API: moddota, which writes both shapes that ecosystem publishes (`api.json` + `api.d.ts`). Plus
    /// `flat`, a format-neutral name -> locator map.
    #[arg(long, default_value = "cssharp")]
    format: String,
    /// Confidence tier for the locator half, cumulative: core | high_confidence | experimental. Defaults to
    /// `high_confidence` (core + the promoted names). Ignored by the schema and script-API formats.
    #[arg(long, default_value = "high_confidence")]
    tier: String,
    /// Directory to write into (default: the working directory). A format writes more than one file, so
    /// this names a DIRECTORY rather than a file — the names are the ones each framework expects.
    #[arg(long, default_value = ".")]
    out: PathBuf,
    /// Render even where the consumer is not known to run on this artifact's game. What that claim rests
    /// on is in `render::GAME_SUPPORT`; it is read out of somebody else's source and they do add games,
    /// so it declines by default rather than refusing outright.
    #[arg(long)]
    force: bool,
}

/// One file to write: the name the consuming framework expects, and what goes in it.
struct Out {
    name: String,
    text: String,
    /// What this file is, for the line printed after writing it.
    what: &'static str,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let fmt = cli.format.as_str();

    let text = std::fs::read_to_string(&cli.from)
        .with_context(|| format!("read {}", cli.from.display()))?;
    let r: model::Rosetta = serde_json::from_str(&text)
        .with_context(|| format!("parse {} as a rosetta artifact", cli.from.display()))?;
    let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
        format!(
            "unknown --tier {:?} (want one of: {})",
            cli.tier,
            model::TIER_IDS.join(" | ")
        )
    })?;

    // Checked before anything is rendered: a file that cannot load on the game it was made for is worse
    // than no file, and the one thing worse than that is one written silently.
    if let Some(mismatch) = render::game_mismatch(fmt, &r.meta.game_key) {
        if !cli.force {
            bail!("{mismatch}\n  Pass --force to render it anyway.");
        }
        eprintln!("warning: {mismatch}\n  Rendering anyway (--force).");
    }

    let outputs = match fmt {
        // A framework gets the pair: WHERE the functions are, and HOW to call them. They were two
        // invocations against two files; one artifact makes them one command, and a consumer who has the
        // locators without the call sites has half of what it takes to make a call.
        f @ ("cssharp" | "metamod" | "modsharp" | "swiftly" | "plugify") => {
            let gd = match f {
                // cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
                "cssharp" => render::render_monolith_cssharp(&r.to_monolith(), tier),
                _ => render::by_id(f)
                    .expect("known flat format")
                    .render(&r.gamedata(tier)),
            };
            let calls = render::abi_by_id(f)
                .expect("known abi format")
                .render(&r.abi_manifest());
            let (gd_name, calls_name) = match f {
                "cssharp" => ("gamedata.json".into(), "RosettaFunctions.cs"),
                "metamod" => (
                    format!("{}.games.txt", r.meta.game_key),
                    "rosetta_prototypes.h",
                ),
                "modsharp" => ("gamedata.json".into(), "RosettaCalls.cs"),
                _ => ("gamedata.json".into(), "prototypes.json"),
            };
            vec![
                Out {
                    name: gd_name,
                    text: gd,
                    what: "locators",
                },
                Out {
                    name: calls_name.into(),
                    text: calls,
                    what: "typed call sites",
                },
            ]
        }
        "flat" => vec![Out {
            name: "gamedata-flat.json".into(),
            text: render::by_id("model")
                .expect("known flat format")
                .render(&r.gamedata(tier)),
            what: "locators, format-neutral",
        }],
        f @ ("cs-sdk" | "netvars") => {
            // Field types are runtime-resolved, so an offline build states no schema at all. Saying so is
            // better than writing an empty SDK that compiles and describes nothing.
            let schema = r.typed_schema().context(
                "this artifact's `schema` is null, so the schema formats have nothing to render — field \
                 TYPES are resolved at runtime and are not in the file. Every PUBLISHED artifact carries \
                 one, so this is a local OFFLINE derive: re-run `produce` with --game-dir, or take the \
                 artifact from a release.",
            )?;
            let text = render::schema_by_id(f)
                .expect("known schema format")
                .render(&schema);
            vec![Out {
                name: if f == "cs-sdk" {
                    "Schema.cs".into()
                } else {
                    "netvars.json".into()
                },
                text,
                what: "typed schema",
            }]
        }
        // One consumer, two files, for the same reason a framework gets two: the ModDota ecosystem's
        // toolchain renders from the JSON, while an author working against the published packages reads
        // the declarations. Emitting one of them is answering half the question.
        "moddota" => {
            let vscript = r.vscript();
            // Both shapes group members by owning class, and the class is only readable from a running
            // server — so an offline artifact renders nothing, and an empty file would look like an
            // answer rather than a missing input. The two ways of having nothing to render are worth
            // telling apart: a game with no script VM at all is not a build that was run offline.
            if vscript.is_empty() {
                bail!(
                    "this artifact carries no VScript bindings at all, so there is no script API to \
                     render. Only Dota 2 and CS2 expose one — check you passed the right artifact."
                );
            }
            if vscript.iter().all(|v| v.class.is_none()) {
                bail!(
                    "this artifact has {} VScript bindings and no owning class on any of them, and both \
                     shapes group members BY class. The owning class is only readable from a running \
                     server, and every PUBLISHED artifact carries it, so this is a local OFFLINE derive: \
                     re-run `produce` with --game-dir, or take the artifact from a release.",
                    vscript.len()
                );
            }
            let schema = r.typed_schema();
            let render_as = |id: &str| {
                render::bindings_by_id(id)
                    .expect("known bindings format")
                    .render(&vscript, schema.as_ref())
            };
            vec![
                Out {
                    name: "api.json".into(),
                    text: render_as("api-json"),
                    what: "script API, ModDota `dota-data` shape",
                },
                Out {
                    name: "api.d.ts".into(),
                    text: render_as("dts"),
                    what: "script API, TypeScript declarations",
                },
            ]
        }
        other => bail!(
            "unknown --format {other:?} (want one of: {})",
            render::FORMAT_IDS.join(" | "),
        ),
    };

    write_all(&cli.out, &outputs)
}

fn write_all(dir: &Path, outputs: &[Out]) -> Result<()> {
    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
    for o in outputs {
        let p = dir.join(&o.name);
        std::fs::write(&p, &o.text).with_context(|| format!("write {}", p.display()))?;
        println!(
            "{}  ({}, {} KB)",
            p.display(),
            o.what,
            o.text.len().div_ceil(1024)
        );
    }
    Ok(())
}
