"""Telnet server for the MUD."""

import asyncio
import logging
import os
import pathlib
import time
import tomllib
from typing import cast

import telnetlib3
from telnetlib3 import GMCP, MSDP, WILL
from telnetlib3.server import TelnetServer
from telnetlib3.server_shell import readline2

import mudlib.combat.commands
import mudlib.commands
import mudlib.commands.containers
import mudlib.commands.crafting
import mudlib.commands.describe
import mudlib.commands.edit
import mudlib.commands.examine
import mudlib.commands.fly
import mudlib.commands.furnish
import mudlib.commands.help
import mudlib.commands.home
import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.play
import mudlib.commands.portals
import mudlib.commands.power
import mudlib.commands.quit
import mudlib.commands.reload
import mudlib.commands.snapneck
import mudlib.commands.spawn
import mudlib.commands.talk
import mudlib.commands.terrain
import mudlib.commands.things
import mudlib.commands.use
from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.corpse import process_decomposing
from mudlib.crafting import load_recipes, recipes
from mudlib.creation import character_creation
from mudlib.dialogue import load_all_dialogues
from mudlib.effects import clear_expired
from mudlib.gametime import get_game_hour, init_game_time
from mudlib.gmcp import (
    send_char_status,
    send_char_vitals,
    send_map_data,
    send_msdp_vitals,
    send_room_info,
)
from mudlib.housing import init_housing, load_home_zone
from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mob_movement, process_mobs
from mudlib.mobs import load_mob_templates, mob_templates, mobs
from mudlib.npc_schedule import process_schedules
from mudlib.player import Player, players
from mudlib.prompt import render_prompt
from mudlib.resting import process_resting
from mudlib.store import (
    PlayerData,
    account_exists,
    authenticate,
    create_account,
    init_db,
    load_aliases,
    load_player_data,
    load_player_stats,
    save_player,
    save_player_description,
    update_last_login,
)
from mudlib.thing import Thing
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
from mudlib.unconscious import process_unconscious
from mudlib.world.terrain import World
from mudlib.zone import Zone
from mudlib.zones import get_zone, load_zones, register_zone

log = logging.getLogger(__name__)

HOST = os.environ.get("MUD_HOST", "127.0.0.1")
PORT = int(os.environ.get("MUD_PORT", "6789"))
TICK_RATE = 10  # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE
AUTOSAVE_INTERVAL = 60.0  # seconds between auto-saves

# Module-level overworld zone instance, created once at startup
_overworld: Zone | None = None


def load_world_config(world_name: str = "earth") -> dict:
    """Load world configuration from TOML file."""
    worlds_dir = pathlib.Path(__file__).resolve().parents[2] / "worlds"
    config_path = worlds_dir / world_name / "config.toml"
    with open(config_path, "rb") as f:
        return tomllib.load(f)


async def game_loop() -> None:
    """Run periodic game tasks at TICK_RATE ticks per second."""
    log.info("game loop started (%d ticks/sec)", TICK_RATE)
    last_save_time = time.monotonic()
    tick_count = 0
    last_schedule_hour = -1

    while True:
        t0 = asyncio.get_event_loop().time()
        tick_count += 1
        clear_expired()
        await process_combat()
        await process_mobs(mudlib.combat.commands.combat_moves)
        await process_mob_movement()
        await process_resting()
        await process_unconscious()
        await process_decomposing()

        # Process NPC schedules when game hour changes
        current_hour = get_game_hour()
        if current_hour != last_schedule_hour:
            process_schedules(mobs, current_hour)
            last_schedule_hour = current_hour

        # MSDP updates once per second (every TICK_RATE ticks)
        if tick_count % TICK_RATE == 0:
            for p in list(players.values()):
                send_msdp_vitals(p)

        # Periodic auto-save (every 60 seconds)
        current_time = time.monotonic()
        if current_time - last_save_time >= AUTOSAVE_INTERVAL:
            player_count = len(players)
            if player_count > 0:
                log.debug("auto-saving %d players", player_count)
                for player in list(players.values()):
                    save_player(player)
                last_save_time = current_time

        elapsed = asyncio.get_event_loop().time() - t0
        sleep_time = TICK_INTERVAL - elapsed
        if sleep_time > 0:
            await asyncio.sleep(sleep_time)


