/*
    Hurry Curry! - a game about cooking
    Copyright (C) 2026 Hurry Curry! Contributors

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, version 3 of the License only.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.

*/

pub mod book;
pub mod entities;
pub mod filter_demands;
pub mod recipes;
pub mod registry;

use crate::{
    book::book,
    entities::{ButtonAction, EntityDecl},
    recipes::{RecipeDecl, RecipeGroup, load_recipes},
    registry::{ItemTileRegistry, filter_unused_tiles_and_items},
};
use anyhow::{Context, Result, anyhow, bail};
use clap::Parser;
use filter_demands::filter_demands_and_recipes;
use hurrycurry_protocol::{
    GameConfig, Gamedata, GamedataFlags, ItemIndex, MapMetadata, Recipe, TileIndex,
    book::Book,
    glam::{IVec2, Vec2},
};
use log::{debug, info};
use serde::Deserialize;
use std::{
    collections::{BTreeMap, HashMap, HashSet},
    fs::read_to_string,
    path::Path,
};

#[derive(Debug, Deserialize)]
pub struct DataIndex {
    pub maps: HashSet<String>,
    pub recipes: HashSet<String>,
}

#[rustfmt::skip]
#[derive(Debug, Clone, Deserialize)]
pub struct MapDecl {
    map: Vec<String>,
    #[serde(default)] tiles: HashMap<char, String>,
    #[serde(default)] use_palettes: Vec<String>,
    #[serde(default = "default_recipes")] recipes: String,
    #[serde(default)] hand_count: Option<usize>,
    #[serde(default)] entities: Vec<EntityDecl>,
    #[serde(default)] score_baseline: i64,
    #[serde(default)] timer: Option<f32>,
    #[serde(default)] flags: GamedataFlags,
    name: String,
    #[serde(default)] difficulty: i32,
    #[serde(default)] players: usize,
}
fn default_recipes() -> String {
    "default".to_string()
}

#[derive(Parser)]
struct TileArgs {
    tiles: Vec<String>,
    #[clap(short, long)]
    button: Option<ButtonAction>,
    #[clap(long)]
    chef_spawn: bool,
    #[clap(long)]
    customer_spawn: bool,
    #[clap(short = 'i', long)]
    item: Option<String>,
    #[clap(long)]
    conveyor: Option<String>,
    #[clap(long)]
    demand_sink: bool,
}

#[derive(Debug, Clone, Default)]
#[rustfmt::skip]
pub struct PrivateGamedata {
    pub initial_map: HashMap<IVec2, (Vec<TileIndex>, Option<ItemIndex>)>,
    pub chef_spawn: Vec2,
    pub customer_spawn: Option<Vec2>,
    pub score_baseline: i64,
    pub timer: f32,
    pub book: Book,
    pub entity_decls: Vec<EntityDecl>,
    pub recipe_groups: BTreeMap<String, RecipeGroup>,
}

pub fn map_list(data_path: &Path) -> Result<Vec<String>> {
    let index =
        read_to_string(data_path.join("index.yaml")).context("Failed reading data index")?;
    let index = serde_yaml_ng::from_str::<DataIndex>(&index)?;
    Ok(index.maps.into_iter().collect())
}
pub fn load_map_metadata(data_path: &Path) -> Result<Vec<MapMetadata>> {
    let mut maps = Vec::new();
    info!("Loading map metadata...");
    for name in map_list(&data_path)? {
        let (d, _) = build_gamedata(&data_path, &GameConfig::new_map(&name), false)
            .context(anyhow!("load map {name:?} "))?;
        maps.push(d.metadata);
    }
    maps.retain(|m| m.players > 0);
    maps.sort_unstable_by_key(|m| m.difficulty);
    maps.sort_by_key(|m| m.players);
    Ok(maps)
}

