import hashlib
import json
import os
import traceback

import unreal


PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
MAP_ROOT = "/Game/HyperTwistTraining/Maps"
MATERIAL_ROOT = "/Game/HyperTwistTraining/Materials"
VERTEX_COLOR_MATERIAL_PATH = f"{MATERIAL_ROOT}/M_HT_ProjectionVertexColor"
SAFE_TRANSIENT_LEVEL_PATH = "/Engine/Maps/Entry"
AUTHOR_TAG = "HyperTwistVirtual4DTrainingMap"
MANIFEST_RELATIVE_PATH = os.path.join(
    "docs",
    "generated",
    "virtual_4d_training_maps",
    "virtual_4d_dedicated_map_manifest.json",
)
MANIFEST_ABSOLUTE_PATH = os.path.join(PROJECT_ROOT, MANIFEST_RELATIVE_PATH)
AUTHORING_RECEIPT_DIRECTORY = os.path.join(
    PROJECT_ROOT,
    "UnrealHyperTwist",
    "Saved",
    "HyperTwistMapAuthoring",
)

MAP_CONFIGS = (
    {
        "order": 2,
        "map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_2x2x2x2Training",
        "game_mode_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistMelindaProjectionGameMode"
        ),
        "projection_actor_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistMelindaProjectionActor"
        ),
        "runtime_profile": "melinda-cell-first-exact-runtime-v1",
    },
    {
        "order": 3,
        "map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_3x3x3x3Training",
        "game_mode_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionGameMode"
        ),
        "projection_actor_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
        ),
        "runtime_profile": "hypercube-3x3x3x3-runtime-v1",
    },
    {
        "order": 4,
        "map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_4x4x4x4Training",
        "game_mode_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual4444ProjectionGameMode"
        ),
        "projection_actor_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
        ),
        "runtime_profile": "hypercube-4x4x4x4-runtime-v1",
    },
    {
        "order": 5,
        "map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_5x5x5x5Training",
        "game_mode_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual5555ProjectionGameMode"
        ),
        "projection_actor_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
        ),
        "runtime_profile": "hypercube-5x5x5x5-runtime-v1",
    },
    {
        "order": 6,
        "map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_6x6x6x6Training",
        "game_mode_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual6666ProjectionGameMode"
        ),
        "projection_actor_class_path": (
            "/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
        ),
        "runtime_profile": "hypercube-6x6x6x6-runtime-v1",
    },
)


def log(message: str) -> None:
    unreal.log(f"[HyperTwistVirtual4DMapAuthoring] {message}")


def ensure_directory(directory_path: str) -> None:
    if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
        if not unreal.EditorAssetLibrary.make_directory(directory_path):
            raise RuntimeError(f"Failed to create content directory: {directory_path}")


def split_asset_path(asset_path: str):
    package_path, asset_name = asset_path.rsplit("/", 1)
    return package_path, asset_name


def require_class(class_path: str):
    loaded_class = unreal.load_class(None, class_path)
    if loaded_class is None:
        raise RuntimeError(f"Required class was not found: {class_path}")
    return loaded_class


def require_asset(asset_path: str):
    asset = unreal.EditorAssetLibrary.load_asset(asset_path)
    if asset is None:
        raise RuntimeError(f"Required asset was not found: {asset_path}")
    return asset


def create_asset(asset_path: str, asset_class, factory):
    package_path, asset_name = split_asset_path(asset_path)
    ensure_directory(package_path)
    asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
        asset_name,
        package_path,
        asset_class,
        factory,
    )
    if asset is None:
        raise RuntimeError(f"Failed to create asset: {asset_path}")
    return asset