def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, int]:
    """Find a passable tile starting from (start_x, start_y) and searching outward.

    Args:
        zone: The zone to search in
        start_x: Starting X coordinate
        start_y: Starting Y coordinate

    Returns:
        Tuple of (x, y) for the first passable tile found
    """
    # Try the starting position first
    if zone.is_passable(start_x, start_y):
        return start_x, start_y

    # Spiral outward from the starting position (wrapping)
    for radius in range(1, 100):
        for dx in range(-radius, radius + 1):
            for dy in range(-radius, radius + 1):
                # Only check the perimeter of the current radius
                if abs(dx) != radius and abs(dy) != radius:
                    continue

                x, y = zone.wrap(start_x + dx, start_y + dy)

                if zone.is_passable(x, y):
                    return x, y

    # Fallback to starting position if nothing found
    return start_x, start_y


async def handle_login(
    name: str,
    read_func,
    write_func,
) -> dict:
    """Handle login or registration for a player.

    Args:
        name: Player name
        read_func: Async function to read input
        write_func: Async function to write output

    Returns:
        Dict with 'success' (bool) and 'player_data' (dict or None)
    """
    if account_exists(name):
        # Existing account - authenticate
        max_attempts = 3
        for attempt in range(max_attempts):
            await write_func("Password: ")
            password = await read_func()

            if password is None or not password.strip():
                return {"success": False, "player_data": None}

            if authenticate(name, password.strip()):
                # Success - load player data
                player_data = load_player_data(name)
                return {"success": True, "player_data": player_data, "is_new": False}

            remaining = max_attempts - attempt - 1
            if remaining > 0:
                msg = f"Incorrect password. {remaining} attempts remaining.\r\n"
                await write_func(msg)
            else:
                await write_func("Too many failed attempts.\r\n")

        return {"success": False, "player_data": None}

    # New account - registration
    await write_func(f"Account '{name}' does not exist. Create new account? (y/n) ")
    response = await read_func()

    if response is None or response.strip().lower() != "y":
        return {"success": False, "player_data": None}

    # Get password with confirmation
    while True:
        await write_func("Choose a password: ")
        password1 = await read_func()

        if password1 is None or not password1.strip():
            return {"success": False, "player_data": None}

        await write_func("Confirm password: ")
        password2 = await read_func()

        if password2 is None or not password2.strip():
            return {"success": False, "player_data": None}

        if password1.strip() == password2.strip():
            # Passwords match - create account
            if create_account(name, password1.strip()):
                await write_func("Account created successfully!\r\n")
                # Return default data for new account
                player_data = load_player_data(name)
                return {"success": True, "player_data": player_data, "is_new": True}
            await write_func("Failed to create account.\r\n")
            return {"success": False, "player_data": None}

        # Passwords don't match - retry or cancel
        await write_func("Passwords do not match. Try again? (y/n) ")
        retry = await read_func()
        if retry is None or retry.strip().lower() != "y":
            return {"success": False, "player_data": None}


class MudTelnetServer(TelnetServer):
    """Telnet server that offers GMCP and MSDP during negotiation."""

    def begin_negotiation(self) -> None:
        """Offer GMCP and MSDP as part of initial negotiation."""
        super().begin_negotiation()
        assert self.writer is not None
        gmcp_ok = self.writer.iac(WILL, GMCP)
        msdp_ok = self.writer.iac(WILL, MSDP)
        log.debug("offered GMCP=%s MSDP=%s", gmcp_ok, msdp_ok)