pub fn build_gamedata(
    data_path: &Path,
    config: &GameConfig,
    generate_book: bool,
) -> Result<(Gamedata, PrivateGamedata)> {
    let map_name = config.map.clone();
    debug!("Preparing gamedata for {map_name}");

    // Load index
    let index =
        read_to_string(data_path.join("index.yaml")).context("Failed reading data index")?;
    let index = serde_yaml_ng::from_str::<DataIndex>(&index)?;

    // Load map
    if map_name.contains("..") || map_name.starts_with("/") || map_name.contains("//") {
        bail!("illegal map path");
    }
    let map_in = read_to_string(data_path.join(format!("maps/{map_name}.yaml")))
        .context("Failed reading map file")?;
    let map_in = serde_yaml_ng::from_str::<MapDecl>(&map_in)?;

    // Load recipes
    if !index.recipes.contains(&map_in.recipes) {
        bail!("unknown recipes: {:?}", map_in.recipes);
    }
    let recipes_in = read_to_string(data_path.join(format!("recipes/{}.yaml", map_in.recipes)))?;
    let recipes_in = serde_yaml_ng::from_str::<Vec<RecipeDecl>>(&recipes_in)?;

    // Load tile flags
    let tile_flags =
        read_to_string(data_path.join("tiles.yaml")).context("Failed reading tile flags")?;
    let tile_flags = serde_yaml_ng::from_str::<HashMap<String, String>>(&tile_flags)?;

    let palette = load_palette(data_path, &map_in)?;

    let reg = ItemTileRegistry::default();
    let (mut recipes, mut demands, mut recipe_groups) = load_recipes(recipes_in, &reg)?;

    let mut entities = Vec::new();
    let mut chef_spawn = None;
    let mut customer_spawn = None;
    let mut initial_map = HashMap::new();
    let mut interactable_empty_extra = HashSet::new();

    for (y, line) in map_in.map.iter().enumerate() {
        for (x, char) in line.chars().enumerate() {
            if char == ' ' {
                continue; // space is empty space
            }
            let pos = IVec2::new(x as i32, y as i32);

            let ts = palette
                .get(&char)
                .ok_or(anyhow!("tile {char} is undefined"))?;

            let tiles = ts
                .tiles
                .iter()
                .cloned()
                .map(|t| reg.register_tile(t))
                .collect::<Vec<_>>();
            let Some(top) = tiles.last().copied() else {
                continue;
            };
            let item = ts.item.clone().map(|i| reg.register_item(i));
            initial_map.insert(pos, (tiles, item));

            if ts.chef_spawn {
                chef_spawn = Some(pos.as_vec2() + Vec2::splat(0.5));
            }
            if ts.customer_spawn {
                customer_spawn = Some(pos.as_vec2() + Vec2::splat(0.5));
            }
            if let Some(action) = ts.button {
                interactable_empty_extra.insert(top);
                entities.push(EntityDecl::Button { pos, action });
            }
            if ts.demand_sink {
                entities.push(EntityDecl::DemandSink { pos });
            }
            if let Some(off) = &ts.conveyor {
                let (x, y) = off
                    .split_once(",")
                    .ok_or(anyhow!("conveyor offset invalid format"))?;
                let dir = IVec2::new(x.parse()?, y.parse()?);
                entities.push(EntityDecl::Conveyor {
                    from: pos,
                    speed: None,
                    to: pos + dir,
                });
            }
        }
    }

    let chef_spawn = chef_spawn.ok_or(anyhow!("map has no chef spawn"))?;
    for mut e in map_in.entities.clone() {
        match &mut e {
            EntityDecl::Customers { unknown_order, .. } => {
                *unknown_order = reg.register_item("unknown-order".to_owned())
            }
            EntityDecl::TagMinigame {
                tag_item,
                blocker_tile,
            } => {
                *tag_item = reg.register_item("lettuce".to_owned());
                *blocker_tile = reg.register_tile("conveyor".to_owned());
            }
            EntityDecl::PlayerPortalPair {
                in_tile,
                out_tile,
                neutral_tile,
                ..
            } => {
                *in_tile = reg.register_tile("black-hole".to_owned());
                *neutral_tile = reg.register_tile("grey-hole".to_owned());
                *out_tile = reg.register_tile("white-hole".to_owned());
            }
            EntityDecl::CtfMinigame {
                items,
                item_indices,
                ..
            } => {
                item_indices.extend(items.iter().cloned().map(|name| reg.register_item(name)));
            }
            _ => (),
        }
        entities.push(e);
    }

    for name in config.bots.clone() {
        entities.push(EntityDecl::Bot { name });
    }

    debug!("{} entities created", entities.len());

    filter_demands_and_recipes(&initial_map, &mut demands, &mut recipes, &mut recipe_groups);
    let (items, tiles) = reg.finish();

    let hand_count = config.hand_count.or(map_in.hand_count).unwrap_or(1);
    let mut data = Gamedata {
        metadata: MapMetadata {
            name: map_name.to_string(),
            display_name: map_in.name,
            players: map_in.players,
            difficulty: map_in.difficulty,
            hand_count: hand_count,
            demand_items: demands
                .iter()
                .map(|d| items[d.input.0].to_string())
                .collect::<Vec<_>>(),
        },
        tile_collide: tiles_flagged(&tile_flags, &tiles, 'c'),
        tile_placeable_items: tile_placeable_items(
            &initial_map,
            &recipes,
            tiles_flagged(&tile_flags, &tiles, 'x'),
        ),
        tile_placeable_any: tiles_flagged(&tile_flags, &tiles, 'a'),
        tile_avoid_pathfinding: tiles_flagged(&tile_flags, &tiles, 'W'),
        tile_interactable_empty: tiles_flagged(&tile_flags, &tiles, 'e')
            .union(&interactable_empty_extra)
            .copied()
            .collect::<HashSet<_>>()
            .union(&tile_interactable_empty_bc_recipe(&recipes))
            .copied()
            .collect(),
        flags: map_in.flags,
        recipes,
        item_names: items,
        demands,
        tile_names: tiles,
        hand_count,
    };
    let mut serverdata = PrivateGamedata {
        initial_map,
        chef_spawn,
        customer_spawn,
        timer: config.timer.or(map_in.timer).unwrap_or(420.),
        book: Book::default(),
        score_baseline: map_in.score_baseline,
        entity_decls: entities,
        recipe_groups,
    };
    filter_unused_tiles_and_items(&mut data, &mut serverdata);

    if generate_book {
        serverdata.book = book(&data, &serverdata).context("within book")?;
    }

    Ok((data, serverdata))
}

