use std::collections::{HashMap, HashSet};
use std::env;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use axum::extract::State;
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use axum::{Json, Router};
use mdforge::forge::HtmlRenderer;
use mdforge::{ArgType, ArgValue, BlockNode, Diagnostic, EvalContext, Forge, InlineExt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Clone)]
struct AppState {
    forge: Forge,
    eval_ctx: EvalContext,
    openai_api_key: Option<String>,
    openai_model: String,
}

#[derive(Debug, Deserialize)]
struct GenerateRequest {
    prompt: String,
    pantry: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct RenderRequest {
    markdown: String,
    pantry: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct RepairRequest {
    prompt: String,
    markdown: String,
    diagnostics: Vec<DiagnosticView>,
    pantry: Vec<String>,
}

#[derive(Debug, Serialize)]
struct ApiResponse {
    ok: bool,
    markdown: String,
    html: String,
    diagnostics: Vec<DiagnosticView>,
    attempts: Vec<String>,
}

#[derive(Debug, Deserialize, Serialize)]
struct DiagnosticView {
    code: String,
    message: String,
    suggestion: Option<String>,
}

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    let state = Arc::new(AppState {
        forge: recipe_forge(),
        eval_ctx: recipe_eval_ctx(),
        openai_api_key: env::var("OPENAI_API_KEY").ok(),
        openai_model: env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4.1-mini".to_string()),
    });

    let app = Router::new()
        .route("/", get(index))
        .route("/api/generate", post(generate_recipe))
        .route("/api/repair", post(repair_recipe))
        .route("/api/render", post(render_recipe))
        .route("/api/signature", get(signature))
        .with_state(state);

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("Recipe Lab を起動しました: http://{addr}");

    let listener = tokio::net::TcpListener::bind(addr)
        .await
        .expect("bind demo server");
    axum::serve(listener, app).await.expect("serve demo server");
}

async fn index() -> impl IntoResponse {
    Html(INDEX_HTML)
}

async fn signature(State(state): State<Arc<AppState>>) -> Json<Value> {
    let ingredients = recipe_ingredients()
        .into_iter()
        .map(|id| json!({"id": id, "name": ingredient_label(id)}))
        .collect::<Vec<_>>();
    Json(json!({
        "signature": state.forge.signature(),
        "ingredients": ingredients,
        "initial_pantry": random_pantry()
    }))
}

async fn generate_recipe(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<GenerateRequest>,
) -> Json<ApiResponse> {
    let Some(api_key) = &state.openai_api_key else {
        return Json(ApiResponse {
            ok: false,
            markdown: String::new(),
            html: String::new(),
            diagnostics: vec![DiagnosticView {
                code: "MissingOpenAIKey".to_string(),
                message:
                    "OPENAI_API_KEY が設定されていません。Markdownの手入力プレビューは利用できます。"
                        .to_string(),
                suggestion: Some(
                    ".env に OPENAI_API_KEY=... を設定してデモを再起動してください。".to_string(),
                ),
            }],
            attempts: vec![],
        });
    };

    match generate_with_feedback(&state, api_key, &payload.prompt, &payload.pantry).await {
        Ok(response) => Json(response),
        Err(err) => Json(ApiResponse {
            ok: false,
            markdown: String::new(),
            html: String::new(),
            diagnostics: vec![DiagnosticView {
                code: "OpenAIRequestFailed".to_string(),
                message: err,
                suggestion: None,
            }],
            attempts: vec![],
        }),
    }
}

async fn render_recipe(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RenderRequest>,
) -> Json<ApiResponse> {
    Json(render_markdown(
        &state,
        payload.markdown,
        &payload.pantry,
        false,
    ))
}

async fn repair_recipe(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RepairRequest>,
) -> Json<ApiResponse> {
    let Some(api_key) = &state.openai_api_key else {
        return Json(ApiResponse {
            ok: false,
            markdown: payload.markdown,
            html: String::new(),
            diagnostics: vec![DiagnosticView {
                code: "MissingOpenAIKey".to_string(),
                message: "AI修復には OPENAI_API_KEY が必要です。".to_string(),
                suggestion: Some(
                    ".env に OPENAI_API_KEY を設定して再起動してください。".to_string(),
                ),
            }],
            attempts: vec![],
        });
    };

    let mut attempts = vec!["手動レンダーに失敗したため、AIに修正を依頼しました".to_string()];
    let mut markdown = payload.markdown;
    let mut diagnostics = payload.diagnostics;

    for attempt in 1..=2 {
        let fixed = match call_openai(
            &state,
            api_key,
            recipe_repair_prompt(&state.forge),
            repair_user_prompt(&payload.prompt, &markdown, &diagnostics, &payload.pantry),
            1_100,
        )
        .await
        {
            Ok(fixed) => fixed,
            Err(err) => {
                return Json(ApiResponse {
                    ok: false,
                    markdown,
                    html: String::new(),
                    diagnostics: vec![DiagnosticView {
                        code: "OpenAIRepairFailed".to_string(),
                        message: err,
                        suggestion: None,
                    }],
                    attempts,
                })
            }
        };

        let mut response = render_markdown(&state, fixed.clone(), &payload.pantry, true);
        attempts.push(format!(
            "修正 {attempt}: {}",
            if response.ok {
                "mdforgeレシピとして有効".to_string()
            } else {
                response
                    .diagnostics
                    .iter()
                    .map(|diag| format!("{}: {}", diag.code, diag.message))
                    .collect::<Vec<_>>()
                    .join("; ")
            }
        ));

        if response.ok || attempt == 2 {
            response.attempts = attempts;
            return Json(response);
        }

        markdown = fixed;
        diagnostics = response.diagnostics;
    }

    unreachable!("repair loop returns inside the bounded attempt loop")
}

async fn generate_with_feedback(
    state: &AppState,
    api_key: &str,
    user_prompt: &str,
    pantry: &[String],
) -> Result<ApiResponse, String> {
    let mut attempts = Vec::new();
    let mut markdown = call_openai(
        state,
        api_key,
        recipe_system_prompt(&state.forge, pantry),
        generation_user_prompt(user_prompt, pantry),
        1_100,
    )
    .await?;

    for attempt in 1..=3 {
        let mut response = render_markdown(state, markdown.clone(), pantry, true);
        attempts.push(format!(
            "生成 {attempt}: {}",
            if response.ok {
                "mdforgeレシピとして有効".to_string()
            } else {
                response
                    .diagnostics
                    .iter()
                    .map(|diag| format!("{}: {}", diag.code, diag.message))
                    .collect::<Vec<_>>()
                    .join("; ")
            }
        ));

        if response.ok || attempt == 3 {
            response.attempts = attempts;
            return Ok(response);
        }

        markdown = call_openai(
            state,
            api_key,
            recipe_repair_prompt(&state.forge),
            repair_user_prompt(user_prompt, &markdown, &response.diagnostics, pantry),
            1_100,
        )
        .await?;
    }

    unreachable!("feedback loop returns inside the bounded attempt loop")
}

async fn call_openai(
    state: &AppState,
    api_key: &str,
    system_prompt: String,
    user_prompt: String,
    max_output_tokens: u16,
) -> Result<String, String> {
    let response = reqwest::Client::new()
        .post("https://api.openai.com/v1/responses")
        .bearer_auth(api_key)
        .json(&json!({
            "model": state.openai_model,
            "input": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            "max_output_tokens": max_output_tokens,
            "store": false
        }))
        .send()
        .await
        .map_err(|err| err.to_string())?;

    let status = response.status();
    let body: Value = response.json().await.map_err(|err| err.to_string())?;
    if !status.is_success() {
        return Err(format!("OpenAI returned {status}: {body}"));
    }

    extract_response_text(&body).ok_or_else(|| format!("OpenAI response had no text: {body}"))
}

fn recipe_system_prompt(forge: &Forge, pantry: &[String]) -> String {
    format!(
        "あなたは Recipe Lab という料理アシスタントです。返答は会話文ではなく、raw mdforge レシピMarkdownだけにしてください。\n\n\
         絶対ルール:\n\
         - 日本語で返す。\n\
         - Markdownコードフェンスは禁止。\n\
         - 文書の前後に説明文を足さない。\n\
         - 全体を必ず1つの root :::recipe ブロックで囲む。\n\
         - すべての :::step と :::tip は ::: で閉じる。\n\
         - root :::recipe も最後に追加の ::: で閉じる。\n\
         - 最後の子要素が step/tip の場合、末尾の空でない2行は ::: と ::: になる。\n\
         - 引数値に空白を入れない。必要なら underscore を使う。日本語の短い値はそのままでよい。\n\n\
         使える mdforge 構文:\n{}\n\n\
         家にある食材 refs:\n{}\n\n\
         全食材 refs:\n{}\n\n\
         構成ルール:\n\
         - できるだけ「家にある食材 refs」だけで作る。\n\
         - {{ingredient ...}} に使ってよい ref は「家にある食材 refs」だけ。\n\
         - 家にある食材は {{ingredient name=表示名 ref=ref amount=分量}} でマークアップする。\n\
         - 家に無いが必要な食材は必ず {{needed name=表示名 ref=ref amount=分量}} でマークアップする。\n\
         - ingredient と needed には name/ref/amount の3つを必ず全部付ける。\n\
         - ref=none, ref=なし, ref=other のような未定義refは禁止。必ず全食材 refs から選ぶ。\n\
         - amount も必須。例: amount=1合, amount=2個, amount=大さじ1, amount=10g。\n\
         - 調味料や油も、文中でマークアップするなら必ず amount を書く。\n\
         - 家に無い食材を {{ingredient ...}} に入れると検証エラーになる。\n\
         - needed は調達が必要な食材用。家にある食材には絶対に使わない。\n\
         - 3〜6個の :::step を n=1,2,3... で作る。kind は prep/cook/serve。\n\
         - すべての :::step には必ず minutes=<整数> を付ける。任意ではなく必須。\n\
         - 少なくとも1つ :::tip を入れる。kind は swap/safety/timing。\n\
         - timer インラインは補足に使ってよいが、各フェーズの分数は必ず step の minutes に入れる。\n\n\
         形の例:\n\
         :::recipe title=トマトごはん servings=2 difficulty=easy\n\
         {{badge kind=quick}}\n\
         :::step n=1 kind=prep minutes=4\n\
         {{ingredient name=トマト ref=tomato amount=1個}}を切ります。{{ingredient name=醤油 ref=soy_sauce amount=小さじ1}}を用意します。\n\
         :::\n\
         :::tip kind=timing\n\
         火加減は中火にします。\n\
         :::\n\
         :::",
        forge.signature(),
        pantry.join(", "),
        recipe_ingredients().join(", ")
    )
}

fn recipe_repair_prompt(forge: &Forge) -> String {
    format!(
        "あなたは壊れた mdforge レシピMarkdownを修正します。返答は修正済みの raw mdforge 文書だけにしてください。\n\
         修正説明やコードフェンスは禁止です。内容はできるだけ維持し、診断エラーをすべて直してください。\n\
         特に閉じ忘れの :::、未定義の引数値、家にある食材/調達食材のマークアップに注意してください。\n\
         ingredient/needed は必ず name/ref/amount をすべて持ち、ref は全食材 refs から選んでください。\n\
         有効な構文:\n{}\n\
         全食材 refs: {}.",
        forge.signature(),
        recipe_ingredients().join(", ")
    )
}

fn generation_user_prompt(user_prompt: &str, pantry: &[String]) -> String {
    format!(
        "ユーザーの要望:\n{user_prompt}\n\n家にある食材 refs:\n{}\n\nこの食材セットを主役にして、足りない食材は needed で明示してください。",
        pantry.join(", ")
    )
}

fn repair_user_prompt(
    user_prompt: &str,
    markdown: &str,
    diagnostics: &[DiagnosticView],
    pantry: &[String],
) -> String {
    let diagnostics = diagnostics
        .iter()
        .map(|diag| {
            format!(
                "- {}: {}{}",
                diag.code,
                diag.message,
                diag.suggestion
                    .as_ref()
                    .map(|suggestion| format!(" ({suggestion})"))
                    .unwrap_or_default()
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "元のユーザー要望:\n{user_prompt}\n\n家にある食材 refs:\n{}\n\n壊れた mdforge Markdown:\n{markdown}\n\n修正すべき診断:\n{diagnostics}",
        pantry.join(", ")
    )
}

fn extract_response_text(response: &Value) -> Option<String> {
    response
        .get("output")
        .and_then(Value::as_array)?
        .iter()
        .flat_map(|item| {
            item.get("content")
                .and_then(Value::as_array)
                .into_iter()
                .flatten()
        })
        .find_map(|content| {
            content
                .get("text")
                .and_then(Value::as_str)
                .map(str::to_string)
        })
}

fn render_markdown(
    state: &AppState,
    markdown: String,
    pantry: &[String],
    enforce_pantry: bool,
) -> ApiResponse {
    let doc = match state.forge.parse(markdown.trim_end()) {
        Ok(doc) => doc,
        Err(diagnostics) => return response_with_diagnostics(markdown, diagnostics),
    };

    if let Err(diagnostics) = state.forge.validate(&doc) {
        return response_with_diagnostics(markdown, diagnostics);
    }

    if let Err(diagnostics) = state.forge.eval(&doc, &state.eval_ctx) {
        return response_with_diagnostics(markdown, diagnostics);
    }

    if enforce_pantry {
        let invalid_home_ingredients = pantry_mismatches(&doc, pantry);
        if !invalid_home_ingredients.is_empty() {
            return response_with_views(
                markdown,
                invalid_home_ingredients
                    .into_iter()
                    .map(|id| DiagnosticView {
                        code: "PantryIngredientNotAvailable".to_string(),
                        message: format!(
                            "'{}' は今回の冷蔵庫にありません。家にない食材は needed でマークしてください。",
                            ingredient_label(&id)
                        ),
                        suggestion: Some(format!(
                            "{{needed name={} ref={} amount=必要量}} を使って調達が必要な食材として示してください。",
                            ingredient_label(&id),
                            id
                        )),
                    })
                    .collect(),
            );
        }
    }

    match state
        .forge
        .render_html(&doc, &state.eval_ctx, &RecipeHtmlRenderer)
    {
        Ok(html) => {
            let summary = render_used_ingredients(&doc, pantry);
            ApiResponse {
                ok: true,
                markdown,
                html: format!("{summary}{html}"),
                diagnostics: vec![],
                attempts: vec![],
            }
        }
        Err(diagnostics) => response_with_diagnostics(markdown, diagnostics),
    }
}

fn response_with_views(markdown: String, diagnostics: Vec<DiagnosticView>) -> ApiResponse {
    ApiResponse {
        ok: false,
        markdown,
        html: String::new(),
        diagnostics,
        attempts: vec![],
    }
}

fn response_with_diagnostics(markdown: String, diagnostics: Vec<Diagnostic>) -> ApiResponse {
    ApiResponse {
        ok: false,
        markdown,
        html: String::new(),
        diagnostics: diagnostics
            .into_iter()
            .map(|diag| DiagnosticView {
                code: format!("{:?}", diag.code),
                message: diag.message,
                suggestion: diag.suggestion,
            })
            .collect(),
        attempts: vec![],
    }
}

fn render_used_ingredients(doc: &mdforge::Document, pantry: &[String]) -> String {
    let mut home = Vec::new();
    let mut needed = Vec::new();
    collect_used_ingredients(&doc.nodes, &mut home, &mut needed);

    home.sort();
    home.dedup_by(|a, b| a.id == b.id && a.amount == b.amount);
    needed.sort();
    needed.dedup_by(|a, b| a.id == b.id && a.amount == b.amount);

    let pantry_set = pantry.iter().cloned().collect::<HashSet<_>>();
    let home_items = ingredient_chips(&home);
    let needed_items = ingredient_chips(&needed);
    let pantry_items = pantry_chips(pantry);
    let outside_home = home
        .iter()
        .filter(|item| !pantry_set.contains(&item.id))
        .map(|item| ingredient_label(&item.id))
        .collect::<Vec<_>>()
        .join("</span><span>");

    let mut warning = String::new();
    if !outside_home.is_empty() {
        warning = format!(
            "<p class=\"summary-warning\">家にある食材としてマークされていますが、今回の冷蔵庫リスト外です: <span>{outside_home}</span></p>"
        );
    }

    format!(
        "<section class=\"used-summary\"><p class=\"eyebrow\">今回の食材</p>\
         <div><strong>家にあるもの</strong><span>{home_items}</span></div>\
         <div><strong>調達が必要</strong><span>{needed_items}</span></div>\
         <details><summary>今回の冷蔵庫</summary><span>{pantry_items}</span></details>{warning}</section>"
    )
}

#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
struct UsedIngredient {
    id: String,
    amount: String,
}

fn ingredient_chips(items: &[UsedIngredient]) -> String {
    if items.is_empty() {
        return "<span>なし</span>".to_string();
    }

    format!(
        "<span>{}</span>",
        items
            .iter()
            .map(|item| format!(
                "{} {}",
                ingredient_label(&item.id),
                item.amount.replace('_', " ")
            ))
            .collect::<Vec<_>>()
            .join("</span><span>")
    )
}

fn pantry_chips(ids: &[String]) -> String {
    if ids.is_empty() {
        return "<span>なし</span>".to_string();
    }

    format!(
        "<span>{}</span>",
        ids.iter()
            .map(|id| ingredient_label(id))
            .collect::<Vec<_>>()
            .join("</span><span>")
    )
}

fn collect_used_ingredients(
    nodes: &[mdforge::Node],
    home: &mut Vec<UsedIngredient>,
    needed: &mut Vec<UsedIngredient>,
) {
    for node in nodes {
        match node {
            mdforge::Node::Markdown(events) => {
                for event in events {
                    let mdforge::MdEvent::Text(text) = event;
                    for inline in scan_inline_exts(text) {
                        let target = match inline.name.as_str() {
                            "ingredient" => Some(&mut *home),
                            "needed" => Some(&mut *needed),
                            _ => None,
                        };
                        if let Some(target) = target {
                            if let Some(ArgValue::String(reference)) = inline.args.get("ref") {
                                let amount = match inline.args.get("amount") {
                                    Some(ArgValue::String(amount)) => amount.clone(),
                                    Some(ArgValue::Int(amount)) => amount.to_string(),
                                    None => "?".to_string(),
                                };
                                target.push(UsedIngredient {
                                    id: reference.clone(),
                                    amount,
                                });
                            }
                        }
                    }
                }
            }
            mdforge::Node::Block(block) => collect_used_ingredients(&block.body, home, needed),
        }
    }
}

fn pantry_mismatches(doc: &mdforge::Document, pantry: &[String]) -> Vec<String> {
    let mut home = Vec::new();
    let mut needed = Vec::new();
    collect_used_ingredients(&doc.nodes, &mut home, &mut needed);
    let pantry_set = pantry.iter().cloned().collect::<HashSet<_>>();
    home.into_iter()
        .filter(|item| !pantry_set.contains(&item.id))
        .map(|item| item.id)
        .collect::<HashSet<_>>()
        .into_iter()
        .collect()
}

fn scan_inline_exts(text: &str) -> Vec<InlineExt> {
    let mut out = Vec::new();
    let bytes = text.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] != b'{' {
            i += 1;
            continue;
        }
        let start = i;
        let mut j = i + 1;
        while j < bytes.len() && bytes[j] != b'}' {
            j += 1;
        }
        if j >= bytes.len() {
            break;
        }
        let content = &text[i + 1..j];
        let parts = content.split_whitespace().collect::<Vec<_>>();
        if !parts.is_empty() {
            let mut args = HashMap::new();
            for token in &parts[1..] {
                if let Some((key, value)) = token.split_once('=') {
                    let value = value
                        .parse::<i64>()
                        .map(ArgValue::Int)
                        .unwrap_or_else(|_| ArgValue::String(value.to_string()));
                    args.insert(key.to_string(), value);
                }
            }
            out.push(InlineExt {
                name: parts[0].to_string(),
                args,
                span: mdforge::Span { start, end: j + 1 },
            });
        }
        i = j + 1;
    }
    out
}

fn recipe_forge() -> Forge {
    Forge::builder()
        .block("recipe")
        .arg("title", ArgType::String.required())
        .arg("servings", ArgType::Int.required())
        .arg(
            "difficulty",
            ArgType::StaticEnum(&["easy", "medium", "hard"]).required(),
        )
        .body_markdown()
        .register()
        .block("step")
        .arg("n", ArgType::Int.required())
        .arg(
            "kind",
            ArgType::StaticEnum(&["prep", "cook", "serve"]).required(),
        )
        .arg("minutes", ArgType::Int.required())
        .body_markdown()
        .register()
        .block("tip")
        .arg(
            "kind",
            ArgType::StaticEnum(&["swap", "safety", "timing"]).required(),
        )
        .body_markdown()
        .register()
        .inline("ingredient")
        .arg("name", ArgType::String.required())
        .arg("ref", ArgType::DynamicEnum("ingredients").required())
        .arg("amount", ArgType::String.required())
        .register()
        .inline("needed")
        .arg("name", ArgType::String.required())
        .arg("ref", ArgType::DynamicEnum("ingredients").required())
        .arg("amount", ArgType::String.required())
        .register()
        .inline("timer")
        .arg("minutes", ArgType::Int.required())
        .register()
        .inline("badge")
        .arg(
            "kind",
            ArgType::StaticEnum(&["quick", "veggie", "spicy", "make_ahead"]).required(),
        )
        .register()
        .build()
}

fn recipe_eval_ctx() -> EvalContext {
    let mut dynamic_values = HashMap::new();
    dynamic_values.insert(
        "ingredients".to_string(),
        recipe_ingredients()
            .into_iter()
            .map(str::to_string)
            .collect::<HashSet<_>>(),
    );
    EvalContext { dynamic_values }
}

fn recipe_ingredients() -> Vec<&'static str> {
    vec![
        "egg",
        "rice",
        "chicken",
        "salmon",
        "tofu",
        "tomato",
        "onion",
        "garlic",
        "butter",
        "olive_oil",
        "soy_sauce",
        "miso",
        "pasta",
        "cheese",
        "basil",
        "potato",
        "carrot",
        "mushroom",
        "cabbage",
        "spinach",
        "green_onion",
        "ginger",
        "milk",
        "flour",
        "bread",
        "tuna",
        "natto",
        "sesame_oil",
        "daikon",
        "pork",
    ]
}

