from __future__ import annotations

from bisect import bisect_left
from typing import Any

import math

import pygame

from ..entities_constants import (
    BUDDY_RADIUS,
    CAR_SPEED,
    SPIKY_PLANT_HUMANOID_SPEED_FACTOR,
    PUDDLE_SPEED_FACTOR,
    SURVIVOR_MAX_SAFE_PASSENGERS,
    SURVIVOR_MIN_SPEED_FACTOR,
    SURVIVOR_RADIUS,
    ZombieKind,
    ZOMBIE_RADIUS,
)
from .constants import (
    LAYER_PLAYERS,
    LAYER_ZOMBIES,
    SURVIVOR_MESSAGE_DURATION_MS,
    SURVIVOR_SPEED_PENALTY_PER_PASSENGER,
)
from ..localization import translate_dict, translate_list
from ..models import GameData, ProgressState
from ..rng import get_rng
from ..surface_effects import resolve_surface_speed_factor
from ..entities import Survivor, Zombie, ZombieDog, spritecollideany_walls
from ..world_grid import WallIndex
from .spawn import _create_zombie
from .spatial_index import SpatialKind
from .utils import (
    find_nearby_offscreen_spawn_position,
    is_active_zombie_threat,
    is_entity_in_fov,
    rect_visible_on_screen,
)
from .moving_floor import (
    get_floor_overlap_rect,
    get_moving_floor_drift,
    is_entity_on_moving_floor,
)

RNG = get_rng()


def update_survivors(
    game_data: GameData,
    wall_index: WallIndex | None = None,
    wall_target_cell: tuple[int, int] | None = None,
    patrol_bot_group: pygame.sprite.Group | None = None,
) -> None:
    survivor_group = game_data.groups.survivor_group
    if not survivor_group:
        return
    wall_group = game_data.groups.wall_group
    player = game_data.player
    assert player is not None
    car = game_data.car
    if not player:
        return
    target_rect = car.rect if player.in_car and car and car.alive() else player.rect
    target_pos = target_rect.center
    spatial_index = game_data.state.spatial_index
    survivors = [s for s in survivor_group if s.alive()]
    player_on_moving_floor = is_entity_on_moving_floor(player)
    moving_floor_survivors: list[Survivor] = []

    for survivor in survivors:
        drift_x, drift_y = get_moving_floor_drift(
            get_floor_overlap_rect(survivor),
            game_data.layout,
            cell_size=game_data.cell_size,
        )
        on_moving_floor = abs(drift_x) > 0.0 or abs(drift_y) > 0.0
        survivor.on_moving_floor = on_moving_floor
        if on_moving_floor:
            moving_floor_survivors.append(survivor)

        speed_factor = resolve_surface_speed_factor(
            survivor.x,
            survivor.y,
            survivor.collision_radius,
            cell_size=game_data.cell_size,
            puddle_cells=game_data.layout.puddle_cells,
            spiky_plants=game_data.spiky_plants,
            spiky_plant_speed_factor=SPIKY_PLANT_HUMANOID_SPEED_FACTOR,
            puddle_speed_factor=PUDDLE_SPEED_FACTOR,
        )

        survivor.update_behavior(
            target_pos,
            wall_group,
            patrol_bot_group=patrol_bot_group,
            wall_index=wall_index,
            cell_size=game_data.cell_size,
            layout=game_data.layout,
            wall_target_cell=wall_target_cell,
            drift=(drift_x, drift_y),
            player_collision_radius=player.collision_radius,
            now_ms=game_data.state.clock.elapsed_ms,
            speed_factor=speed_factor,
        )

    # Gently prevent survivors from overlapping the player or each other
    def _separate_from_point(
        survivor: Survivor, point: tuple[float, float], min_dist: float
    ) -> None:
        dx = point[0] - survivor.x
        dy = point[1] - survivor.y
        dist = math.hypot(dx, dy)
        if dist == 0:
            angle = RNG.uniform(0, math.tau)
            dx, dy = math.cos(angle), math.sin(angle)
            dist = 1
        if dist < min_dist:
            push = min_dist - dist
            survivor.x -= (dx / dist) * push
            survivor.y -= (dy / dist) * push
            survivor.rect.center = (int(survivor.x), int(survivor.y))

    player_overlap = (SURVIVOR_RADIUS + player.collision_radius) * 1.05
    survivor_overlap = (SURVIVOR_RADIUS * 2) * 1.05

    player_point = (player.x, player.y)
    if not player_on_moving_floor:
        for survivor in survivors:
            if survivor in moving_floor_survivors:
                continue
            _separate_from_point(survivor, player_point, player_overlap)

    def _resolve_wall_overlap(survivor: Survivor) -> None:
        for _ in range(4):
            hit_wall = spritecollideany_walls(
                survivor,
                wall_group,
                wall_index=wall_index,
                cell_size=game_data.cell_size,
                grid_cols=game_data.stage.grid_cols,
                grid_rows=game_data.stage.grid_rows,
            )
            if not hit_wall:
                return
            cx, cy = survivor.rect.center
            radius = survivor.collision_radius
            rect = hit_wall.rect
            closest_x = min(max(cx, rect.left), rect.right)
            closest_y = min(max(cy, rect.top), rect.bottom)
            dx = cx - closest_x
            dy = cy - closest_y
            if dx == 0 and dy == 0:
                left_pen = cx - rect.left
                right_pen = rect.right - cx
                top_pen = cy - rect.top
                bottom_pen = rect.bottom - cy
                min_pen = min(left_pen, right_pen, top_pen, bottom_pen)
                if min_pen == left_pen:
                    cx = rect.left - radius
                elif min_pen == right_pen:
                    cx = rect.right + radius
                elif min_pen == top_pen:
                    cy = rect.top - radius
                else:
                    cy = rect.bottom + radius
            else:
                dist = math.hypot(dx, dy)
                if dist == 0:
                    return
                push = max(1.0, radius - dist)
                cx += (dx / dist) * push
                cy += (dy / dist) * push
            survivor.x = float(cx)
            survivor.y = float(cy)
            survivor.rect.center = (int(survivor.x), int(survivor.y))

    for survivor in survivors:
        if survivor in moving_floor_survivors:
            continue
        nearby = spatial_index.query_radius(
            (survivor.x, survivor.y),
            survivor_overlap,
            kinds=SpatialKind.SURVIVOR,
        )
        for other in nearby:
            if other is survivor or not other.alive():
                continue
            if is_entity_on_moving_floor(other):
                continue
            if id(other) <= id(survivor):
                continue
            dx = other.x - survivor.x
            dy = other.y - survivor.y
            dist = math.hypot(dx, dy)
            if dist == 0:
                angle = RNG.uniform(0, math.tau)
                dx, dy = math.cos(angle), math.sin(angle)
                dist = 1
            if dist < survivor_overlap:
                push = (survivor_overlap - dist) / 2
                offset_x = (dx / dist) * push
                offset_y = (dy / dist) * push
                survivor.x -= offset_x
                survivor.y -= offset_y
                other.x += offset_x
                other.y += offset_y
                survivor.rect.center = (int(survivor.x), int(survivor.y))
                other.rect.center = (int(other.x), int(other.y))

    for survivor in survivors:
        if survivor in moving_floor_survivors:
            continue
        _resolve_wall_overlap(survivor)