async def shell(
    reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode,
    writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode,
) -> None:
    """Shell callback that handles player connection and command loop."""
    _reader = cast(telnetlib3.TelnetReaderUnicode, reader)
    _writer = cast(telnetlib3.TelnetWriterUnicode, writer)

    assert _overworld is not None, (
        "Overworld zone must be initialized before accepting connections"
    )

    log.debug("new connection from %s", _writer.get_extra_info("peername"))

    _writer.write("Welcome to the MUD!\r\n")
    _writer.write("What is your name? ")
    await _writer.drain()

    # Skip empty lines from client negotiation bytes, only close on actual disconnect
    while True:
        name_input = await readline2(_reader, _writer)
        if name_input is None:
            _writer.close()
            return
        if name_input.strip():
            break

    player_name = name_input.strip()

    # Handle login/registration
    async def read_input():
        result = await readline2(_reader, _writer)
        return result

    async def write_output(msg: str):
        _writer.write(msg)
        await _writer.drain()

    login_result = await handle_login(player_name, read_input, write_output)

    if not login_result["success"]:
        _writer.write("Login failed. Disconnecting.\r\n")
        await _writer.drain()
        _writer.close()
        return

    # Update last login timestamp
    update_last_login(player_name)

    # Load player data from database or use defaults for new player
    player_data: PlayerData | None = login_result["player_data"]

    # Run character creation for new accounts
    is_new_account = login_result.get("is_new", False)
    if is_new_account:
        creation_data = await character_creation(player_name, read_input, write_output)
        if creation_data.get("description"):
            save_player_description(player_name, creation_data["description"])
            if player_data is not None:
                player_data["description"] = creation_data["description"]
    if player_data is None:
        # New player - find a passable starting position
        center_x = _overworld.width // 2
        center_y = _overworld.height // 2
        start_x, start_y = find_passable_start(_overworld, center_x, center_y)
        player_data = {
            "x": start_x,
            "y": start_y,
            "pl": 100.0,
            "stamina": 100.0,
            "max_stamina": 100.0,
            "flying": False,
            "zone_name": "overworld",
            "inventory": [],
            "description": "",
            "home_zone": None,
        }

    # Resolve zone from zone_name using zone registry
    zone_name = player_data.get("zone_name", "overworld")
    player_zone = get_zone(zone_name)
    if player_zone is None:
        log.warning(
            "unknown zone '%s' for player '%s', defaulting to overworld",
            zone_name,
            player_name,
        )
        player_zone = _overworld

    # Verify spawn position is still passable
    if not player_zone.is_passable(player_data["x"], player_data["y"]):
        # Saved position is no longer passable, find a new one
        start_x, start_y = find_passable_start(
            player_zone, player_data["x"], player_data["y"]
        )
        player_data["x"] = start_x
        player_data["y"] = start_y

    # Load player's home zone if they have one
    home_zone_name = player_data.get("home_zone")
    if home_zone_name and home_zone_name.startswith("home:"):
        load_home_zone(player_name)

    # Create player instance
    player = Player(
        name=player_name,
        location=player_zone,
        x=player_data["x"],
        y=player_data["y"],
        pl=player_data["pl"],
        stamina=player_data["stamina"],
        max_stamina=player_data["max_stamina"],
        flying=player_data["flying"],
        writer=_writer,
        reader=_reader,
    )

    # Set description and home zone
    player.description = player_data.get("description", "")
    player.home_zone = player_data.get("home_zone")

    # Load aliases from database
    player.aliases = load_aliases(player_name)

    # Load stats from database
    stats = load_player_stats(player_name)
    player.kills = stats["kills"]
    player.deaths = stats["deaths"]
    player.mob_kills = stats["mob_kills"]
    player.play_time_seconds = stats["play_time_seconds"]
    player.unlocked_moves = stats["unlocked_moves"]

    # Set session start time for play time tracking
    player.session_start = time.monotonic()

    # Reconstruct inventory from saved data
    for item_name in player_data.get("inventory", []):
        template = thing_templates.get(item_name)
        if template:
            spawn_thing(template, player)
        else:
            # Template not found — create a bare Thing so it's not lost
            log.warning(
                "unknown thing template '%s' for player '%s'",
                item_name,
                player_name,
            )
            Thing(name=item_name, location=player)

    # Parse and store client capabilities from MTTS
    ttype3 = _writer.get_extra_info("ttype3")
    player.caps = parse_mtts(ttype3)
    log.debug(
        "%s capabilities: %s (color_depth=%s)",
        player_name,
        player.caps,
        player.color_depth,
    )

    # Register player
    players[player_name] = player
    log.info(
        "%s connected at (%d, %d)",
        player_name,
        player_data["x"],
        player_data["y"],
    )

    _writer.write(f"\r\nWelcome, {player_name}!\r\n")
    _writer.write("Type 'help' for commands or 'quit' to disconnect.\r\n\r\n")
    await _writer.drain()

    # Show initial map
    await mudlib.commands.look.cmd_look(player, "")

    # Send initial GMCP data to rich clients
    send_char_vitals(player)
    send_char_status(player)
    send_room_info(player)
    send_map_data(player)

    # Command loop
    try:
        while not _writer.is_closing():
            # Show appropriate prompt based on mode
            if player.mode == "editor" and player.editor:
                _writer.write(f"  {player.editor.cursor + 1}> ")
            else:
                _writer.write(render_prompt(player))
            await _writer.drain()

            inp = await readline2(_reader, _writer)
            if inp is None:
                break

            command = inp.strip()
            if not command and player.mode not in ("editor", "if"):
                continue

            # Handle editor mode
            if player.mode == "editor" and player.editor:
                response = await player.editor.handle_input(inp)
                if response.output:
                    await player.send(response.output)
                if response.done:
                    player.editor = None
                    player.mode_stack.pop()
            # Handle IF mode
            elif player.mode == "if" and player.if_session:
                response = await player.if_session.handle_input(command)
                if response.output:
                    # Ensure output ends with newline
                    output = response.output
                    if not output.endswith("\r\n"):
                        output += "\r\n"
                    await player.send(output)
                    # Broadcast to spectators unless it's an escape command
                    if not command.startswith("::"):
                        spectator_msg = (
                            f"[{player.name}'s terminal]\r\n> {command}\r\n{output}"
                        )
                        await broadcast_to_spectators(player, spectator_msg)
                if response.done:
                    await player.if_session.stop()
                    player.if_session = None
                    player.mode_stack.pop()
                    await player.send("\r\nyou leave the terminal.\r\n")
                    # Notify spectators
                    leave_msg = f"{player.name} steps away from the terminal.\r\n"
                    await broadcast_to_spectators(player, leave_msg)
            else:
                # Dispatch normal command
                await mudlib.commands.dispatch(player, command)
                # Update GMCP vitals after command (prompt shows vitals, so sync GMCP)
                send_char_vitals(player)

            # Check if writer was closed by quit command
            if _writer.is_closing():
                break
    finally:
        # Clean up IF session if player was playing
        if player.if_session:
            await player.if_session.stop()
        # Save player state on disconnect (if not already saved by quit command)
        if player_name in players:
            save_player(player)
            player.move_to(None)
            del players[player_name]
            log.info("%s disconnected", player_name)

        _writer.close()