def ensure_projection_material():
    if unreal.EditorAssetLibrary.does_asset_exist(VERTEX_COLOR_MATERIAL_PATH):
        material = require_asset(VERTEX_COLOR_MATERIAL_PATH)
    else:
        material = create_asset(
            VERTEX_COLOR_MATERIAL_PATH,
            unreal.Material,
            unreal.MaterialFactoryNew(),
        )

        vertex_color = unreal.MaterialEditingLibrary.create_material_expression(
            material,
            unreal.MaterialExpressionVertexColor,
            -560,
            -80,
        )
        glow_strength = unreal.MaterialEditingLibrary.create_material_expression(
            material,
            unreal.MaterialExpressionScalarParameter,
            -560,
            120,
        )
        glow_strength.set_editor_property("parameter_name", "GlowStrength")
        glow_strength.set_editor_property("default_value", 1.15)
        emissive = unreal.MaterialEditingLibrary.create_material_expression(
            material,
            unreal.MaterialExpressionMultiply,
            -240,
            -20,
        )
        unreal.MaterialEditingLibrary.connect_material_expressions(
            vertex_color,
            "RGB",
            emissive,
            "A",
        )
        unreal.MaterialEditingLibrary.connect_material_expressions(
            glow_strength,
            "",
            emissive,
            "B",
        )
        unreal.MaterialEditingLibrary.connect_material_property(
            emissive,
            "",
            unreal.MaterialProperty.MP_EMISSIVE_COLOR,
        )
        unreal.MaterialEditingLibrary.connect_material_property(
            vertex_color,
            "A",
            unreal.MaterialProperty.MP_OPACITY,
        )
        unreal.MaterialEditingLibrary.layout_material_expressions(material)

    material.set_editor_property("two_sided", True)
    material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_TRANSLUCENT)
    material.set_editor_property(
        "shading_model",
        unreal.MaterialShadingModel.MSM_UNLIT,
    )
    unreal.MaterialEditingLibrary.recompile_material(material)
    if not unreal.EditorAssetLibrary.save_loaded_asset(material, False):
        raise RuntimeError(
            f"Failed to save projection material: {VERTEX_COLOR_MATERIAL_PATH}"
        )
    log(f"Verified projection material {VERTEX_COLOR_MATERIAL_PATH}")
    return material


def map_file_relative_path(config) -> str:
    map_name = config["map_asset_path"].rsplit("/", 1)[-1]
    return os.path.join(
        "UnrealHyperTwist",
        "Content",
        "HyperTwistTraining",
        "Maps",
        f"{map_name}.umap",
    )


def map_file_absolute_path(config) -> str:
    return os.path.join(PROJECT_ROOT, map_file_relative_path(config))


def map_object_path(config) -> str:
    map_name = config["map_asset_path"].rsplit("/", 1)[-1]
    return f"{config['map_asset_path']}.{map_name}"


def delete_existing_level(level_subsystem, config) -> None:
    map_path = config["map_asset_path"]
    map_file = map_file_absolute_path(config)
    if (
        not os.path.exists(map_file)
        and not unreal.EditorAssetLibrary.does_asset_exist(map_path)
        and not unreal.EditorAssetLibrary.does_asset_exist(map_object_path(config))
    ):
        return

    if not level_subsystem.load_level(SAFE_TRANSIENT_LEVEL_PATH):
        raise RuntimeError(
            f"Failed to load {SAFE_TRANSIENT_LEVEL_PATH} before replacing {map_path}."
        )

    for candidate_path in (map_object_path(config), map_path):
        if unreal.EditorAssetLibrary.does_asset_exist(candidate_path):
            unreal.EditorAssetLibrary.delete_asset(candidate_path)

    if os.path.exists(map_file):
        os.remove(map_file)


def configure_world_settings(config) -> None:
    editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
    if editor_subsystem is None:
        raise RuntimeError("UnrealEditorSubsystem was not available.")
    world = editor_subsystem.get_editor_world()
    world_settings = world.get_world_settings() if world is not None else None
    if world_settings is None:
        raise RuntimeError("World settings were not available.")
    world_settings.set_editor_property(
        "default_game_mode",
        require_class(config["game_mode_class_path"]),
    )


def spawn_actor(actor_class, label: str, location, rotation):
    actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
    if actor_subsystem is None:
        raise RuntimeError("EditorActorSubsystem was not available.")
    actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
    if actor is None:
        raise RuntimeError(f"Failed to spawn actor: {label}")
    actor.set_actor_label(label)
    return actor