fn random_pantry() -> Vec<String> {
    let mut items = recipe_ingredients()
        .into_iter()
        .filter(|id| !staple_ingredients().contains(id))
        .map(str::to_string)
        .collect::<Vec<_>>();
    let mut seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos() as u64)
        .unwrap_or(0x5eed);

    for i in (1..items.len()).rev() {
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        items.swap(i, (seed as usize) % (i + 1));
    }

    seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
    let count = 5 + (seed as usize % 6);
    items.truncate(count);
    let mut pantry = staple_ingredients()
        .into_iter()
        .map(str::to_string)
        .collect::<Vec<_>>();
    pantry.extend(items);
    pantry.sort();
    pantry.dedup();
    pantry
}

fn staple_ingredients() -> Vec<&'static str> {
    vec![
        "soy_sauce",
        "miso",
        "olive_oil",
        "sesame_oil",
        "butter",
        "garlic",
        "ginger",
    ]
}

fn ingredient_label(id: &str) -> String {
    match id {
        "egg" => "卵",
        "rice" => "ごはん",
        "chicken" => "鶏肉",
        "salmon" => "鮭",
        "tofu" => "豆腐",
        "tomato" => "トマト",
        "onion" => "玉ねぎ",
        "garlic" => "にんにく",
        "butter" => "バター",
        "olive_oil" => "オリーブオイル",
        "soy_sauce" => "醤油",
        "miso" => "味噌",
        "pasta" => "パスタ",
        "cheese" => "チーズ",
        "basil" => "バジル",
        "potato" => "じゃがいも",
        "carrot" => "にんじん",
        "mushroom" => "きのこ",
        "cabbage" => "キャベツ",
        "spinach" => "ほうれん草",
        "green_onion" => "ねぎ",
        "ginger" => "しょうが",
        "milk" => "牛乳",
        "flour" => "小麦粉",
        "bread" => "パン",
        "tuna" => "ツナ",
        "natto" => "納豆",
        "sesame_oil" => "ごま油",
        "daikon" => "大根",
        "pork" => "豚肉",
        other => other,
    }
    .to_string()
}

