"""Unit tests for the project/layer builders.

These exercise the pure-Python layer construction without needing a browser or
the bundled web app, so they run in plain CI.
"""

from __future__ import annotations

import json

import pytest

import geolibre
from geolibre import project

POINT_FC = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {"name": "A"},
            "geometry": {"type": "Point", "coordinates": [0, 0]},
        }
    ],
}


def test_build_empty_project_defaults():
    proj = project.build_empty_project()
    assert proj["version"] == project.PROJECT_VERSION
    assert proj["mapView"]["center"] == [-100, 40]
    assert proj["layers"] == []
    # Preferences must be a fresh copy, not the shared default.
    assert proj["preferences"] is not project.DEFAULT_PROJECT_PREFERENCES


def test_top_level_package_exports_headless_authoring_api(tmp_path):
    proj = project.build_empty_project()
    assert geolibre.basemap_catalog()
    assert geolibre.builtin_legend_names()
    assert geolibre.color_ramp_names()

    path = tmp_path / "map.geolibre.json"
    geolibre.save_project(path, proj)
    loaded = geolibre.load_project(path)
    assert geolibre.describe_project(loaded)["layerCount"] == 0


def test_build_empty_project_overrides():
    proj = project.build_empty_project(center=(10, 20), zoom=7, basemap_url="x")
    assert proj["mapView"]["center"] == [10.0, 20.0]
    assert proj["mapView"]["zoom"] == 7.0
    assert proj["basemapStyleUrl"] == "x"


def test_geojson_layer_inlines_data():
    layer = project.geojson_layer("Pts", POINT_FC, fillColor="#ff0000")
    assert layer["type"] == "geojson"
    assert layer["source"] == {"type": "geojson"}
    assert layer["geojson"] == POINT_FC
    assert layer["style"]["fillColor"] == "#ff0000"
    # Unspecified style fields fall back to the defaults.
    assert layer["style"]["strokeWidth"] == project.DEFAULT_LAYER_STYLE["strokeWidth"]


def test_geojson_layer_with_source_url():
    layer = project.geojson_layer("R", POINT_FC, source_url="https://e/x.geojson")
    assert layer["source"]["url"] == "https://e/x.geojson"
    assert layer["sourcePath"] == "https://e/x.geojson"


def test_tile_layer_shape():
    layer = project.tile_layer("OSM", "https://t/{z}/{x}/{y}.png")
    assert layer["type"] == "xyz"
    assert layer["source"]["type"] == "raster"
    assert layer["source"]["tiles"] == ["https://t/{z}/{x}/{y}.png"]
    assert layer["source"]["tileSize"] == 256
    assert layer["metadata"]["sourceKind"] == "xyz-url"


def test_cog_layer_restore_shape():
    layer = project.cog_layer("DEM", "https://e/dem.tif", bands=[1, 2, 3], colormap="terrain")
    assert layer["type"] == "cog"
    assert layer["source"] == {"type": "raster", "url": "https://e/dem.tif"}
    md = layer["metadata"]
    assert md["sourceKind"] == "maplibre-gl-raster"
    assert md["rasterSource"] == "url"
    assert md["externalNativeLayer"] is True
    assert md["nativeLayerIds"] == [layer["id"]]
    assert md["rasterState"]["bands"] == [1, 2, 3]
    assert md["rasterState"]["mode"] == "rgb"
    assert md["rasterState"]["colormap"] == "terrain"


def test_wms_layer_shape_and_url():
    layer = project.wms_layer(
        "NAIP",
        "https://example.com/wms",
        "layer:a,layer:b",
        styles="",
        image_format="image/png",
    )
    assert layer["type"] == "wms"
    src = layer["source"]
    assert src["type"] == "raster"
    assert src["tileSize"] == 256
    assert src["url"] == "https://example.com/wms"
    assert src["layers"] == "layer:a,layer:b"
    assert layer["metadata"]["service"] == "wms"
    tile = src["tiles"][0]
    # The bbox placeholder is preserved verbatim; other values are encoded.
    assert "BBOX={bbox-epsg-3857}" in tile
    assert "SERVICE=WMS" in tile
    assert "REQUEST=GetMap" in tile
    assert "LAYERS=layer%3Aa%2Clayer%3Ab" in tile
    assert "SRS=EPSG%3A3857" in tile
    assert "WIDTH=256" in tile


def test_wms_layer_version_1_3_0_uses_crs():
    # A 1.3.0-only server (e.g. the IGN Géoplateforme raster endpoint) rejects
    # a 1.1.1 GetMap, so the version must be honored and SRS renamed to CRS.
    layer = project.wms_layer("x", "https://e/wms", "a", version="1.3.0")
    tile = layer["source"]["tiles"][0]
    assert "VERSION=1.3.0" in tile
    assert "CRS=EPSG%3A3857" in tile
    assert "SRS=" not in tile
    assert "BBOX={bbox-epsg-3857}" in tile
    assert layer["source"]["version"] == "1.3.0"