def calculate_car_speed_for_passengers(
    passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
) -> float:
    cap = max(1, capacity)
    load_ratio = max(0.0, passengers / cap)
    penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
    penalty = min(0.95, max(0.0, penalty))
    adjusted = CAR_SPEED * (1 - penalty)
    if passengers <= cap:
        return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, adjusted)

    overload = passengers - cap
    overload_factor = 1 / math.sqrt(overload + 1)
    overloaded_speed = CAR_SPEED * overload_factor
    return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, overloaded_speed)


def apply_passenger_speed_penalty(game_data: GameData) -> None:
    car = game_data.car
    if not car:
        return
    if not game_data.stage.survivor_rescue_stage:
        car.speed = CAR_SPEED
        return
    car.speed = calculate_car_speed_for_passengers(
        game_data.state.survivors_onboard,
        capacity=game_data.state.survivor_capacity,
    )


def increase_survivor_capacity(game_data: GameData, increments: int = 1) -> None:
    if increments <= 0:
        return
    stage = game_data.stage
    supports_passengers = (
        stage.survivor_rescue_stage
        or stage.survivor_spawn_rate > 0.0
        or stage.buddy_required_count > 0
    )
    if not supports_passengers:
        return
    state = game_data.state
    state.survivor_capacity += increments * SURVIVOR_MAX_SAFE_PASSENGERS
    apply_passenger_speed_penalty(game_data)


