from collections.abc import Sequence
import datetime
from dataclasses import dataclass, field
import uuid

from sqlalchemy import create_engine, select, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session

from idlemaxx.models.models import (
    IdlemaxBase,
    Character,
    CharacterActivity,
    CharacterActivityQueue,
    CharacterActivityXpReward,
    CharacterActivityItemReward,
    CharacterActivityMaterialConsumed,
    CharacterSkill,
    Skill,
    now_utc,
    Activity,
    ActivityCategory,
    level_from_xp,
    Item,
    CharacterItem,
    Trade,
    TradeOffer,
    TradeOfferItem,
    TradeStatus,
)

QUEUE_LIMIT = 3


@dataclass
class ActivityEstimate:
    possible_actions: int | None  # None = unlimited (no materials constrain it)
    total_items: dict[Item, int] = field(default_factory=dict)
    total_xp: dict[Skill, int] = field(default_factory=dict)


@dataclass
class ActivityReward:
    actions_completed: int
    xp_gained: dict[Skill, int] = field(default_factory=dict)
    items_gained: dict[Item, int] = field(default_factory=dict)
    materials_consumed: dict[Item, int] = field(default_factory=dict)


class Game:
    def __init__(self, db_url: str = "sqlite:///idlemaxx.db") -> None:
        engine = create_engine(db_url)
        IdlemaxBase.metadata.create_all(engine)
        with engine.connect() as conn:
            try:
                conn.execute(text("ALTER TABLE characters ADD COLUMN discord_user_id VARCHAR"))
                conn.commit()
            except OperationalError:
                pass
        self._session = Session(engine)

    def _get_character_skill(self, character: Character, skill: Skill) -> CharacterSkill:
        character_skill = self._session.scalars(
            select(CharacterSkill)
            .where(CharacterSkill.character_id == character.id)
            .where(CharacterSkill.skill == skill)
        ).one_or_none()
        if not character_skill:
            character_skill = CharacterSkill(character.id, skill, 0)
            character.skills.append(character_skill)
        return character_skill

    def _get_character_item(self, character: Character, item: Item) -> CharacterItem:
        character_item = self._session.scalars(
            select(CharacterItem).where(CharacterItem.character_id == character.id).where(CharacterItem.item == item)
        ).one_or_none()
        if not character_item:
            character_item = CharacterItem(character.id, item, quantity=0)
            character.items.append(character_item)
        return character_item

    def create_character(self, name: str, discord_user_id: str | None = None) -> Character:
        character = Character(name, discord_user_id=discord_user_id)
        for skill in Skill:
            character.skills.append(CharacterSkill(character.id, skill, 0))
        self._session.add(character)
        self._session.commit()
        return character

    def delete_character(self, character: Character) -> None:
        self._session.delete(character)
        self._session.commit()

    def get_character_by_discord_id(self, discord_user_id: str) -> Character | None:
        return self._session.scalars(
            select(Character).where(Character.discord_user_id == discord_user_id)
        ).one_or_none()

    def get_character_by_name(self, name: str) -> Character:
        return self._session.scalars(select(Character).where(Character.name == name)).one()

    def list_characters(self) -> Sequence[Character]:
        return self._session.scalars(select(Character)).all()

    def activity_categories(self) -> list[ActivityCategory]:
        return list(dict.fromkeys(a.value.category for a in Activity))

    def activities_for_category(self, category: ActivityCategory) -> list[Activity]:
        return [a for a in Activity if a.value.category == category]

    def _material_action_limit(self, character: Character, activity: Activity) -> int | None:
        """Returns the max actions completable from current inventory, or None if the activity has no materials."""
        data = activity.value
        if not data.materials:
            return None
        return min(self._get_character_item(character, mat.item).quantity // mat.quantity for mat in data.materials)

    def estimate_activity(self, character: Character, activity: Activity) -> ActivityEstimate:
        data = activity.value
        possible = self._material_action_limit(character, activity)
        if possible is None:
            return ActivityEstimate(possible_actions=None)
        return ActivityEstimate(
            possible_actions=possible,
            total_items={r.item: r.quantity * possible for r in data.item_rewards},
            total_xp={r.skill: r.xp * possible for r in data.skill_rewards},
        )

    def start_activity(
        self,
        character: Character,
        activity: Activity,
        action_limit: int | None = None,
        _started_at: datetime.datetime | None = None,
    ) -> CharacterActivity:
        existing = self._session.scalars(
            select(CharacterActivity)
            .where(CharacterActivity.character_id == character.id)
            .where(CharacterActivity.ended_at.is_(None))
        ).one_or_none()
        if existing:
            raise Exception(f"{character.name} is already doing {existing.activity.name}")

        for skill_requirement in activity.skill_requirements:
            character_skill = self._get_character_skill(character, skill_requirement.skill)
            character_skill_level = level_from_xp(character_skill.experience)
            if character_skill_level < skill_requirement.required_level:
                raise Exception(
                    f"Requires {skill_requirement.skill.value} level {skill_requirement.required_level}, "
                    f"but you only have {character_skill_level}"
                )

        character_activity = CharacterActivity(character.id, activity, action_limit=action_limit)
        if _started_at is not None:
            character_activity.started_at = _started_at
        self._session.add(character_activity)
        self._session.commit()
        return character_activity

    def get_queue_limit(self, character: Character) -> int:
        return QUEUE_LIMIT

    def get_activity_queue(self, character: Character) -> list[CharacterActivityQueue]:
        return [item for item in character.activity_queue if item.started_at is None]

    def remove_from_queue(self, character: Character, item: CharacterActivityQueue) -> None:
        character.activity_queue.remove(item)
        self._session.commit()

    def _dequeue_next(self, character: Character, started_at: datetime.datetime) -> CharacterActivityQueue | None:
        pending = self.get_activity_queue(character)
        if not pending:
            return None
        item = pending[0]
        item.started_at = started_at
        return item

    def queue_activity(
        self,
        character: Character,
        activity: Activity,
        action_limit: int | None = None,
    ) -> None:
        if character.current_activity is None:
            self.start_activity(character, activity, action_limit=action_limit)
            return
        queue = self.get_activity_queue(character)
        if len(queue) >= self.get_queue_limit(character):
            raise Exception(f"Activity queue is full (limit: {self.get_queue_limit(character)})")
        for skill_requirement in activity.skill_requirements:
            character_skill = self._get_character_skill(character, skill_requirement.skill)
            character_skill_level = level_from_xp(character_skill.experience)
            if character_skill_level < skill_requirement.required_level:
                raise Exception(
                    f"Requires {skill_requirement.skill.value} level {skill_requirement.required_level}, "
                    f"but you only have {character_skill_level}"
                )
        item = CharacterActivityQueue(character.id, activity, action_limit)
        character.activity_queue.append(item)
        self._session.commit()

    def _apply_rewards(
        self,
        character: Character,
        ca: CharacterActivity,
        actions_completed: int,
    ) -> ActivityReward:
        reward = ActivityReward(actions_completed=actions_completed)

        for mat in ca.activity.materials:
            item = self._get_character_item(character, mat.item)
            consumed = actions_completed * mat.quantity
            item.quantity -= consumed
            reward.materials_consumed[mat.item] = consumed
            ca.materials_consumed.append(CharacterActivityMaterialConsumed(ca.id, mat.item, consumed))

        for skill_reward in ca.activity.skill_rewards:
            character_skill = self._get_character_skill(character, skill_reward.skill)
            xp = skill_reward.xp * actions_completed
            character_skill.experience += xp
            reward.xp_gained[skill_reward.skill] = xp
            ca.xp_rewards.append(CharacterActivityXpReward(ca.id, skill_reward.skill, xp))

        for item_reward in ca.activity.item_rewards:
            character_item = self._get_character_item(character, item_reward.item)
            quantity = item_reward.quantity * actions_completed
            character_item.quantity += quantity
            reward.items_gained[item_reward.item] = quantity
            ca.item_rewards.append(CharacterActivityItemReward(ca.id, item_reward.item, quantity))

        return reward

    def end_activity(self, character: Character) -> list[ActivityReward]:
        ca = self._session.scalars(
            select(CharacterActivity)
            .where(CharacterActivity.character_id == character.id)
            .where(CharacterActivity.ended_at.is_(None))
        ).one_or_none()

        if not ca:
            return []

        current_time = now_utc()
        ca.ended_at = current_time

        rewards: list[ActivityReward] = []
        chain_time = ca.started_at  # init; overwritten each iteration

        while True:
            assert ca.ended_at is not None  # always set before loop and for each new_ca
            elapsed = (ca.ended_at - ca.started_at).total_seconds()
            time_actions = elapsed / ca.activity.action_time

            mat_limit = self._material_action_limit(character, ca.activity)
            action_limit = ca.action_limit

            candidates: list[float] = [time_actions]
            if mat_limit is not None:
                candidates.append(float(mat_limit))
            if action_limit is not None:
                candidates.append(float(action_limit))
            actions_completed = int(min(candidates))

            chain_time = ca.started_at + datetime.timedelta(seconds=actions_completed * ca.activity.action_time)

            reward = self._apply_rewards(character, ca, actions_completed)
            rewards.append(reward)

            # Only chain if a hard limit (action or material) existed and was reached before now
            has_limit = (action_limit is not None) or (mat_limit is not None)
            if not has_limit or chain_time >= current_time:
                break

            next_item = self._dequeue_next(character, chain_time)
            if next_item is None:
                break

            new_ca = CharacterActivity(character.id, next_item.activity, action_limit=next_item.action_limit)
            new_ca.started_at = chain_time
            new_ca.ended_at = current_time
            self._session.add(new_ca)
            ca = new_ca

        # After loop: if there are still pending queue items, start the next one as active.
        pending = self.get_activity_queue(character)
        if pending:
            next_item = pending[0]
            next_item.started_at = current_time
            new_ca = CharacterActivity(character.id, next_item.activity, action_limit=next_item.action_limit)
            new_ca.started_at = current_time
            # ended_at stays None → this is the new active activity
            self._session.add(new_ca)

        self._session.commit()
        return rewards

    # --- Trading ---

    def get_trade(self, trade_id: uuid.UUID) -> Trade | None:
        return self._session.scalars(select(Trade).where(Trade.id == trade_id)).one_or_none()

    def get_current_offer(self, trade: Trade) -> TradeOffer:
        if not trade.offers:
            raise ValueError("Trade has no offers")
        return trade.offers[-1]

    def get_pending_trades(self, character: Character) -> list[Trade]:
        """Trades where it is currently character's turn to respond."""
        return list(
            self._session.scalars(
                select(Trade).where(Trade.status == TradeStatus.PENDING).where(Trade.current_turn_id == character.id)
            ).all()
        )

    def get_outgoing_trades(self, character: Character) -> list[Trade]:
        """Pending trades where character is waiting on the other party."""
        return list(
            self._session.scalars(
                select(Trade)
                .where(Trade.status == TradeStatus.PENDING)
                .where((Trade.initiator_id == character.id) | (Trade.counterparty_id == character.id))
                .where(Trade.current_turn_id != character.id)
            ).all()
        )

    def _validate_offer_items(self, proposer: Character, proposer_items: dict[Item, int]) -> None:
        """Validate quantities > 0 and proposer owns enough of each item they are offering."""
        for item, qty in proposer_items.items():
            if qty <= 0:
                raise ValueError(f"Quantity for {item.value} must be greater than 0")
            owned = self._get_character_item(proposer, item).quantity
            if owned < qty:
                raise ValueError(f"Insufficient {item.value}: offering {qty} but only own {owned}")

    def _validate_item_quantities(self, items: dict[Item, int]) -> None:
        """Validate all quantities > 0 (no ownership check)."""
        for item, qty in items.items():
            if qty <= 0:
                raise ValueError(f"Quantity for {item.value} must be greater than 0")

    def _build_offer(
        self,
        trade: Trade,
        proposed_by: Character,
        initiator_items: dict[Item, int],
        counterparty_items: dict[Item, int],
    ) -> TradeOffer:
        offer = TradeOffer(trade_id=trade.id, proposed_by_id=proposed_by.id)
        for item, qty in initiator_items.items():
            offer.items.append(
                TradeOfferItem(
                    trade_offer_id=offer.id,
                    offered_by_id=trade.initiator_id,
                    item=item,
                    quantity=qty,
                )
            )
        for item, qty in counterparty_items.items():
            offer.items.append(
                TradeOfferItem(
                    trade_offer_id=offer.id,
                    offered_by_id=trade.counterparty_id,
                    item=item,
                    quantity=qty,
                )
            )
        trade.offers.append(offer)
        return offer

    def create_trade(
        self,
        initiator: Character,
        counterparty: Character,
        initiator_items: dict[Item, int],
        counterparty_items: dict[Item, int],
    ) -> Trade:
        if initiator.id == counterparty.id:
            raise ValueError("Cannot trade with yourself")
        if not initiator_items and not counterparty_items:
            raise ValueError("At least one side of the trade must have items")
        self._validate_offer_items(initiator, initiator_items)
        self._validate_item_quantities(counterparty_items)

        trade = Trade(
            initiator_id=initiator.id,
            counterparty_id=counterparty.id,
            current_turn_id=counterparty.id,
        )
        self._session.add(trade)
        self._build_offer(trade, initiator, initiator_items, counterparty_items)
        self._session.commit()
        return trade

    def counter_trade(
        self,
        trade: Trade,
        proposer: Character,
        initiator_items: dict[Item, int],
        counterparty_items: dict[Item, int],
    ) -> TradeOffer:
        if trade.status != TradeStatus.PENDING:
            raise ValueError("Trade is no longer pending")
        if trade.current_turn_id != proposer.id:
            raise ValueError("It is not your turn")
        if not initiator_items and not counterparty_items:
            raise ValueError("At least one side of the trade must have items")

        if proposer.id == trade.initiator_id:
            self._validate_offer_items(proposer, initiator_items)
            self._validate_item_quantities(counterparty_items)
        else:
            self._validate_item_quantities(initiator_items)
            self._validate_offer_items(proposer, counterparty_items)

        trade.current_turn_id = trade.counterparty_id if proposer.id == trade.initiator_id else trade.initiator_id

        offer = self._build_offer(trade, proposer, initiator_items, counterparty_items)
        self._session.commit()
        return offer

    def accept_trade(self, trade: Trade, accepter: Character) -> None:
        if trade.status != TradeStatus.PENDING:
            raise ValueError("Trade is no longer pending")
        if trade.current_turn_id != accepter.id:
            raise ValueError("It is not your turn")

        offer = self.get_current_offer(trade)

        initiator_items: dict[Item, int] = {}
        counterparty_items: dict[Item, int] = {}
        for oi in offer.items:
            if oi.offered_by_id == trade.initiator_id:
                initiator_items[oi.item] = oi.quantity
            else:
                counterparty_items[oi.item] = oi.quantity

        initiator = self._session.get(Character, trade.initiator_id)
        counterparty = self._session.get(Character, trade.counterparty_id)
        assert initiator is not None and counterparty is not None

        errors: list[str] = []
        for item, qty in initiator_items.items():
            owned = self._get_character_item(initiator, item).quantity
            if owned < qty:
                errors.append(f"{initiator.name} lacks {item.value}: needs {qty}, has {owned}")
        for item, qty in counterparty_items.items():
            owned = self._get_character_item(counterparty, item).quantity
            if owned < qty:
                errors.append(f"{counterparty.name} lacks {item.value}: needs {qty}, has {owned}")
        if errors:
            raise ValueError("Trade cannot be completed:\n" + "\n".join(errors))

        for item, qty in initiator_items.items():
            self._get_character_item(initiator, item).quantity -= qty
            self._get_character_item(counterparty, item).quantity += qty
        for item, qty in counterparty_items.items():
            self._get_character_item(counterparty, item).quantity -= qty
            self._get_character_item(initiator, item).quantity += qty

        trade.status = TradeStatus.ACCEPTED
        self._session.commit()

    def reject_trade(self, trade: Trade, rejector: Character) -> None:
        if trade.status != TradeStatus.PENDING:
            raise ValueError("Trade is no longer pending")
        if rejector.id not in (trade.initiator_id, trade.counterparty_id):
            raise ValueError("You are not a party to this trade")
        trade.status = TradeStatus.REJECTED
        self._session.commit()