async def run_server() -> None:
    """Start the MUD telnet server."""
    global _overworld

    # Initialize database
    data_dir = pathlib.Path(__file__).resolve().parents[2] / "data"
    db_path = data_dir / "mud.db"
    log.info("initializing database at %s", db_path)
    init_db(db_path)

    # Initialize game time (1 real minute = 1 game hour)
    init_game_time(real_minutes_per_game_hour=1.0)
    log.info("game time initialized (1 real minute = 1 game hour)")

    # Generate world once at startup (cached to build/ after first run)
    cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
    config = load_world_config()
    world_cfg = config["world"]
    log.info(
        "loading world (seed=%d, %dx%d)...",
        world_cfg["seed"],
        world_cfg["width"],
        world_cfg["height"],
    )
    t0 = time.monotonic()
    world = World(
        seed=world_cfg["seed"],
        width=world_cfg["width"],
        height=world_cfg["height"],
        cache_dir=cache_dir,
    )
    elapsed = time.monotonic() - t0
    if world.cached:
        log.info("world loaded from cache in %.2fs", elapsed)
    else:
        log.info("world generated in %.2fs (cached for next run)", elapsed)

    # Create overworld zone from generated terrain
    _overworld = Zone(
        name="overworld",
        description="The Overworld",
        width=world.width,
        height=world.height,
        terrain=world.terrain,
        toroidal=True,
    )
    log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height)

    # Register overworld zone
    register_zone("overworld", _overworld)

    # Load and register zones from content/zones/
    zones_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "zones"
    if zones_dir.exists():
        loaded_zones = load_zones(zones_dir)
        for zone_name, zone in loaded_zones.items():
            register_zone(zone_name, zone)
        log.info("loaded %d zones from %s", len(loaded_zones), zones_dir)

    # Initialize player housing
    player_zones_dir = data_dir / "player_zones"
    init_housing(player_zones_dir)
    log.info("player housing initialized at %s", player_zones_dir)

    # Load content-defined commands from TOML files
    content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
    if content_dir.exists():
        log.info("loading content from %s", content_dir)
        content_commands = load_commands(content_dir)
        for cmd_def in content_commands:
            mudlib.commands.register(cmd_def)
            log.debug("registered content command: %s", cmd_def.name)

    # Load combat moves and register as commands
    combat_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "combat"
    if combat_dir.exists():
        log.info("loading combat moves from %s", combat_dir)
        register_combat_commands(combat_dir)
        log.info("registered combat commands")

    # Load mob templates
    mobs_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "mobs"
    if mobs_dir.exists():
        loaded = load_mob_templates(mobs_dir)
        mob_templates.update(loaded)
        log.info("loaded %d mob templates from %s", len(loaded), mobs_dir)

    # Load thing templates
    things_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "things"
    if things_dir.exists():
        loaded_things = load_thing_templates(things_dir)
        thing_templates.update(loaded_things)
        log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)

    # Load crafting recipes
    recipes_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "recipes"
    if recipes_dir.exists():
        loaded_recipes = load_recipes(recipes_dir)
        recipes.update(loaded_recipes)
        log.info("loaded %d recipes from %s", len(loaded_recipes), recipes_dir)

    # Load dialogue trees for NPC conversations
    dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
    if dialogue_dir.exists():
        loaded_dialogues = load_all_dialogues(dialogue_dir)
        mudlib.commands.talk.dialogue_trees.update(loaded_dialogues)
        log.info(
            "loaded %d dialogue trees from %s", len(loaded_dialogues), dialogue_dir
        )

    # connect_maxwait: how long to wait for telnet option negotiation (CHARSET
    # etc) before starting the shell. default is 4.0s which is painful.
    # MUD clients like tintin++ reject CHARSET immediately via MTTS, but
    # telnetlib3 still waits for the full timeout. 0.5s is plenty.
    server = await telnetlib3.create_server(
        host=HOST,
        port=PORT,
        shell=shell,
        connect_maxwait=0.5,
        protocol_factory=MudTelnetServer,
    )
    log.info("listening on %s:%d", HOST, PORT)

    loop_task = asyncio.create_task(game_loop())

    try:
        while True:
            await asyncio.sleep(3600)
    except KeyboardInterrupt:
        log.info("shutting down...")
        loop_task.cancel()
        server.close()
        await server.wait_closed()