def add_survivor_message(game_data: GameData, text: str) -> None:
    expires = game_data.state.clock.elapsed_ms + SURVIVOR_MESSAGE_DURATION_MS
    game_data.state.survivor_messages.append({"text": text, "expires_at": expires})


def _normalize_legacy_conversion_lines(data: dict[str, Any]) -> list[str]:
    numbered: list[tuple[int, str]] = []
    others: list[tuple[str, str]] = []
    for key, value in data.items():
        if not value:
            continue
        text = str(value)
        if isinstance(key, str) and key.startswith("line"):
            suffix = key[4:]
            if suffix.isdigit():
                numbered.append((int(suffix), text))
                continue
        others.append((str(key), text))
    numbered.sort(key=lambda item: item[0])
    others.sort(key=lambda item: item[0])
    return [text for _, text in numbered] + [text for _, text in others]


def _get_survivor_conversion_messages(stage_id: str) -> list[str]:
    key = f"stages.{stage_id}.survivor_conversion_messages"
    raw = translate_list(key)
    if raw:
        return [str(item) for item in raw if item]
    legacy = translate_dict(f"stages.{stage_id}.conversion_lines")
    if legacy:
        return _normalize_legacy_conversion_lines(legacy)
    return []


def random_survivor_conversion_line(stage_id: str) -> str:
    lines = _get_survivor_conversion_messages(stage_id)
    if not lines:
        return ""
    return RNG.choice(lines)


def cleanup_survivor_messages(state: ProgressState) -> None:
    now = state.clock.elapsed_ms
    state.survivor_messages = [
        msg for msg in state.survivor_messages if msg.get("expires_at", 0) > now
    ]


