//! # Tilemake RS
//!
//! Tilemake-RS is a library that converts OpenStreetMap
//! data (`osm.pbf`) to vector tiles (pmtiles).
//!
//! The name is an oxidized homage to [tilemaker](https://tilemaker.org),
//! from which many ideas are borrowed.
//!
//! The goal of Tilemake-RS is to be fully compatible with the API (Lua
//! and config files) of tilemaker.
//!
//! You can use `tilemake_rs` as a library or
//! [tilemake_rs_cli](https://crates.io/crates/tilemake_rs_cli) in the
//! terminal.
//!
//! ## Lua API
//!
//! When implementing a custom Lua file you should use this structure:
//!
//! ```lua
//! -- called for every node
//! function node_function(node)
//!   -- find the value of the "power" attribute of the node
//!   local power = Find("power")
//!   if power then
//!     -- add the current node to the "power" layer
//!     Layer("power")
//!   end
//! end
//! ```
//!
//! ### Lua entrypoint
//!
//! The following table lists the entrypoint functions. They will be excecuted for
//! each feature type.
//!
//! | Function | Description |
//! |----------|-------------|
//! | `node_function(node)` | entrypoint called for each node. |
//!
//! ### Additional Lua functions
//!
//! | Function | Description |
//! |----------|-------------|
//! | `Find(key)` | Returns the value for the given tag key, or nil if not found. |
//! | `AllTags()` | Returns a Lua table of all tag key-value pairs for the current node. |
//! | `Layer(layer_name)` | Adds the current node to the specified layer. |
//! | `Attribute(key)` | Adds an attribute with the given key and empty string value to the current node. |
//! | `Attribute(key, value)` | Adds an attribute with the given key and value to the current node. |
//! | `Id()` | Returns the numeric OSM node ID of the current node. |
//! | `OsmType()` | Returns the OSM type of the current object (e.g., "node"). |
//! | `Holds(key)` | Returns true if the node has the specified tag key. |
//! | `Holds(key, value)` | Returns true if the node has the specified tag key with the specified value. |
//! | `node:find(key)` | Method on node object. Returns the value for the given tag key. |
//! | `node:get_id()` | Method on node object. Returns the numeric OSM node ID. |
//! | `node:id()` | Method on node object. Returns the numeric OSM node ID. |
//! | `node:osm_type()` | Method on node object. Returns the OSM type (e.g., "node"). |
//! | `node:holds(key)` | Method on node object. Returns true if the node has the specified tag key. |
//! | `node:holds(key, value)` | Method on node object. Returns true if the node has the specified tag key with the specified value. |
//! | `node:get_lat()` | Method on node object. Returns the x pixel coordinate in the tile. |
//! | `node:get_lon()` | Method on node object. Returns the y pixel coordinate in the tile. |

use geozero::mvt::tile::{Feature, Layer, Value};
use geozero::mvt::{Message, Tile, TileValue};
use mlua::Lua;
use osmpbfreader::OsmObj::Node;
use osmpbfreader::{NodeId, OsmId, OsmObj, Tags};
use pmtiles::{PmTilesWriter, TileCoord, TileType};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::f64::consts::PI;
use std::rc::Rc;

mod config;
mod input;
mod lua;

pub use config::Config;
pub use input::InputArguments;
use lua::{LuaProcess, LuaState, OsmNode, call_node_function, load_lua_script};

static EXTENT: u32 = 4096;
static FEATURE_ID_NODE: i32 = 1;

fn zigzag(n: i32) -> u32 {
    ((n << 1) ^ (n >> 31)) as u32
}

#[derive(Debug, PartialEq)]
struct TilePointInput {
    lat: f64,
    lon: f64,
    zoom: Zoom,
    extent: u32,
}