def test_wms_layer_version_defaults_to_1_1_1():
    layer = project.wms_layer("x", "https://e/wms", "a")
    tile = layer["source"]["tiles"][0]
    assert "VERSION=1.1.1" in tile
    assert "SRS=EPSG%3A3857" in tile
    assert "CRS=" not in tile
    assert layer["source"]["version"] == "1.1.1"
    # An unrecognized version falls back rather than emitting a bad request.
    assert (
        project.wms_layer("x", "https://e/wms", "a", version="2.0")["source"]["version"] == "1.1.1"
    )
    # A None from an untyped caller must not raise.
    assert (
        project.wms_layer("x", "https://e/wms", "a", version=None)["source"]["version"] == "1.1.1"
    )


def test_wms_layer_transparent_false():
    layer = project.wms_layer("x", "https://e/wms", "a", transparent=False, tile_size=512)
    tile = layer["source"]["tiles"][0]
    assert "TRANSPARENT=FALSE" in tile
    assert "WIDTH=512" in tile
    assert layer["source"]["tileSize"] == 512


def test_append_query_keeps_fragment_after_query():
    # A "#fragment" must stay at the end so the query is not pushed past it.
    layer = project.wms_layer("x", "https://e/ows#v2", "a")
    tile = layer["source"]["tiles"][0]
    assert tile.startswith("https://e/ows?SERVICE=WMS")
    assert tile.endswith("#v2")
    assert tile.index("SERVICE=WMS") < tile.index("#v2")


def test_vector_tiles_layer_warns_on_both_source_layer_args():
    with pytest.warns(UserWarning, match="source_layer is ignored"):
        project.vector_tiles_layer("VT", "https://e/t.json", source_layers=["a"], source_layer="b")


def test_wmts_layer_shape():
    layer = project.wmts_layer("W", "https://t/{z}/{y}/{x}.png")
    assert layer["type"] == "wmts"
    assert layer["source"]["tiles"] == ["https://t/{z}/{y}/{x}.png"]
    assert layer["source"]["type"] == "raster"
    assert layer["metadata"]["service"] == "wmts"


def test_wfs_getfeature_url_v2():
    url = project.wfs_getfeature_url(
        "https://e/wfs", "topp:states", version="2.0.0", max_features=10
    )
    assert "typeNames=topp%3Astates" in url
    assert "count=10" in url
    assert "service=WFS" in url
    assert "outputFormat=application%2Fjson" in url


def test_wfs_getfeature_url_v1_uses_legacy_params():
    url = project.wfs_getfeature_url(
        "https://e/wfs?token=1", "ns:type", version="1.1.0", max_features=5
    )
    assert "typeName=ns%3Atype" in url
    assert "maxFeatures=5" in url
    # An existing query string is respected with an "&" separator.
    assert "wfs?token=1&" in url


def test_vector_layer_geojson_mode():
    layer = project.vector_layer("V", "https://e/data.parquet", data_format="parquet")
    assert layer["type"] == "geojson"
    assert layer["source"] == {"type": "geojson", "url": "https://e/data.parquet"}
    md = layer["metadata"]
    assert md["sourceKind"] == "maplibre-gl-vector"
    assert md["externalNativeLayer"] is True
    assert md["controlOwnsPaint"] is True
    assert md["vectorSource"] == "url"
    assert md["vectorState"] == {"renderMode": "geojson", "format": "parquet"}
    assert layer["sourcePath"] == "https://e/data.parquet"


def test_vector_layer_tiles_mode():
    layer = project.vector_layer("V", "https://e/data.fgb", render_mode="tiles")
    assert layer["type"] == "vector-tiles"
    assert layer["source"]["type"] == "vector"
    assert layer["metadata"]["vectorState"]["renderMode"] == "tiles"


def test_vector_layer_invalid_render_mode():
    with pytest.raises(ValueError, match="render_mode must be"):
        project.vector_layer("V", "https://e/x", render_mode="bogus")


def test_vector_tiles_layer_shape():
    layer = project.vector_tiles_layer("VT", "https://e/tiles.json", source_layers=["a", "b"])
    assert layer["type"] == "vector-tiles"
    assert layer["source"] == {
        "type": "vector",
        "url": "https://e/tiles.json",
        "sourceLayers": ["a", "b"],
    }