def place_map_anchor(config) -> None:
    order = config["order"]
    player_start = spawn_actor(
        unreal.PlayerStart,
        f"HT_Virtual4D_{order}x_PlayerStart",
        unreal.Vector(-720.0, 0.0, 180.0),
        unreal.Rotator(0.0, 0.0, 0.0),
    )
    player_start.set_editor_property(
        "tags",
        [
            unreal.Name(AUTHOR_TAG),
            unreal.Name(f"puzzle-order:{order}"),
            unreal.Name(f"runtime-profile:{config['runtime_profile']}"),
            unreal.Name("input:keyboard-mouse"),
            unreal.Name("projection-spawn:runtime-game-mode"),
            unreal.Name(
                f"projection-class:{config['projection_actor_class_path']}"
            ),
            unreal.Name("ownership:first-party-dedicated-map"),
        ],
    )


def validate_current_map(config) -> None:
    actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
    editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
    if actor_subsystem is None or editor_subsystem is None:
        raise RuntimeError("Editor subsystems were unavailable during validation.")

    actors_by_label = {
        actor.get_actor_label(): actor
        for actor in actor_subsystem.get_all_level_actors()
        if actor is not None
    }
    order = config["order"]
    expected_anchor_label = f"HT_Virtual4D_{order}x_PlayerStart"
    missing = {expected_anchor_label} - set(actors_by_label)
    if missing:
        raise RuntimeError(
            "Authored map is missing required actors: " + ", ".join(sorted(missing))
        )

    anchor_tags = {
        str(tag) for tag in actors_by_label[expected_anchor_label].tags
    }
    expected_anchor_tags = {
        AUTHOR_TAG,
        f"puzzle-order:{order}",
        f"runtime-profile:{config['runtime_profile']}",
        "input:keyboard-mouse",
        "projection-spawn:runtime-game-mode",
        f"projection-class:{config['projection_actor_class_path']}",
        "ownership:first-party-dedicated-map",
    }
    missing_tags = expected_anchor_tags - anchor_tags
    if missing_tags:
        raise RuntimeError(
            "Authored map anchor is missing required ownership tags: "
            + ", ".join(sorted(missing_tags))
        )

    if not unreal.EditorAssetLibrary.does_asset_exist(VERTEX_COLOR_MATERIAL_PATH):
        raise RuntimeError(
            f"Authored map projection material is missing: {VERTEX_COLOR_MATERIAL_PATH}"
        )

    world = editor_subsystem.get_editor_world()
    world_settings = world.get_world_settings() if world is not None else None
    game_mode = (
        world_settings.get_editor_property("default_game_mode")
        if world_settings is not None
        else None
    )
    expected_game_mode_name = config["game_mode_class_path"].rsplit(".", 1)[-1]
    if game_mode is None or game_mode.get_name() != expected_game_mode_name:
        raise RuntimeError(
            f"Map {config['map_asset_path']} does not own {expected_game_mode_name}."
        )


