from copy import deepcopy

from pydantic import BaseModel, Field, NonNegativeFloat, field_validator, model_validator

from gravi_model_client._base import CamelModel


class VolumeRange(CamelModel):
    lower_bound: NonNegativeFloat
    upper_bound: NonNegativeFloat

    @property
    def delta(self):
        return self.upper_bound - self.lower_bound

    @model_validator(mode="after")
    def validate_upper_bound_above_lower_bound(self, values):
        if self.upper_bound < self.lower_bound:
            raise ValueError("upper_bound must be greater than or equal to lower_bound")
        return self

    def contains(self, value: float):
        return self.lower_bound <= value <= self.upper_bound

    def __str__(self):
        return f"{self.lower_bound} -> {self.upper_bound}"


class TankPeriod(CamelModel):
    period_index: int
    demand: float
    delivered_volume: float


def bounds_issues(bounds: VolumeRange, bound_name: str, value: float, value_name: str):
    if not bounds.contains(value):
        return (
            f"{value_name} must be inside of {bound_name} "
            f"({bounds.lower_bound}<={value}<={bounds.upper_bound})"
        )
    return None


class TankRequest(CamelModel):
    tank: str
    product: str = ""
    current_inventory: float
    demand: list[float]
    deliveries: list[float]
    phantom_volume: list[float] | None = None
    tank_range: VolumeRange
    er_drain_cost: float | None = None
    er_fill_cost: float | None = None
    target_range: VolumeRange
    over_target_cost: float | None = None
    under_target_cost: float | None = None
    preferred_range: VolumeRange
    over_preferred_cost: float | None = None
    under_preferred_cost: float | None = None

    minimum_load_size: float | None = None
    maximum_load_size: float | None = None

    weight_volume_adjustment: float | None = None

    def calculate_phantom_volume(self):
        periods = len(self.demand)
        phantom = [0.0] * periods
        current = 0.0

        for period in reversed(range(periods)):
            current = max(0.0, current - self.demand[period])
            phantom[period] = current
            current += self.deliveries[period]

        self.phantom_volume = phantom

    @model_validator(mode="after")
    def validate_bounds_values(cls, values: "TankRequest"):
        issues = [
            bounds_issues(
                values.tank_range,
                "tank range",
                values.target_range.lower_bound,
                "Target min",
            ),
            bounds_issues(
                values.tank_range,
                "tank range",
                values.target_range.upper_bound,
                "Target max",
            ),
            bounds_issues(
                values.target_range,
                "target range",
                values.preferred_range.lower_bound,
                "Preferred min",
            ),
            bounds_issues(
                values.target_range,
                "target range",
                values.preferred_range.upper_bound,
                "Preferred max",
            ),
        ]
        issues = [x for x in issues if x]
        if issues:
            raise ValueError("\n".join(issues))
        return values

    @model_validator(mode="after")
    def validate_lists_length(cls, values):
        if len(values.demand) != len(values.deliveries):
            raise ValueError(
                "Length of demand list must equal length of deliveries list"
            )
        if len(values.demand) < 1:
            raise ValueError(
                "Tank must have at least 1 period (demand list length >= 1)"
            )
        return values

    @model_validator(mode="after")
    def validate_current_inventory(cls, values):
        if not (
            values.tank_range.lower_bound
            <= values.current_inventory
            <= values.tank_range.upper_bound
        ):
            raise ValueError(
                f"Current inventory ({values.current_inventory}) must be within tank range "
                f"[{values.tank_range.lower_bound}, {values.tank_range.upper_bound}]"
            )
        return values

    @model_validator(mode="after")
    def validate_deliveries(cls, values):
        tank_max = values.tank_range.upper_bound
        for i, delivery in enumerate(values.deliveries):
            if delivery < 0 or delivery > tank_max:
                raise ValueError(
                    f"Delivery at period {i} ({delivery}) must be between 0 and "
                    f"tank capacity ({tank_max})"
                )
        return values

    @property
    def periods(self):
        return len(self.demand)


class StoreRequest(CamelModel):
    name: str
    tanks: list[TankRequest]

    @model_validator(mode="after")
    def validate_tank_periods_match(cls, values):
        if not values.tanks:
            raise ValueError("Store must have at least one tank")
        periods = values.tanks[0].periods
        for tank in values.tanks:
            if tank.periods != periods:
                raise ValueError("All tanks must have the same number of periods")
        return values

    @property
    def periods(self):
        return self.tanks[0].periods


class InitialVolume:
    def __init__(self, store: StoreRequest, tank: TankRequest, volume: float):
        self.store = store
        self.tank = tank
        self.volume: float = volume

    def __str__(self):
        return f"Initial {self.tank.tank} @{self.store.name} is {self.volume} gals"