struct RecipeHtmlRenderer;

impl HtmlRenderer for RecipeHtmlRenderer {
    fn render_block(&self, block: &BlockNode, _ctx: &EvalContext, children_html: String) -> String {
        match block.name.as_str() {
            "recipe" => {
                let title = display_arg(block, "title");
                let servings = int_arg(block, "servings").unwrap_or_default();
                let difficulty = display_arg(block, "difficulty");
                format!(
                    "<article class=\"recipe-card\">\
                     <header><p class=\"eyebrow\">レシピ</p><h2>{}</h2>\
                     <div class=\"meta\"><span>{}人分</span><span>{}</span></div></header>\
                     <div class=\"recipe-body\">{}</div></article>",
                    escape_html(&title),
                    servings,
                    escape_html(&difficulty_label(&difficulty)),
                    children_html
                )
            }
            "step" => {
                let n = int_arg(block, "n").unwrap_or_default();
                let kind = display_arg(block, "kind");
                let minutes = int_arg(block, "minutes").unwrap_or_default();
                format!(
                    "<section class=\"step step-{}\"><div class=\"step-number\">{}</div>\
                     <div class=\"step-copy\"><p class=\"step-kind\">{}<span class=\"phase-time\">{}分</span></p>{}</div></section>",
                    escape_attr(&kind),
                    n,
                    escape_html(&step_kind_label(&kind)),
                    minutes,
                    children_html
                )
            }
            "tip" => {
                let kind = display_arg(block, "kind");
                format!(
                    "<aside class=\"tip tip-{}\"><strong>{}</strong>{}</aside>",
                    escape_attr(&kind),
                    escape_html(&tip_kind_label(&kind)),
                    children_html
                )
            }
            _ => children_html,
        }
    }