def compute_md5(file_path: str) -> str:
    digest = hashlib.md5()
    with open(file_path, "rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def write_receipt(config) -> None:
    map_file = map_file_absolute_path(config)
    if not os.path.exists(map_file):
        raise RuntimeError(f"Authored map file was not saved: {map_file}")
    os.makedirs(AUTHORING_RECEIPT_DIRECTORY, exist_ok=True)
    order = config["order"]
    receipt_path = os.path.join(
        AUTHORING_RECEIPT_DIRECTORY,
        f"virtual_4d_{order}x_complete.json",
    )
    receipt = {
        "schemaVersion": "hypertwist/virtual-4d-map-authoring-receipt/v1",
        "order": order,
        "mapAssetPath": config["map_asset_path"],
        "mapHashMd5": compute_md5(map_file),
        "gameModeClassPath": config["game_mode_class_path"],
        "projectionActorClassPath": config["projection_actor_class_path"],
        "projectionSpawnMode": "runtime-game-mode",
        "runtimeProfile": config["runtime_profile"],
        "validatedActorLabels": [
            f"HT_Virtual4D_{order}x_PlayerStart",
        ],
        "authoringComplete": True,
    }
    with open(receipt_path, "w", encoding="utf-8") as handle:
        json.dump(receipt, handle, indent=2)
        handle.write("\n")


def author_map(config) -> None:
    level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
    if level_subsystem is None:
        raise RuntimeError("LevelEditorSubsystem was not available.")

    delete_existing_level(level_subsystem, config)
    if not level_subsystem.new_level(config["map_asset_path"]):
        raise RuntimeError(f"Failed to create level: {config['map_asset_path']}")
    configure_world_settings(config)
    # Procedural projection actors are runtime-owned. Spawning them under
    # UnrealEditor-Cmd + NullRHI can crash UE 5.7 before a map is certifiable.
    place_map_anchor(config)
    if not level_subsystem.save_current_level():
        raise RuntimeError(f"Failed to save level: {config['map_asset_path']}")
    validate_current_map(config)
    write_receipt(config)
    log(f"Authored {config['map_asset_path']}")


def write_manifest() -> None:
    entries = []
    for config in MAP_CONFIGS:
        map_file = map_file_absolute_path(config)
        if not os.path.exists(map_file):
            raise RuntimeError(f"Cannot seal manifest; map is missing: {map_file}")
        order = config["order"]
        entries.append(
            {
                "order": order,
                "puzzleId": f"hypercube/{order}x{order}x{order}x{order}",
                "mapAssetPath": config["map_asset_path"],
                "mapFileRelativePath": map_file_relative_path(config).replace("\\", "/"),
                "mapHashMd5": compute_md5(map_file),
                "gameModeClassPath": config["game_mode_class_path"],
                "projectionActorClassPath": config["projection_actor_class_path"],
                "projectionSpawnMode": "runtime-game-mode",
                "runtimeProfile": config["runtime_profile"],
                "inputMode": "keyboard-mouse",
                "authorTag": AUTHOR_TAG,
            }
        )

    os.makedirs(os.path.dirname(MANIFEST_ABSOLUTE_PATH), exist_ok=True)
    manifest = {
        "schemaVersion": "hypertwist/virtual-4d-dedicated-map-manifest/v1",
        "manifestVersion": "2026.07.23",
        "authoringScriptRelativePath": (
            "scripts/hypertwist_author_virtual_4d_training_maps.py"
        ),
        "projectionMaterialAssetPath": VERTEX_COLOR_MATERIAL_PATH,
        "entries": entries,
    }
    with open(MANIFEST_ABSOLUTE_PATH, "w", encoding="utf-8") as handle:
        json.dump(manifest, handle, indent=2)
        handle.write("\n")
    log(f"Wrote manifest {MANIFEST_ABSOLUTE_PATH}")


def all_map_files_exist() -> bool:
    return all(os.path.exists(map_file_absolute_path(config)) for config in MAP_CONFIGS)


def main() -> None:
    ensure_directory(MAP_ROOT)
    ensure_directory(MATERIAL_ROOT)
    ensure_projection_material()
    requested_order = os.getenv("HYPERTWIST_VIRTUAL_4D_ORDER", "").strip()
    targets = MAP_CONFIGS
    if requested_order:
        try:
            order = int(requested_order)
        except ValueError as error:
            raise RuntimeError(
                f"Invalid HYPERTWIST_VIRTUAL_4D_ORDER: {requested_order}"
            ) from error
        targets = tuple(config for config in MAP_CONFIGS if config["order"] == order)
        if not targets:
            raise RuntimeError(f"Unsupported 4D puzzle order: {order}")

    for config in targets:
        author_map(config)
    if not requested_order or all_map_files_exist():
        write_manifest()
    log("All requested virtual 4D training maps are ready.")


if __name__ == "__main__":
    try:
        main()
    except Exception as error:
        unreal.log_error(
            f"[HyperTwistVirtual4DMapAuthoring] {error}\n{traceback.format_exc()}"
        )
        raise