class ModelRunRequest(CamelModel):
    solver: str = ""
    time_limit: int | None = None
    mip_gap: float | None = None
    threads: int | None = None
    periods: int
    compartments: list[float]
    stores: list[StoreRequest]
    delivery_allowed: list[bool] = Field(default_factory=list)
    period_chunk_size: int = 12
    period_offset: int = 0
    multi_product_drop_cost: float = 100.0
    multi_store_drop_cost: float = 1000.0
    default_er_adjustment_cost: float = 1000.0
    default_er_adjustment_over_cost: float | None = None
    default_er_adjustment_under_cost: float | None = None
    default_target_deviation_cost: float = 1.0
    default_target_deviation_over_cost: float | None = None
    default_target_deviation_under_cost: float | None = None
    default_preferred_deviation_cost: float = 0.00010
    default_preferred_deviation_over_cost: float | None = None
    default_preferred_deviation_under_cost: float | None = None
    adaptive_period_chunks: bool = True
    adaptive_chunk_buffer: int = 4
    include_logs: bool = True
    include_phantom_volume: bool = True
    start_date: str | None = None

    def build_starting_volumes(self) -> list[InitialVolume]:
        ret = []
        for store in self.stores:
            for tank in store.tanks:
                ret.append(InitialVolume(store, tank, tank.current_inventory))
        return ret

    def propagate_default_costs_to_tanks(self):
        for store in self.stores:
            for tank in store.tanks:
                if tank.er_drain_cost is None:
                    tank.er_drain_cost = (
                        self.default_er_adjustment_over_cost
                        or self.default_er_adjustment_cost
                    )
                if tank.er_fill_cost is None:
                    tank.er_fill_cost = (
                        self.default_er_adjustment_under_cost
                        or self.default_er_adjustment_cost
                    )
                if tank.over_target_cost is None:
                    tank.over_target_cost = (
                        self.default_target_deviation_over_cost
                        or self.default_target_deviation_cost
                    )
                if tank.under_target_cost is None:
                    tank.under_target_cost = (
                        self.default_target_deviation_under_cost
                        or self.default_target_deviation_cost
                    )
                if tank.over_preferred_cost is None:
                    tank.over_preferred_cost = (
                        self.default_preferred_deviation_over_cost
                        or self.default_preferred_deviation_cost
                    )
                if tank.under_preferred_cost is None:
                    tank.under_preferred_cost = (
                        self.default_preferred_deviation_under_cost
                        or self.default_preferred_deviation_cost
                    )

    @classmethod
    @field_validator("compartments")
    def validate_compartments(cls, v):
        if not v:
            raise ValueError("Compartments list must have at least one value")
        if any(comp <= 0 for comp in v):
            raise ValueError("All compartment sizes must be greater than 0")
        return v

    @classmethod
    @field_validator("stores")
    def validate_stores(cls, v):
        if not v:
            raise ValueError("Must have at least one store")
        return v

    @model_validator(mode="after")
    def validate_store_periods_match(cls, values):
        if not values.stores:
            raise ValueError("Model must have at least one store")
        periods = values.stores[0].periods
        for store in values.stores:
            if store.periods != periods:
                raise ValueError("All stores must have the same number of periods")
        if len(values.delivery_allowed) != periods:
            raise ValueError(
                "Length of delivery_allowed list must equal number of periods"
            )
        return values

    def build_inventory_lookup(self) -> dict[str, float]:
        current_inventory: dict[str, float] = {
            tank.tank: tank.current_inventory
            for store in self.stores
            for tank in store.tanks
        }
        return current_inventory

    def chunk(self, period_offset: int, count: int):
        """
        duplicates the whole model but slices the demand, deliveries,
        and unavailable periods to just the chunk requested
        """
        chunked = deepcopy(self)
        chunked.period_offset = period_offset
        chunked.periods = count
        for store in chunked.stores:
            for tank in store.tanks:
                tank.demand = tank.demand[period_offset : period_offset + count]
                tank.deliveries = tank.deliveries[period_offset : period_offset + count]
                tank.phantom_volume = tank.phantom_volume[
                    period_offset : period_offset + count
                ]
        chunked.delivery_allowed = chunked.delivery_allowed[
            period_offset : period_offset + count
        ]
        return chunked


class DropResponse(CamelModel):
    tank: str
    volume: float
    compartment_index: int


class OrderResponse(CamelModel):
    store: str
    period: int
    drops: list[DropResponse]


class UnscheduledVolume(CamelModel):
    store: str
    tank: str
    period: int
    volume: float


class ERVolume(CamelModel):
    kind: str
    volume: float
    cost: float


class TankVolume(CamelModel):
    tank: str
    volume: float


class Order(CamelModel):
    this_tank_volume: float
    other_tank_volumes: list[TankVolume]


class InventoryRecord(CamelModel):
    volume: float
    order: Order | None = None
    er_volume: ERVolume | None = None
    is_chunk_start: bool = False
    phantom_volume: float = 0


class TankInventory(CamelModel):
    store: str
    tank: str
    inventory: list[InventoryRecord]


class CostDetails(CamelModel):
    emergency_volume_cost: float
    emergency_volume_cost_details: dict
    target_volume_deviation_cost: float
    target_volume_deviation_cost_details: dict
    preferred_volume_deviation_cost: float
    preferred_volume_deviation_cost_details: dict
    multi_product_drop_cost: float
    multi_product_drop_cost_details: dict


class ModelResultResponse(CamelModel):
    original_request: ModelRunRequest
    order_responses: list[OrderResponse]
    tank_inventories: list[TankInventory]
    er_fills: list[UnscheduledVolume]
    er_drains: list[UnscheduledVolume]
    cost_details: CostDetails
    chunk_starts: list[int]
    logs: str | None = None
    solved: bool = True
    infeasible: bool = False
    infeasible_chunk: int | None = None
    not_solved: bool = False
    not_solved_chunk: int | None = None


class ModelInput(BaseModel):
    truck_compartments: list[int]
    tanks: list[str]
    periods: int
    period_chunk_size: int = 12
    demand_type: str