fn tile_placeable_items(
    initial_map: &HashMap<IVec2, (Vec<TileIndex>, Option<ItemIndex>)>,
    recipes: &[Recipe],
    extiles: HashSet<TileIndex>,
) -> BTreeMap<TileIndex, HashSet<ItemIndex>> {
    let mut tile_placeable_items = BTreeMap::new();
    for tile in extiles {
        let initially_placed = initial_map
            .values()
            .filter(|(t, _)| t.contains(&tile))
            .flat_map(|(_, i)| i)
            .copied();
        let used_in_recipe = recipes
            .iter()
            .filter(|r| r.tile() == Some(tile))
            .flat_map(|e| e.inputs());
        tile_placeable_items.insert(tile, used_in_recipe.chain(initially_placed).collect());
    }
    tile_placeable_items
}

fn tile_interactable_empty_bc_recipe(recipes: &[Recipe]) -> HashSet<TileIndex> {
    recipes
        .iter()
        .filter(|r| r.inputs().next().is_none())
        .flat_map(|r| r.tile())
        .collect()
}

fn load_palette(data_path: &Path, map_in: &MapDecl) -> Result<HashMap<char, TileArgs>> {
    // Load palette
    let palettes =
        read_to_string(data_path.join("palettes.yaml")).context("Failed reading palettes")?;
    let palettes = serde_yaml_ng::from_str::<HashMap<String, HashMap<char, String>>>(&palettes)?;

    let mut raw_args = HashMap::new();
    for p in &map_in.use_palettes {
        raw_args.extend(
            palettes
                .get(p)
                .cloned()
                .ok_or(anyhow!("palette {p:?} is undefined"))?,
        )
    }
    raw_args.extend(map_in.tiles.clone());

    let mut palette = HashMap::new();
    for (k, raw) in raw_args {
        let mut toks = shlex::split(&raw).ok_or(anyhow!("tile stack quoting invalid"))?;
        toks.insert(0, "tilestack".to_string()); // exe name
        let args = TileArgs::try_parse_from(toks)
            .context(anyhow!("tile declaration for {k:?} is invalid"))?;
        palette.insert(k, args);
    }

    Ok(palette)
}

fn tiles_flagged(
    tile_flags: &HashMap<String, String>,
    tiles: &[String],
    flag: char,
) -> HashSet<TileIndex> {
    let mut out = HashSet::new();
    for (i, tile) in tiles.iter().enumerate() {
        let (kind, _params) = tile.split_once(":").unwrap_or((tile, ""));
        if let Some(flags) = tile_flags.get(kind)
            && flags.contains(flag)
        {
            out.insert(TileIndex(i));
        }
    }
    out
}