    fn render_inline(&self, inline: &InlineExt, _ctx: &EvalContext) -> String {
        match inline.name.as_str() {
            "ingredient" => {
                let name = display_inline_arg(inline, "name");
                let reference = display_inline_arg(inline, "ref");
                let amount = display_inline_arg(inline, "amount");
                format!(
                    "<span class=\"ingredient\" title=\"家にある食材: {} / {}\">{}<small>{}</small></span>",
                    escape_attr(&reference),
                    escape_attr(&amount),
                    escape_html(&name),
                    escape_html(&amount)
                )
            }
            "needed" => {
                let name = display_inline_arg(inline, "name");
                let reference = display_inline_arg(inline, "ref");
                let amount = display_inline_arg(inline, "amount");
                format!(
                    "<span class=\"needed\" title=\"調達が必要: {} / {}\">買う: {}<small>{}</small></span>",
                    escape_attr(&reference),
                    escape_attr(&amount),
                    escape_html(&name),
                    escape_html(&amount)
                )
            }
            "timer" => {
                let minutes = int_inline_arg(inline, "minutes").unwrap_or_default();
                format!("<span class=\"timer\">{} min</span>", minutes)
            }
            "badge" => {
                let kind = display_inline_arg(inline, "kind");
                format!(
                    "<span class=\"badge badge-{}\">{}</span>",
                    escape_attr(&kind),
                    escape_html(&badge_label(&kind))
                )
            }
            _ => String::new(),
        }
    }
}