def test_pmtiles_layer_vector_shape():
    layer = project.pmtiles_layer("P", "https://e/tiles.pmtiles", source_layers=["roads"])
    assert layer["type"] == "pmtiles"
    assert layer["source"]["type"] == "vector"
    assert layer["source"]["tileType"] == "vector"
    assert layer["source"]["sourceId"] == layer["id"]
    md = layer["metadata"]
    assert md["sourceKind"] == "pmtiles-url"
    assert md["externalNativeLayer"] is True
    assert md["sourceLayers"] == ["roads"]
    # nativeLayerIds must be non-empty or isExternalNativeLayer() skips render.
    assert md["nativeLayerIds"] == [layer["id"]]


def test_pmtiles_layer_raster_shape():
    layer = project.pmtiles_layer("P", "https://e/r.pmtiles", tile_type="raster")
    assert layer["source"]["type"] == "raster"
    assert layer["metadata"]["tileType"] == "raster"
    # Raster placeholder matches ensurePMTilesExternalLayer's computed fallback.
    assert layer["metadata"]["nativeLayerIds"] == [f"{layer['id']}-raster"]


def test_pmtiles_layer_invalid_tile_type():
    with pytest.raises(ValueError, match="tile_type must be"):
        project.pmtiles_layer("P", "https://e/x.pmtiles", tile_type="bogus")


def test_three_d_tiles_layer_shape():
    layer = project.three_d_tiles_layer(
        "T", "https://e/tileset.json", altitude_offset=12, request_headers={"k": "v"}
    )
    assert layer["type"] == "3d-tiles"
    assert layer["source"]["url"] == "https://e/tileset.json"
    assert layer["source"]["altitudeOffset"] == 12
    assert layer["source"]["requestHeaders"] == {"k": "v"}
    md = layer["metadata"]
    assert md["sourceKind"] == "3d-tiles-url"
    assert md["externalNativeLayer"] is True
    assert md["customLayerType"] == "3d-tiles"
    assert md["nativeLayerIds"] == [layer["id"]]


def test_three_d_tiles_layer_omits_empty_headers():
    layer = project.three_d_tiles_layer("T", "https://e/tileset.json")
    assert "requestHeaders" not in layer["source"]


def test_video_layer_shape():
    corners = [[-122, 38], [-121, 38], [-121, 37], [-122, 37]]
    layer = project.video_layer("Vid", ["https://e/a.mp4", "https://e/a.webm"], corners)
    assert layer["type"] == "video"
    assert layer["source"]["type"] == "video"
    assert layer["source"]["urls"] == ["https://e/a.mp4", "https://e/a.webm"]
    assert layer["source"]["coordinates"] == corners
    assert layer["sourcePath"] == "https://e/a.mp4"
    assert layer["metadata"]["sourceKind"] == "video-url"
    # bounds is [west, south, east, north] of the four corners.
    assert layer["metadata"]["bounds"] == [-122, 37, -121, 38]


def test_video_layer_requires_url():
    with pytest.raises(ValueError, match="non-empty URL"):
        project.video_layer("Vid", [], [[0, 0], [1, 0], [1, 1], [0, 1]])


def test_video_layer_requires_four_corners():
    with pytest.raises(ValueError, match=r"four \[lng, lat\] corners"):
        project.video_layer("Vid", ["https://e/a.mp4"], [[0, 0], [1, 1]])


def test_video_layer_rejects_non_https():
    with pytest.raises(ValueError, match="https://"):
        project.video_layer("Vid", ["http://e/a.mp4"], [[0, 0], [1, 0], [1, 1], [0, 1]])


def test_video_layer_rejects_non_string_url():
    with pytest.raises(ValueError, match="non-empty string"):
        project.video_layer("Vid", ["https://e/a.mp4", None], [[0, 0], [1, 0], [1, 1], [0, 1]])


def test_load_featurecollection_passthrough():
    assert project.load_featurecollection(POINT_FC) is POINT_FC


def test_load_featurecollection_wraps_feature():
    feature = POINT_FC["features"][0]
    fc = project.load_featurecollection(feature)
    assert fc["type"] == "FeatureCollection"
    assert fc["features"] == [feature]


def test_load_featurecollection_wraps_geometry():
    fc = project.load_featurecollection({"type": "Point", "coordinates": [1, 2]})
    assert fc["features"][0]["geometry"]["coordinates"] == [1, 2]


def test_load_featurecollection_from_json_string():
    fc = project.load_featurecollection(json.dumps(POINT_FC))
    assert fc["features"][0]["properties"]["name"] == "A"


def test_load_featurecollection_from_file(tmp_path):
    path = tmp_path / "pts.geojson"
    path.write_text(json.dumps(POINT_FC), encoding="utf-8")
    fc = project.load_featurecollection(str(path))
    assert fc == POINT_FC


def test_load_featurecollection_geo_interface():
    class Fake:
        __geo_interface__ = POINT_FC

    assert project.load_featurecollection(Fake()) == POINT_FC


def test_load_featurecollection_invalid():
    with pytest.raises(ValueError):
        project.load_featurecollection(42)