impl TilePointInput {
    fn new_from_decimicro(lat: i32, lon: i32, zoom: Zoom, extent: u32) -> Self {
        Self {
            lat: lat as f64 / 10_000_000.0,
            lon: lon as f64 / 10_000_000.0,
            zoom,
            extent,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
struct TilePoint {
    pub tile_x: u32,
    pub tile_y: u32,
    pub x: u32,
    pub y: u32,
}

impl TilePoint {
    fn new(tile_x: u32, tile_y: u32, x: u32, y: u32) -> Self {
        Self {
            tile_x,
            tile_y,
            x,
            y,
        }
    }
}

fn lonlat_to_tile_and_pixel(input: TilePointInput) -> TilePoint {
    let n = 2f64.powi(input.zoom as i32);

    let x = (input.lon + 180.0) / 360.0 * n;

    let lat_rad = input.lat.to_radians();
    let y = (1.0 - ((lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / PI)) / 2.0 * n;

    let tile_x = x.floor() as u32;
    let tile_y = y.floor() as u32;

    let pixel_x = ((x - tile_x as f64) * input.extent as f64) as u32;
    let pixel_y = ((y - tile_y as f64) * input.extent as f64) as u32;

    TilePoint::new(tile_x, tile_y, pixel_x, pixel_y)
}

type NodeMap = HashMap<NodeId, TilePoint>;

type Zoom = u8;

struct NodeZoomLevelMap {
    node_map: HashMap<Zoom, NodeMap>,
}

impl NodeZoomLevelMap {
    fn new() -> Self {
        Self {
            node_map: HashMap::new(),
        }
    }

    fn insert(&mut self, zoom: Zoom, node_id: NodeId, tile_point: TilePoint) {
        match self.node_map.get_mut(&zoom) {
            Some(node_map) => {
                node_map.insert(node_id, tile_point);
            }
            None => {
                let mut node_map = HashMap::new();
                node_map.insert(node_id, tile_point);
                self.node_map.insert(zoom, node_map);
            }
        };
    }

    fn find(&self, z: &Zoom, id: &NodeId) -> &TilePoint {
        let zoom_map = self.node_map.get(z).unwrap();
        zoom_map.get(id).unwrap()
    }
}

#[derive(Clone)]
struct NodeData {
    id: i64,
    tile_point: TilePoint,
}

impl NodeData {
    fn new(id: i64, tile_point: TilePoint) -> Self {
        Self { id, tile_point }
    }
}

struct NodeZoomLayerMap {
    map: HashMap<Zoom, HashMap<String, Vec<NodeData>>>,
    attribute_map: HashMap<Zoom, HashMap<i64, HashMap<String, String>>>,
    config: Option<Config>,
}

impl NodeZoomLayerMap {
    fn new_with_config(config: Option<Config>) -> Self {
        Self {
            map: HashMap::new(),
            attribute_map: HashMap::new(),
            config,
        }
    }

    fn insert(&mut self, node: &OsmNode, layer_name: String) {
        // Check if layer exists in config and if zoom is within layer's range
        if let Some(config) = &self.config {
            if let Some(layer) = config.layers.get(&layer_name) {
                if node.zoom < layer.minzoom || node.zoom > layer.maxzoom {
                    // Skip this node - it's outside the layer's zoom range
                    return;
                }
            }
        }
        // If no config or layer not found in config, allow the insert
        // (this maintains backward compatibility)
        self.map
            .entry(node.zoom)
            .or_default()
            .entry(layer_name.clone())
            .or_default()
            .push(NodeData::new(node.id, node.tile_point.clone()));
    }

    fn add_attribute(&mut self, node: &OsmNode, key: &String, value: &String) {
        self.attribute_map
            .entry(node.zoom)
            .or_default()
            .entry(node.id)
            .or_default()
            .entry(key.clone())
            .or_insert(value.clone());
    }
}

fn osm_obj_tags_to_hashmap(tags: &Tags) -> HashMap<String, String> {
    let mut tag_map = HashMap::new();
    for (key, value) in tags.iter() {
        tag_map.insert(key.to_string().clone(), value.to_string().clone());
    }
    tag_map
}

fn build_node_map(
    input_arguments: &InputArguments,
    objs: &BTreeMap<OsmId, OsmObj>,
) -> NodeZoomLevelMap {
    let mut node_map = NodeZoomLevelMap::new();
    for zoom in input_arguments.zoom_iter() {
        println!("z {:?}", &zoom);
        for (_id, obj) in objs {
            if let Node(node) = obj {
                // What is more efficient?
                // - insert decimicro and only convert if node is in layer
                // - convert all nodes here
                let tile_point = lonlat_to_tile_and_pixel(TilePointInput::new_from_decimicro(
                    node.decimicro_lat,
                    node.decimicro_lon,
                    zoom,
                    EXTENT,
                ));
                node_map.insert(zoom, node.id, tile_point);
            }
        }
    }
    node_map
}

fn process_nodes(
    input_arguments: &InputArguments,
    node_map: &NodeZoomLevelMap,
    lua_process: &LuaProcess,
    objs: &BTreeMap<OsmId, OsmObj>,
    layered_nodes: Rc<RefCell<NodeZoomLayerMap>>,
) -> AggregatedNodes {
    for zoom in input_arguments.zoom_iter() {
        for (_id, obj) in objs {
            if let Node(node) = obj {
                let tile_point = node_map.find(&zoom, &node.id);
                match lua_process {
                    LuaProcess::Some(lua_state) => {
                        let _res = call_node_function(
                            &lua_state.lua,
                            &lua_state.node_function,
                            OsmNode::new(
                                node.id.0,
                                zoom,
                                tile_point.clone(),
                                osm_obj_tags_to_hashmap(obj.tags()),
                            ),
                            layered_nodes.clone(),
                        );
                    }
                    LuaProcess::None => {
                        layered_nodes.clone().borrow_mut().insert(
                            &OsmNode::new(
                                node.id.0,
                                zoom,
                                tile_point.clone(),
                                osm_obj_tags_to_hashmap(obj.tags()),
                            ),
                            "default".to_string(),
                        );
                    }
                }
            }
        }
    }

    aggregate_nodes(layered_nodes.clone())
}

fn get_lua_process(input_arguments: &InputArguments) -> LuaProcess {
    match &input_arguments.process {
        Some(code) => {
            let lua = Lua::new();
            let node_function = load_lua_script(&lua, code).unwrap();
            LuaProcess::Some(LuaState { lua, node_function })
        }
        None => LuaProcess::None,
    }
}

fn aggregate_nodes(layered_nodes: Rc<RefCell<NodeZoomLayerMap>>) -> AggregatedNodes {
    let mut aggregated = AggregatedNodes::new();
    let binding = layered_nodes.clone();
    let layered_node_ref = binding.borrow();
    println!("aggreate nodes");

    for (zoom, zoom_layers) in layered_node_ref.map.iter() {
        println!("iter zoom: {:?}", zoom);
        for (layer_name, node_data) in zoom_layers {
            for node in node_data {
                // Get attributes for the node
                let attributes = layered_node_ref
                    .attribute_map
                    .get(zoom)
                    .and_then(|zoom_attrs| zoom_attrs.get(&node.id))
                    .cloned()
                    .unwrap_or_default();
                println!("{:?} {:?}", attributes, node.id);
                aggregated.insert(zoom, layer_name, node, attributes);
            }
        }
    }

    aggregated
}

/// Runs the processing pipeline based on the input arguments
///
/// ```
/// # #[cfg(feature = "serde")]
/// # {
/// use tilemake_rs::{Config, InputArguments, process};
/// use std::path::Path;
/// use std::fs::File;
/// use std::fs;
///
/// let input_file = File::open(Path::new("tests/data/azores-260318-power.osm.pbf")).unwrap();
/// let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
/// let mut input_arguments = InputArguments::new(input_file, output_file);
///
/// let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
/// let config = Config::from_json(&config_string).unwrap();
/// input_arguments.set_config(Some(config));
///
/// let process_string = fs::read_to_string(Path::new("tests/data/azores.lua")).unwrap();
/// input_arguments.set_process(Some(process_string));
///
/// process(&input_arguments);
/// # }
/// ```
pub fn process(input_arguments: &InputArguments) {
    let mut writer = PmTilesWriter::new(TileType::Mvt)
        .create(&input_arguments.output_file)
        .unwrap();

    let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
    let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
    let node_map = build_node_map(input_arguments, &objs);
    let lua_process = get_lua_process(input_arguments);

    let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
    let aggregated = process_nodes(
        input_arguments,
        &node_map,
        &lua_process,
        &objs,
        layered_nodes.clone(),
    );

    for (_zoom, zoom_tiles) in aggregated.map {
        for (tile_coord, layers) in zoom_tiles {
            for (_layer_name, layer_data) in layers {
                let mut tile = Tile::default();
                tile.layers.push(layer_data.clone());
                let tile_bytes = tile.encode_to_vec();
                writer.add_tile(tile_coord, &tile_bytes).unwrap();
            }
        }
    }

    println!("Write layer");
    writer.finalize().unwrap();
}

struct AggregatedNodes {
    map: HashMap<Zoom, HashMap<TileCoord, HashMap<String, Layer>>>,
}

impl AggregatedNodes {
    fn new() -> Self {
        Self {
            map: HashMap::new(),
        }
    }

    fn insert(
        &mut self,
        zoom: &Zoom,
        layer_name: &String,
        node: &NodeData,
        attributes: HashMap<String, String>,
    ) {
        let layer = self
            .map
            .entry(*zoom)
            .or_default()
            .entry(TileCoord::new(*zoom, node.tile_point.tile_x, node.tile_point.tile_y).unwrap())
            .or_default()
            .entry(layer_name.clone())
            .or_insert_with(|| {
                let mut layer = Layer::default();
                layer.name = layer_name.clone();
                layer.version = 2;
                layer.extent = Some(4096);
                layer
            });
        let mut feature = Feature::default();
        feature.id = Some(1u64);
        feature.r#type = Some(1); // POINT
        let move_to = 1 | (1 << 3);

        // Convert u32 to i32 before calling zigzag
        let x_i32 = node.tile_point.x as i32;
        let y_i32 = node.tile_point.y as i32;

        feature.geometry = vec![move_to, zigzag(x_i32), zigzag(y_i32)];

        // Track indices for keys and values to avoid duplicates
        let mut key_indices: HashMap<String, u32> = HashMap::new();
        let mut value_indices: HashMap<String, u32> = HashMap::new();

        for (key, value) in attributes {
            // Get or insert key index
            let key_idx = *key_indices.entry(key.clone()).or_insert_with(|| {
                let idx = layer.keys.len() as u32;
                layer.keys.push(key.to_string());
                idx
            });

            // Get or insert value index
            let val_idx = *value_indices.entry(value.clone()).or_insert_with(|| {
                let idx = layer.values.len() as u32;
                layer.values.push(Value::from(TileValue::Str(value)));
                idx
            });

            // Push the indices to the feature's tags
            feature.tags.push(key_idx);
            feature.tags.push(val_idx);
        }

        layer.features.push(feature);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rstest::rstest;
    use std::fs;
    use std::fs::File;
    use std::path::Path;

    #[rstest]
    #[case(
        TilePointInput::new_from_decimicro(0, 0, 0, 4096),
        TilePoint::new(0, 0, 2048, 2048)
    )]
    #[case(
        TilePointInput::new_from_decimicro(0, 0, 1, 4096),
        TilePoint::new(1, 1, 0, 0)
    )]
    #[case(
        TilePointInput::new_from_decimicro(0, 0, 2, 4096),
        TilePoint::new(2, 2, 0, 0)
    )]
    #[case(
        TilePointInput::new_from_decimicro(0, 1_800_000_000, 0, 4096),
        TilePoint::new(1, 0, 0, 2048)
    )]
    #[case(
        TilePointInput::new_from_decimicro(437452160, 74283760, 0, 4096),
        TilePoint::new(0, 0, 2132, 1493)
    )]
    fn point_micro_test(#[case] input: TilePointInput, #[case] expected: TilePoint) {
        assert_eq!(expected, lonlat_to_tile_and_pixel(input))
    }

    #[rstest]
    #[case("azores.lua", 3000, 0)]
    #[case("azores-generator.lua", 140, 0)]
    #[case("azores_with_attribute.lua", 3000, 2)]
    #[cfg(feature = "serde")]
    fn test_with_lua_file(
        #[case] process_file: String,
        #[case] features: usize,
        #[case] attributes: usize,
    ) {
        let input_file = File::open(Path::new("tests/data/azores-260318-power.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        let process_string =
            fs::read_to_string(Path::new(&format!("tests/data/{}", process_file))).unwrap();
        input_arguments.set_process(Some(process_string));
        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 3_000);
        assert_eq!(node_map.node_map[&1].len(), 3_000);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        let keys: Vec<_> = zoom_level_0.keys().cloned().collect();
        assert_eq!(keys.len(), 1);
        let power_layer_0 = &zoom_level_0["power"];
        assert_eq!(power_layer_0.len(), features);
        assert_eq!(
            layered_nodes.clone().borrow().attribute_map.len(),
            attributes
        );
        if layered_nodes.clone().borrow().attribute_map.len() > 0 {
            let attr_map = layered_nodes.clone().borrow().attribute_map.clone();
            let first_zoom = attr_map.keys().next().unwrap();
            let first_zoom_attrs = &attr_map[first_zoom];
            assert!(!first_zoom_attrs.is_empty());
            let first_node_id = first_zoom_attrs.keys().next().unwrap();
            let node_attrs = &first_zoom_attrs[first_node_id];
            for (key, value) in node_attrs {
                assert_eq!(key, "power");
                assert_eq!(value, "");
            }
        }
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_single_point_feature() {
        let input_file = File::open(Path::new("tests/data/single-point.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        let process_string = fs::read_to_string(Path::new("tests/data/azores.lua")).unwrap();
        input_arguments.set_process(Some(process_string));
        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 1);
        assert_eq!(node_map.node_map[&1].len(), 1);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        let layer_keys: Vec<_> = zoom_level_0.keys().cloned().collect();
        assert_eq!(layer_keys.len(), 1);
        assert_eq!(layer_keys[0], "power");
        let power_nodes = &zoom_level_0["power"];
        assert_eq!(power_nodes.len(), 1);
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_single_point_feature_with_attributes() {
        let input_file = File::open(Path::new("tests/data/single-point.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output-with-attrs.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        // Use all-tags.lua to add all 12 OSM tags as attributes
        let process_string = fs::read_to_string(Path::new("tests/data/all-tags.lua")).unwrap();
        input_arguments.set_process(Some(process_string));

        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 1);
        assert_eq!(node_map.node_map[&1].len(), 1);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        let aggregated = process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );

        // Check intermediate data structures (layered_nodes) have attributes in ALL zoom levels
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);

        // Check zoom level 0
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        assert!(zoom_level_0.contains_key("power"));
        assert_eq!(zoom_level_0["power"].len(), 1);

        // Check zoom level 1
        let zoom_level_1 = layered_nodes.clone().borrow().map[&1].clone();
        assert_eq!(zoom_level_1.len(), 1);
        assert!(zoom_level_1.contains_key("power"));
        assert_eq!(zoom_level_1["power"].len(), 1);

        // Check that attributes are present in ALL zoom levels
        assert_eq!(layered_nodes.clone().borrow().attribute_map.len(), 2);
        let attr_map = layered_nodes.clone().borrow().attribute_map.clone();

        // All 12 expected tags from the OSM node
        let expected_tags: Vec<(&str, &str)> = vec![
            ("generator:method", "wind_turbine"),
            ("generator:output:electricity", "900 kW"),
            ("generator:output:electricity:voltage", "30000"),
            ("generator:source", "wind"),
            ("height:hub", "55"),
            ("manufacturer", "ENERCON"),
            ("model", "E-44"),
            ("operator", "Eletricidade dos Açores"),
            ("operator:short", "EDA"),
            ("operator:wikidata", "Q131463417"),
            ("power", "generator"),
            ("rotor:diameter", "44"),
        ];

        // Verify zoom level 0 has all attributes
        assert!(attr_map.contains_key(&0));
        let zoom_0_attrs = &attr_map[&0];
        assert!(!zoom_0_attrs.is_empty());
        let zoom_0_node_ids: Vec<_> = zoom_0_attrs.keys().cloned().collect();
        assert_eq!(zoom_0_node_ids.len(), 1);
        let zoom_0_node_id = zoom_0_node_ids[0];
        let zoom_0_node_attrs = &zoom_0_attrs[&zoom_0_node_id];
        assert_eq!(zoom_0_node_attrs.len(), expected_tags.len());
        for (key, expected_value) in &expected_tags {
            assert_eq!(
                zoom_0_node_attrs.get(*key),
                Some(&expected_value.to_string()),
                "Missing or incorrect tag: {}",
                key
            );
        }

        // Verify zoom level 1 has all attributes
        assert!(attr_map.contains_key(&1));
        let zoom_1_attrs = &attr_map[&1];
        assert!(!zoom_1_attrs.is_empty());
        let zoom_1_node_ids: Vec<_> = zoom_1_attrs.keys().cloned().collect();
        assert_eq!(zoom_1_node_ids.len(), 1);
        let zoom_1_node_id = zoom_1_node_ids[0];
        let zoom_1_node_attrs = &zoom_1_attrs[&zoom_1_node_id];
        assert_eq!(zoom_1_node_attrs.len(), expected_tags.len());
        for (key, expected_value) in &expected_tags {
            assert_eq!(
                zoom_1_node_attrs.get(*key),
                Some(&expected_value.to_string()),
                "Missing or incorrect tag at zoom 1: {}",
                key
            );
        }

        // Check aggregated data has attributes in ALL zoom levels
        assert_eq!(aggregated.map.len(), 2);

        // Check zoom level 0 aggregated data
        let zoom_0_layers = &aggregated.map[&0];
        assert_eq!(zoom_0_layers.len(), 1);
        let tile_coord_0 = TileCoord::new(0, 0, 0).unwrap();
        assert!(zoom_0_layers.contains_key(&tile_coord_0));
        let layers_0 = &zoom_0_layers[&tile_coord_0];
        assert_eq!(layers_0.len(), 1);
        let power_layer_0 = &layers_0["power"];
        assert_eq!(power_layer_0.name, "power");
        // All 12 tags should be in keys
        assert_eq!(power_layer_0.keys.len(), expected_tags.len());
        // All 12 values should be present
        assert_eq!(power_layer_0.values.len(), expected_tags.len());
        assert_eq!(power_layer_0.features.len(), 1);
        // Feature should have 2 * 12 = 24 tags (key_idx + val_idx for each tag)
        assert_eq!(
            power_layer_0.features[0].tags.len(),
            expected_tags.len() * 2
        );

        // Check zoom level 1 aggregated data
        let zoom_1_layers = &aggregated.map[&1];
        assert_eq!(zoom_1_layers.len(), 1);
        let tile_coord_1 = zoom_1_layers.keys().next().unwrap();
        let layers_1 = &zoom_1_layers[tile_coord_1];
        assert_eq!(layers_1.len(), 1);
        let power_layer_1 = &layers_1["power"];
        assert_eq!(power_layer_1.name, "power");
        assert_eq!(power_layer_1.keys.len(), expected_tags.len());
        assert_eq!(power_layer_1.values.len(), expected_tags.len());
        assert_eq!(power_layer_1.features.len(), 1);
        assert_eq!(
            power_layer_1.features[0].tags.len(),
            expected_tags.len() * 2
        );
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_without_lua_file() {
        let input_file = File::open(Path::new("tests/data/azores-260318-power.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 3_000);
        assert_eq!(node_map.node_map[&1].len(), 3_000);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        let aggregated = process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        let default_layer_0 = &zoom_level_0["default"];
        assert_eq!(default_layer_0.len(), 3000);
        assert_eq!(default_layer_0[0].id, 871_925_534);
        assert_eq!(
            default_layer_0[0].tile_point,
            TilePoint::new(0, 0, 1729, 1564)
        );
        assert_eq!(layered_nodes.clone().borrow().attribute_map.len(), 0);
        for (_z, layers) in aggregated.map.iter() {}
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_id_function() {
        let input_file = File::open(Path::new("tests/data/single-point.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        let process_string = fs::read_to_string(Path::new("tests/data/test-id.lua")).unwrap();
        input_arguments.set_process(Some(process_string));

        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 1);
        assert_eq!(node_map.node_map[&1].len(), 1);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );

        // Check that the test_id layer was created with the node
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        assert!(zoom_level_0.contains_key("test_id"));
        let test_id_nodes = &zoom_level_0["test_id"];
        assert_eq!(test_id_nodes.len(), 1);

        // Check that the node_id attribute was set
        assert_eq!(layered_nodes.clone().borrow().attribute_map.len(), 2);
        let attr_map = layered_nodes.clone().borrow().attribute_map.clone();
        assert!(attr_map.contains_key(&0));
        let zoom_0_attrs = &attr_map[&0];
        assert!(!zoom_0_attrs.is_empty());
        let first_node_id = zoom_0_attrs.keys().next().unwrap();
        let node_attrs = &zoom_0_attrs[first_node_id];
        assert!(node_attrs.contains_key("node_id"));
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_osm_type_function() {
        let input_file = File::open(Path::new("tests/data/single-point.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        let process_string = fs::read_to_string(Path::new("tests/data/test-osm-type.lua")).unwrap();
        input_arguments.set_process(Some(process_string));

        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 1);
        assert_eq!(node_map.node_map[&1].len(), 1);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );

        // Check that the test_osm_type layer was created with the node
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        assert!(zoom_level_0.contains_key("test_osm_type"));
        let test_osm_type_nodes = &zoom_level_0["test_osm_type"];
        assert_eq!(test_osm_type_nodes.len(), 1);

        // Check that the osm_type attribute was set to "node"
        assert_eq!(layered_nodes.clone().borrow().attribute_map.len(), 2);
        let attr_map = layered_nodes.clone().borrow().attribute_map.clone();
        assert!(attr_map.contains_key(&0));
        let zoom_0_attrs = &attr_map[&0];
        assert!(!zoom_0_attrs.is_empty());
        let first_node_id = zoom_0_attrs.keys().next().unwrap();
        let node_attrs = &zoom_0_attrs[first_node_id];
        assert!(node_attrs.contains_key("osm_type"));
        assert_eq!(node_attrs.get("osm_type"), Some(&"node".to_string()));
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_holds_function() {
        let input_file = File::open(Path::new("tests/data/single-point.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        let config_string = fs::read_to_string(Path::new("tests/data/azores-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        let process_string = fs::read_to_string(Path::new("tests/data/test-holds.lua")).unwrap();
        input_arguments.set_process(Some(process_string));

        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        assert_eq!(node_map.node_map.len(), 2);
        assert_eq!(node_map.node_map[&0].len(), 1);
        assert_eq!(node_map.node_map[&1].len(), 1);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );

        // Check that the test_holds layer was created with the node
        assert_eq!(layered_nodes.clone().borrow().map.len(), 2);
        let zoom_level_0 = layered_nodes.clone().borrow().map[&0].clone();
        assert_eq!(zoom_level_0.len(), 1);
        assert!(zoom_level_0.contains_key("test_holds"));
        let test_holds_nodes = &zoom_level_0["test_holds"];
        assert_eq!(test_holds_nodes.len(), 1);

        // Check that the attributes were set correctly
        assert_eq!(layered_nodes.clone().borrow().attribute_map.len(), 2);
        let attr_map = layered_nodes.clone().borrow().attribute_map.clone();
        assert!(attr_map.contains_key(&0));
        let zoom_0_attrs = &attr_map[&0];
        assert!(!zoom_0_attrs.is_empty());
        let first_node_id = zoom_0_attrs.keys().next().unwrap();
        let node_attrs = &zoom_0_attrs[first_node_id];
        assert!(node_attrs.contains_key("has_power"));
        assert!(node_attrs.contains_key("is_generator"));
        assert_eq!(node_attrs.get("has_power"), Some(&"true".to_string()));
        assert_eq!(node_attrs.get("is_generator"), Some(&"true".to_string()));
    }

    #[test]
    fn test_add_attribute() {
        let mut node_map = NodeZoomLayerMap::new_with_config(None);

        // Create test nodes
        let node1 = OsmNode::new(1, 0, TilePoint::new(0, 0, 100, 200), HashMap::new());
        let node2 = OsmNode::new(2, 0, TilePoint::new(0, 0, 300, 400), HashMap::new());

        // Add attributes to nodes
        node_map.add_attribute(&node1, &"key1".to_string(), &"value1".to_string());
        node_map.add_attribute(&node1, &"key2".to_string(), &"value2".to_string());
        node_map.add_attribute(&node2, &"key1".to_string(), &"value3".to_string());
        node_map.add_attribute(&node2, &"key2".to_string(), &"value4".to_string());

        // Check that both nodes have the correct attributes
        let attr_map = &node_map.attribute_map;
        assert_eq!(attr_map.len(), 1); // Only zoom level 0

        let zoom_attrs = &attr_map[&0];
        assert_eq!(zoom_attrs.len(), 2); // Two nodes

        // Check node1 attributes
        let node1_attrs = &zoom_attrs[&1];
        assert_eq!(node1_attrs.len(), 2);
        assert_eq!(node1_attrs.get("key1"), Some(&"value1".to_string()));
        assert_eq!(node1_attrs.get("key2"), Some(&"value2".to_string()));

        // Check node2 attributes
        let node2_attrs = &zoom_attrs[&2];
        assert_eq!(node2_attrs.len(), 2);
        assert_eq!(node2_attrs.get("key1"), Some(&"value3".to_string()));
        assert_eq!(node2_attrs.get("key2"), Some(&"value4".to_string()));
    }

    #[test]
    #[cfg(feature = "serde")]
    fn test_layer_minmax_zoom_filtering() {
        // This test verifies that layer minzoom/maxzoom settings correctly filter features
        let input_file = File::open(Path::new("tests/data/azores-260318-power.osm.pbf")).unwrap();
        let output_file = File::create(Path::new("tests/data/output.pmtiles")).unwrap();
        let mut input_arguments = InputArguments::new(input_file, output_file);

        // Config with:
        // - settings: process zoom levels 0-14
        // - "roads" layer: minzoom:12, maxzoom:14 (should only appear at zooms 12-14)
        // - "power" layer: minzoom:0, maxzoom:1 (should only appear at zooms 0-1)
        let config_string = fs::read_to_string(Path::new("tests/data/azores-zoom-test-config.json")).unwrap();
        let config = Config::from_json(&config_string).unwrap();
        input_arguments.set_config(Some(config));

        // Lua script assigns power features to "roads" layer
        let process_string = fs::read_to_string(Path::new("tests/data/azores-zoom-test.lua")).unwrap();
        input_arguments.set_process(Some(process_string));

        let mut pbf = osmpbfreader::OsmPbfReader::new(&input_arguments.input_file);
        let objs = pbf.get_objs_and_deps(|_obj| true).unwrap();
        let node_map = build_node_map(&input_arguments, &objs);
        // With settings.minzoom:0 and settings.maxzoom:14, we should have 15 zoom levels (0-14)
        assert_eq!(node_map.node_map.len(), 15);
        // Each zoom level should have the same 3000 power nodes
        assert_eq!(node_map.node_map[&0].len(), 3_000);
        assert_eq!(node_map.node_map[&1].len(), 3_000);

        let lua_process = get_lua_process(&input_arguments);
        let layered_nodes = Rc::new(RefCell::new(NodeZoomLayerMap::new_with_config(input_arguments.config.clone())));
        process_nodes(
            &input_arguments,
            &node_map,
            &lua_process,
            &objs,
            layered_nodes.clone(),
        );

        // Verify that layered_nodes only has zoom levels 12-14 (where roads layer is active)
        let binding = layered_nodes.clone();
        let layered_nodes_ref = binding.borrow();
        assert_eq!(layered_nodes_ref.map.len(), 3, "Should only have 3 zoom levels (12, 13, 14)");

        // Check zoom level 0: should not exist in layered_nodes at all
        assert!(!layered_nodes_ref.map.contains_key(&0), "zoom 0 should not be in layered_nodes");

        // Check zoom level 1: should not exist in layered_nodes at all
        assert!(!layered_nodes_ref.map.contains_key(&1), "zoom 1 should not be in layered_nodes");

        // Check zoom level 12: "roads" layer SHOULD have features (within minzoom:12, maxzoom:14)
        let zoom_level_12 = layered_nodes.clone().borrow().map[&12].clone();
        assert!(zoom_level_12.contains_key("roads"), "roads layer should exist at zoom 12");
        let roads_nodes_12 = &zoom_level_12["roads"];
        // All 3000 power nodes should be in the roads layer at zoom 12
        assert_eq!(roads_nodes_12.len(), 3_000, "roads layer should have 3000 features at zoom 12");

        // Check zoom level 14: "roads" layer SHOULD have features (at maxzoom:14)
        let zoom_level_14 = layered_nodes.clone().borrow().map[&14].clone();
        assert!(zoom_level_14.contains_key("roads"), "roads layer should exist at zoom 14");
        let roads_nodes_14 = &zoom_level_14["roads"];
        assert_eq!(roads_nodes_14.len(), 3_000, "roads layer should have 3000 features at zoom 14");

        // Check zoom level 15: should not be processed (settings.maxzoom:14)
        // This is already verified by node_map.node_map.len() == 15 (0-14)
    }
}