def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> None:
    """Respawn boarded survivors back into the world after a crash."""
    count = game_data.state.survivors_onboard
    if count <= 0:
        return
    wall_group = game_data.groups.wall_group
    survivor_group = game_data.groups.survivor_group
    all_sprites = game_data.groups.all_sprites
    pitfall_cells = game_data.layout.pitfall_cells
    cell_size = game_data.cell_size
    walkable_cells = game_data.layout.walkable_cells

    def _in_pitfall(pos: tuple[float, float]) -> bool:
        if cell_size <= 0 or not pitfall_cells:
            return False
        cell = (int(pos[0] // cell_size), int(pos[1] // cell_size))
        return cell in pitfall_cells

    safe_origin = origin
    if _in_pitfall((origin[0], origin[1])) and walkable_cells and cell_size > 0:
        nearest = min(
            walkable_cells,
            key=lambda cell: (
                ((cell[0] + 0.5) * cell_size - origin[0]) ** 2
                + ((cell[1] + 0.5) * cell_size - origin[1]) ** 2
            ),
        )
        safe_origin = (
            int((nearest[0] + 0.5) * cell_size),
            int((nearest[1] + 0.5) * cell_size),
        )

    for _ in range(count):
        placed = False
        for _ in range(6):
            angle = RNG.uniform(0, math.tau)
            dist = RNG.uniform(16, 40)
            pos = (
                safe_origin[0] + math.cos(angle) * dist,
                safe_origin[1] + math.sin(angle) * dist,
            )
            s = Survivor(*pos)
            if not spritecollideany_walls(s, wall_group) and not _in_pitfall(pos):
                survivor_group.add(s)
                all_sprites.add(s, layer=LAYER_PLAYERS)
                placed = True
                break
        if not placed:
            s = Survivor(*safe_origin)
            survivor_group.add(s)
            all_sprites.add(s, layer=LAYER_PLAYERS)

    game_data.state.survivors_onboard = 0
    apply_passenger_speed_penalty(game_data)


def handle_survivor_zombie_collisions(
    game_data: GameData, config: dict[str, Any]
) -> None:
    survivor_group = game_data.groups.survivor_group
    if not survivor_group:
        return
    zombie_group = game_data.groups.zombie_group
    zombies = [z for z in zombie_group if z.alive()]
    zombies.sort(key=lambda s: s.rect.centerx)
    zombie_xs = [z.rect.centerx for z in zombies]
    camera = game_data.camera
    walkable_cells = game_data.layout.walkable_cells
    contaminated_cells = game_data.layout.zombie_contaminated_cells
    cell_size = game_data.cell_size
    player = game_data.player
    car = game_data.car
    active_car = car if car and car.alive() else None
    fov_target = active_car if player and player.in_car and active_car else player
    now = game_data.state.clock.elapsed_ms

    def _is_on_contaminated_cell(survivor: Survivor) -> bool:
        if cell_size <= 0 or not contaminated_cells:
            return False
        cell = (
            int(survivor.rect.centerx // cell_size),
            int(survivor.rect.centery // cell_size),
        )
        return cell in contaminated_cells

    def _convert_survivor_to_zombie(
        survivor: Survivor,
        *,
        zombie_kind: ZombieKind,
    ) -> None:
        survivor.kill()
        line = random_survivor_conversion_line(game_data.stage.id)
        if line:
            add_survivor_message(game_data, line)
        new_zombie = _create_zombie(
            config,
            start_pos=survivor.rect.center,
            stage=game_data.stage,
            kind=zombie_kind,
        )
        zombie_group.add(new_zombie)
        game_data.groups.all_sprites.add(new_zombie, layer=LAYER_ZOMBIES)
        insert_idx = bisect_left(zombie_xs, new_zombie.rect.centerx)
        zombie_xs.insert(insert_idx, new_zombie.rect.centerx)
        zombies.insert(insert_idx, new_zombie)

    for survivor in list(survivor_group):
        if not survivor.alive():
            continue
        if _is_on_contaminated_cell(survivor):
            _convert_survivor_to_zombie(survivor, zombie_kind=ZombieKind.NORMAL)
            continue
        survivor_radius = survivor.collision_radius
        search_radius = survivor_radius + ZOMBIE_RADIUS
        search_radius_sq = search_radius * search_radius

        min_x = survivor.rect.centerx - search_radius
        max_x = survivor.rect.centerx + search_radius
        start_idx = bisect_left(zombie_xs, min_x)
        collided_zombie: Zombie | None = None
        for idx in range(start_idx, len(zombies)):
            zombie_x = zombie_xs[idx]
            if zombie_x > max_x:
                break
            zombie = zombies[idx]
            if not zombie.alive():
                continue
            if not is_active_zombie_threat(zombie, now_ms=now):
                continue
            dy = zombie.rect.centery - survivor.rect.centery
            if abs(dy) > search_radius:
                continue
            dx = zombie_x - survivor.rect.centerx
            if dx * dx + dy * dy <= search_radius_sq:
                collided_zombie = zombie
                break

        if collided_zombie is None:
            continue
        survivor_on_screen = rect_visible_on_screen(camera, survivor.rect)
        survivor_in_fov = is_entity_in_fov(
            survivor.rect,
            fov_target=fov_target,
            flashlight_count=game_data.state.flashlight_count,
        )
        if not (survivor_on_screen and survivor_in_fov):
            spawn_pos = find_nearby_offscreen_spawn_position(
                walkable_cells,
                cell_size,
                camera=camera,
            )
            survivor.teleport(spawn_pos)
            continue
        _convert_survivor_to_zombie(
            survivor,
            zombie_kind=(
                ZombieKind.NORMAL
                if isinstance(collided_zombie, ZombieDog)
                else getattr(collided_zombie, "kind", ZombieKind.NORMAL)
            ),
        )


def respawn_buddies_near_player(game_data: GameData) -> None:
    """Bring back onboard buddies near the player after losing the car."""
    if game_data.stage.buddy_required_count <= 0:
        return
    count = game_data.state.buddy_onboard
    if count <= 0:
        return

    player = game_data.player
    assert player is not None
    wall_group = game_data.groups.wall_group
    camera = game_data.camera
    walkable_cells = game_data.layout.walkable_cells
    cell_size = game_data.cell_size
    offsets = [
        (BUDDY_RADIUS * 3, 0),
        (-BUDDY_RADIUS * 3, 0),
        (0, BUDDY_RADIUS * 3),
        (0, -BUDDY_RADIUS * 3),
        (0, 0),
    ]
    for _ in range(count):
        if walkable_cells:
            spawn_pos = find_nearby_offscreen_spawn_position(
                walkable_cells,
                cell_size,
                camera=camera,
                attempts=50,
            )
        else:
            spawn_pos = (int(player.x), int(player.y))
        for dx, dy in offsets:
            candidate = Survivor(player.x + dx, player.y + dy, is_buddy=True)
            if not spritecollideany_walls(candidate, wall_group):
                spawn_pos = (candidate.x, candidate.y)
                break

        buddy = Survivor(*spawn_pos, is_buddy=True)
        buddy.following = True
        game_data.groups.all_sprites.add(buddy, layer=LAYER_PLAYERS)
        game_data.groups.survivor_group.add(buddy)
    game_data.state.buddy_onboard = 0