fn display_arg(block: &BlockNode, name: &str) -> String {
    match block.args.get(name) {
        Some(ArgValue::String(value)) => value.replace('_', " "),
        Some(ArgValue::Int(value)) => value.to_string(),
        None => String::new(),
    }
}

fn int_arg(block: &BlockNode, name: &str) -> Option<i64> {
    match block.args.get(name) {
        Some(ArgValue::Int(value)) => Some(*value),
        _ => None,
    }
}

fn display_inline_arg(inline: &InlineExt, name: &str) -> String {
    match inline.args.get(name) {
        Some(ArgValue::String(value)) => value.replace('_', " "),
        Some(ArgValue::Int(value)) => value.to_string(),
        None => String::new(),
    }
}

fn int_inline_arg(inline: &InlineExt, name: &str) -> Option<i64> {
    match inline.args.get(name) {
        Some(ArgValue::Int(value)) => Some(*value),
        _ => None,
    }
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn escape_attr(value: &str) -> String {
    escape_html(value).replace('\'', "&#39;")
}

fn difficulty_label(value: &str) -> String {
    match value {
        "easy" => "かんたん",
        "medium" => "ふつう",
        "hard" => "本格派",
        other => other,
    }
    .to_string()
}

fn step_kind_label(value: &str) -> String {
    match value {
        "prep" => "下ごしらえ",
        "cook" => "調理",
        "serve" => "仕上げ",
        other => other,
    }
    .to_string()
}

fn tip_kind_label(value: &str) -> String {
    match value {
        "swap" => "代用",
        "safety" => "安全メモ",
        "timing" => "タイミング",
        other => other,
    }
    .to_string()
}

fn badge_label(value: &str) -> String {
    match value {
        "quick" => "時短",
        "veggie" => "野菜多め",
        "spicy" => "ピリ辛",
        "make_ahead" => "作り置き",
        other => other,
    }
    .to_string()
}

const INDEX_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Recipe Lab | mdforge デモ</title>
  <style>
    :root {
      --ink: #22190f;
      --muted: #7c6753;
      --paper: #fff8ec;
      --card: #fffdf6;
      --tomato: #e34f35;
      --basil: #2e7d4f;
      --gold: #e3a229;
      --line: #eadac3;
      --shadow: 0 24px 70px rgba(87, 54, 23, .18);
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      color: var(--ink);
      background:
        radial-gradient(circle at top left, rgba(227, 79, 53, .22), transparent 32rem),
        radial-gradient(circle at 80% 20%, rgba(46, 125, 79, .18), transparent 28rem),
        linear-gradient(135deg, #fff8ec, #f7ead8 55%, #fff3df);
      font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
    }
    main { width: min(1180px, calc(100vw - 32px)); margin: 0 auto; padding: 38px 0 56px; }
    .hero {
      display: grid;
      gap: 16px;
      margin-bottom: 24px;
      padding: 30px;
      border: 1px solid rgba(124, 103, 83, .22);
      border-radius: 30px;
      background: rgba(255, 253, 246, .72);
      box-shadow: var(--shadow);
      backdrop-filter: blur(16px);
    }
    h1 { margin: 0; font-size: clamp(2.4rem, 6vw, 5.8rem); line-height: .86; letter-spacing: -.07em; }
    .hero p { max-width: 760px; margin: 0; color: var(--muted); font-size: 1.12rem; line-height: 1.7; }
    .workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .95fr); gap: 22px; }
    .pantry {
      margin-bottom: 22px;
      padding: 20px;
      border: 1px solid rgba(124, 103, 83, .24);
      border-radius: 28px;
      background: rgba(255, 253, 246, .82);
      box-shadow: 0 18px 50px rgba(87, 54, 23, .12);
    }
    .pantry-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
    .pantry-head h2 { margin: 0; font-size: 1.2rem; }
    .chips { display: flex; flex-wrap: wrap; gap: 9px; }
    .chip, .used-summary span {
      display: inline-flex;
      align-items: center;
      border-radius: 999px;
      padding: 7px 11px;
      background: #f4e3c9;
      font: 800 .86rem/1 sans-serif;
    }
    .chip.staple { background: #e8f3dc; color: #365923; }
    .panel {
      border: 1px solid rgba(124, 103, 83, .24);
      border-radius: 28px;
      background: rgba(255, 253, 246, .82);
      box-shadow: 0 18px 50px rgba(87, 54, 23, .12);
      overflow: hidden;
    }
    .panel-head { padding: 18px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 14px; }
    .panel-head h2 { margin: 0; font-size: 1rem; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
    .panel-body { padding: 18px; }
    input {
      width: 100%;
      border: 1px solid var(--line);
      border-radius: 18px;
      background: #fffaf1;
      color: var(--ink);
      font: 15px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      padding: 14px;
      outline: none;
    }
    input:focus { border-color: rgba(227, 79, 53, .65); box-shadow: 0 0 0 4px rgba(227, 79, 53, .12); }
    .prompt-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; margin-bottom: 14px; }
    button {
      border: 0;
      border-radius: 999px;
      padding: 12px 18px;
      color: #fff8ec;
      background: var(--ink);
      font-weight: 800;
      cursor: pointer;
      box-shadow: 0 12px 26px rgba(34, 25, 15, .18);
    }
    button.secondary { color: var(--ink); background: #f0ddbf; }
    button:disabled { opacity: .55; cursor: progress; }
    .preview {
      min-height: 560px;
      padding: 18px;
      background: linear-gradient(180deg, #fffdf6, #fff6e5);
    }
    .recipe-card {
      border: 1px solid var(--line);
      border-radius: 28px;
      padding: 24px;
      background: var(--card);
      box-shadow: 0 16px 36px rgba(87, 54, 23, .12);
    }
    .recipe-card h2 { margin: 0; font-size: 2.1rem; letter-spacing: -.04em; }
    .eyebrow, .step-kind { margin: 0 0 6px; color: var(--tomato); font: 800 .74rem/1 sans-serif; text-transform: uppercase; letter-spacing: .16em; }
    .meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
    .meta span, .badge, .timer {
      display: inline-flex;
      align-items: center;
      border-radius: 999px;
      padding: 6px 10px;
      background: #f4e3c9;
      font: 800 .8rem/1 sans-serif;
    }
    .recipe-body { margin-top: 22px; display: grid; gap: 14px; }
    .step {
      display: grid;
      grid-template-columns: 52px 1fr;
      gap: 14px;
      padding: 16px;
      border-radius: 22px;
      background: #fff8ec;
      border: 1px solid var(--line);
    }
    .step-number {
      width: 52px;
      height: 52px;
      border-radius: 18px;
      display: grid;
      place-items: center;
      color: #fff8ec;
      background: var(--tomato);
      font: 900 1.3rem/1 sans-serif;
    }
    .step-copy p { margin-top: 0; }
    .phase-time {
      margin-left: 8px;
      display: inline-flex;
      align-items: center;
      border-radius: 999px;
      padding: 4px 8px;
      background: rgba(227, 162, 41, .22);
      color: #7a4b00;
      font: 900 .75rem/1 sans-serif;
      letter-spacing: 0;
    }
    .ingredient, .needed {
      position: relative;
      display: inline-block;
      padding: 1px 6px 2px;
      border-radius: 8px;
      font-weight: 800;
      cursor: help;
    }
    .ingredient {
      background: rgba(46, 125, 79, .12);
      color: #1f6840;
    }
    .needed {
      background: rgba(227, 79, 53, .14);
      color: #a42f1f;
      border: 1px dashed rgba(227, 79, 53, .55);
    }
    .ingredient small, .needed small {
      margin-left: 4px;
      opacity: .78;
      font-size: .78em;
    }
    .timer { background: rgba(227, 162, 41, .22); color: #7a4b00; }
    .badge { margin-right: 6px; background: rgba(227, 79, 53, .14); color: var(--tomato); }
    .tip {
      border-left: 5px solid var(--gold);
      border-radius: 18px;
      padding: 14px 16px;
      background: #fff2d4;
    }
    .tip strong { margin-right: 8px; text-transform: uppercase; font: 900 .78rem/1 sans-serif; letter-spacing: .12em; }
    #diagnostics { margin-top: 12px; display: grid; gap: 8px; }
    .diag { border-radius: 14px; padding: 10px 12px; background: #ffe1dc; color: #8f2417; font: 14px/1.45 sans-serif; }
    #attempts { margin-top: 12px; display: grid; gap: 6px; }
    .attempt { border-radius: 14px; padding: 9px 11px; background: #e8f3dc; color: #365923; font: 13px/1.45 sans-serif; }
    .used-summary {
      margin-bottom: 14px;
      padding: 18px;
      border: 1px solid var(--line);
      border-radius: 24px;
      background: rgba(255, 253, 246, .9);
      display: grid;
      gap: 10px;
    }
    .used-summary div, .used-summary details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
    .used-summary strong { min-width: 88px; font: 900 .82rem/1 sans-serif; color: var(--muted); }
    .used-summary details { color: var(--muted); }
    .summary-warning { margin: 0; color: #a42f1f; font: 13px/1.5 sans-serif; }
    .signature, .generated-md { white-space: pre-wrap; color: var(--muted); font: 12px/1.45 ui-monospace, monospace; }
    .generated-md {
      margin: 0;
      max-height: 420px;
      overflow: auto;
      border: 1px solid var(--line);
      border-radius: 18px;
      background: #fffaf1;
      padding: 14px;
    }
    @media (max-width: 900px) {
      .workspace { grid-template-columns: 1fr; }
      .prompt-row { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <main>
    <section class="hero">
      <h1>Recipe Lab</h1>
      <p>家にある食材だけで作れるレシピをAIに考えてもらい、mdforgeで構造と食材参照を検証します。足りない食材は「調達が必要」として特別にマークされます。</p>
    </section>

    <section class="pantry">
      <div class="pantry-head">
        <div>
          <p class="eyebrow">今日の冷蔵庫</p>
          <h2>常備調味料 + この食材の中から作ります</h2>
        </div>
        <button class="secondary" id="shuffle-pantry">食材をシャッフル</button>
      </div>
      <div id="pantry-list" class="chips"></div>
    </section>

    <section class="workspace">
      <div class="panel">
        <div class="panel-head"><h2>チャット</h2></div>
        <div class="panel-body">
          <div class="prompt-row">
            <input id="prompt" placeholder="例: 家にあるもので、15分くらいで作れる美味しいレシピを教えて">
            <button id="generate">レシピ作成</button>
          </div>
          <div id="attempts"></div>
          <div id="diagnostics"></div>
        </div>
      </div>

      <div class="panel">
        <div class="panel-head"><h2>HTMLプレビュー</h2></div>
        <div id="preview" class="preview"></div>
      </div>
    </section>

    <section class="panel" style="margin-top:22px">
      <div class="panel-head"><h2>AIに渡す構文仕様</h2></div>
      <div class="panel-body">
        <div id="signature" class="signature"></div>
        <h3 style="margin:18px 0 10px">生成された拡張Markdown</h3>
        <pre id="generated-md" class="generated-md"></pre>
      </div>
    </section>
  </main>

  <script>
    const promptInput = document.querySelector('#prompt');
    const preview = document.querySelector('#preview');
    const diagnostics = document.querySelector('#diagnostics');
    const attempts = document.querySelector('#attempts');
    const signature = document.querySelector('#signature');
    const generatedMd = document.querySelector('#generated-md');
    const pantryList = document.querySelector('#pantry-list');
    const generateButton = document.querySelector('#generate');
    const shuffleButton = document.querySelector('#shuffle-pantry');
    let lastDiagnostics = [];
    let currentMarkdown = '';
    let ingredientCatalog = [];
    let currentPantry = [];
    const stapleIngredients = ['soy_sauce', 'miso', 'olive_oil', 'sesame_oil', 'butter', 'garlic', 'ginger'];

    preview.innerHTML = '<p style="color:#7c6753">お願いを入力して「レシピ作成」を押すと、ここにレシピが表示されます。</p>';
    generatedMd.textContent = 'まだ生成されていません。';

    function labelFor(id) {
      return ingredientCatalog.find(item => item.id === id)?.name || id;
    }

    function shuffle(values) {
      const copy = [...values];
      for (let i = copy.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [copy[i], copy[j]] = [copy[j], copy[i]];
      }
      return copy;
    }

    function pickPantry() {
      const count = 5 + Math.floor(Math.random() * 6);
      const staples = stapleIngredients.filter(id => ingredientCatalog.some(item => item.id === id));
      const randomItems = shuffle(
        ingredientCatalog
          .map(item => item.id)
          .filter(id => !staples.includes(id))
      ).slice(0, count);
      currentPantry = [...new Set([...staples, ...randomItems])];
      renderPantry();
    }

    function renderPantry() {
      pantryList.innerHTML = '';
      for (const id of currentPantry) {
        const el = document.createElement('span');
        el.className = stapleIngredients.includes(id) ? 'chip staple' : 'chip';
        el.textContent = labelFor(id);
        pantryList.appendChild(el);
      }
    }

    async function api(path, body) {
      const response = await fetch(path, {
        method: 'POST',
        headers: {'content-type': 'application/json'},
        body: JSON.stringify(body)
      });
      return response.json();
    }

    function showResult(result) {
      if (result.markdown) currentMarkdown = result.markdown.trim();
      generatedMd.textContent = currentMarkdown || 'まだ生成されていません。';
      preview.innerHTML = result.html || '<p style="color:#7c6753">まだプレビューはありません。</p>';
      lastDiagnostics = result.diagnostics || [];
      diagnostics.innerHTML = '';
      attempts.innerHTML = '';
      for (const attempt of result.attempts || []) {
        const el = document.createElement('div');
        el.className = 'attempt';
        el.textContent = `AIフィードバック: ${attempt}`;
        attempts.appendChild(el);
      }
      for (const diag of result.diagnostics || []) {
        const el = document.createElement('div');
        el.className = 'diag';
        el.textContent = `${diag.code}: ${diag.message}${diag.suggestion ? ' (' + diag.suggestion + ')' : ''}`;
        diagnostics.appendChild(el);
      }
    }

    generateButton.addEventListener('click', async () => {
      const prompt = promptInput.value.trim();
      if (!prompt) {
        diagnostics.innerHTML = '<div class="diag">お願いを入力してください。</div>';
        return;
      }
      generateButton.disabled = true;
      try {
        showResult(await api('/api/generate', {prompt, pantry: currentPantry}));
      } finally {
        generateButton.disabled = false;
      }
    });

    shuffleButton.addEventListener('click', pickPantry);

    fetch('/api/signature').then(r => r.json()).then(data => {
      ingredientCatalog = data.ingredients;
      currentPantry = data.initial_pantry;
      renderPantry();
      signature.textContent = `${data.signature}\n\n食材refs:\n${data.ingredients.map(item => `${item.id}=${item.name}`).join(', ')}`;
    });
  </script>
</body>
</html>"#;
