from abc import ABCMeta, abstractmethod
from pathlib import Path
from typing import BinaryIO

from pydantic import FilePath, PositiveFloat

from geolib.models import BaseDataClass, BaseModel
from geolib.models.dsheetpiling.constructions import (
    DiaphragmWall,
    Pile,
    Sheet,
    VerticalBalance,
)
from geolib.models.meta import CONSOLE_RUN_BATCH_FLAG
from geolib.soils import Soil

from .calculation_options import CalculationOptions, CalculationOptionsPerStage
from .dsheetpiling_parserprovider import DSheetPilingParserProvider
from .internal import (
    DSheetPilingDumpStructure,
    DSheetPilingOutputStructure,
    DSheetPilingStructure,
    Water,
)
from .loads import (
    HorizontalLineLoad,
    Moment,
    NormalForce,
    SoilDisplacement,
    SurchargeLoad,
    UniformLoad,
)
from .profiles import SoilProfile
from .serializer import DSheetPilingInputSerializer
from .settings import (
    CalculationType,
    CurveSettings,
    LateralEarthPressureMethod,
    LateralEarthPressureMethodStage,
    ModelType,
    PartialFactorCalculationType,
    PassiveSide,
    Side,
    SinglePileLoadOptions,
    VerifyType,
)
from .supports import Anchor, RigidSupport, SpringSupport, Strut
from .surface import Surface
from .water_level import WaterLevel


class BaseModelType(BaseDataClass, metaclass=ABCMeta):
    """Base Class for Model types (Pile, Sheet, Wall)."""

    @property
    @abstractmethod
    def model(self):
        raise NotImplementedError

    def to_internal(self):
        return self.model_dump()


class SheetModelType(BaseModelType):
    method: LateralEarthPressureMethod | None = None
    check_vertical_balance: bool | None = None
    verification: bool | None = None
    trildens_calculation: bool | None = None
    reliability_analysis: bool | None = None
    elastic_calculation: bool | None = None

    @property
    def model(self) -> ModelType:
        return ModelType.SHEET_PILING


class WoodenSheetPileModelType(BaseModelType):
    method: LateralEarthPressureMethod | None = None
    check_vertical_balance: bool | None = None
    verification: bool | None = None
    elastic_calculation: bool = True
    wooden_sheetpiling: bool = True

    @property
    def model(self) -> ModelType:
        return ModelType.SHEET_PILING


class SinglePileModelType(BaseModelType):
    pile_load_option: SinglePileLoadOptions | None = None
    elastic_calculation: bool | None = None

    @property
    def model(self) -> ModelType:
        return ModelType.SINGLE_PILE

    def to_internal(self):
        if self.pile_load_option:
            if self.pile_load_option.value == "forces":
                return {"pile_load_option": 0, "pile_load_by_user": 0}
            elif self.pile_load_option.value == "user_defined_displacements":
                return {"pile_load_option": 1, "pile_load_by_user": 0}
            elif self.pile_load_option.value == "calculated_displacements":
                return {"pile_load_option": 1, "pile_load_by_user": 1}
        else:
            return {}


class DiaphragmModelType(BaseModelType):
    method: LateralEarthPressureMethod | None = None
    check_vertical_balance: bool | None = None
    verification: bool | None = None
    elastic_calculation: bool = False
    diepwand_calculation: bool = True

    @property
    def model(self) -> ModelType:
        return ModelType.SHEET_PILING


class DSheetPilingModel(BaseModel):
    r"""DSheetPiling is a tool used to design sheetpile and diaphragm walls and
    horizontally loaded piles.

    This model can read, modify and create \*.shi files, read \*.shd and \*.err files.
    """

    current_stage: int | None = None  # Forces user to always set a stage.
    datastructure: DSheetPilingStructure | DSheetPilingDumpStructure = (
        DSheetPilingStructure()
    )

    @property
    def parser_provider_type(self) -> type[DSheetPilingParserProvider]:
        return DSheetPilingParserProvider

    @property
    def default_console_path(self) -> Path:
        return Path("DSheetPiling/DSheetPiling.exe")

    @property
    def custom_console_path(self) -> Path:
        return self.get_meta_property("dsheetpiling_console_path")

    @property
    def console_flags(self) -> list[str]:
        return [CONSOLE_RUN_BATCH_FLAG]

    @property
    def output(self) -> DSheetPilingOutputStructure:
        return self.datastructure.dumpfile.output_data

    @property
    def model_type(self) -> str | ModelType:
        return self.datastructure.input_data.model.model

    def serialize(self, filename: FilePath | BinaryIO):
        ds = self.datastructure.input_data.model_dump()
        ds.update(
            {
                "version": self.datastructure.input_data.version.model_dump(),
                "version_externals": self.datastructure.input_data.version_externals.model_dump(),
            }
        )
        serializer = DSheetPilingInputSerializer(ds=ds)
        serializer.write(filename)
        if isinstance(filename, Path):
            self.filename = filename

    def _is_calculation_per_stage_required(self) -> bool:
        """Function that checks if [CALCULATION PER STAGE] can be modified. This is true for a verify sheet-piling calculation and method B."""
        _map_method_b_available = {
            VerifyType.CUR: self.datastructure.input_data.calculation_options.curmethod,
            VerifyType.EC7NL: self.datastructure.input_data.calculation_options.ec7nlmethod,
            VerifyType.EC7BE: self.datastructure.input_data.calculation_options.ec7bemethod,
        }
        if (
            self.datastructure.input_data.calculation_options.inputcalculationtype
            == CalculationType.VERIFY_SHEETPILING
        ):
            if (
                _map_method_b_available[
                    self.datastructure.input_data.calculation_options.verifytype
                ]
                == PartialFactorCalculationType.METHODA
            ):
                raise ValueError(
                    "MethodA selected for VERIFY_SHEETPILING calculation. Calculation_per_stage options should not be defined or the type of calculation should be switch to MethodB"
                )
        else:
            raise ValueError(
                "Calculation_per_stage should only be defined if VERIFY_SHEETPILING calculation was selected."
            )
        return True

    def set_unit_weight_water(self, unit_weight: float) -> None:
        """Sets the unit weight for water in the [WATER] block"""
        self.datastructure.input_data.water = Water(unit_weight_of_water=unit_weight)

    def add_stage(
        self,
        name: str,
        passive_side: PassiveSide,
        method_left: LateralEarthPressureMethodStage,
        method_right: LateralEarthPressureMethodStage,
        pile_top_displacement: float = 0.0,
    ) -> int:
        """Add a new stage to the model.

        When using a Single Pile model, the lateral earth pressure method left and right need to be the same.
        Their inputs however do not effect the outcome of the calculation.

        Args:
            name: Name of the stage.
            passive_side: Option to set the passive side for the stage.
            method_left: LateralEarthPressureMethodStage applied to left side, must be compatible with the Model LateralEarthPressureMethod
            method_right: LateralEarthPressureMethodStage applied to right side, must be compatible with the Model LateralEarthPressureMethod
            pile_top_displacement: Pile top displacement [m]. When not provided, Use top displacement will be set to false.

        Raises:
            ValidationError: when input arguments are not within constraints

        Returns:
            int: Stage id which can be used to modify the stage.
        """
        new_stage_id = self.current_stage + 1 if self.current_stage is not None else 0
        self.datastructure.input_data.add_stage(
            name, passive_side, method_left, method_right, pile_top_displacement
        )
        self.current_stage = new_stage_id
        return new_stage_id

    def add_calculation_options_per_stage(
        self, calculation_options_per_stage: CalculationOptionsPerStage, stage_id: int
    ) -> None:
        """Set calculation options per stage.

        Calculation options per stage are set in [CALCULATION OPTIONS PER STAGE].

        Args:
            calculation_options_per_stage: Calculation options for stage
            stage_id: Settings are set to this stage. This refers to the Pythonic input and has a starting point of 0.

        Raises:
            ValueError: When non-existing stage_id is passed or when no
                        CalculationOptionsPerStage are required.
        """
        if self._is_calculation_per_stage_required():
            self._check_if_stage_id_exists(stage_id)
            self.datastructure.input_data.add_calculation_options_per_stage(
                input_calc_options=calculation_options_per_stage,
                stage_id=stage_id,
            )
        else:
            raise ValueError(
                f"calculation_options_per_stage is not required for stage {stage_id}."
            )

    def _get_default_stage_none_provided(self, stage_id: int | None) -> int:
        stage_id = stage_id if stage_id is not None else self.current_stage
        if stage_id is None:
            raise ValueError("No stages available yet: add a stage first.")
        self._check_if_stage_id_exists(stage_id)
        return stage_id

    def _check_if_stage_id_exists(self, stage_id: int) -> None:
        if not self.datastructure.input_data.has_stage(stage_id):
            raise ValueError(
                f"Stage {stage_id} is not added to the internal datastructure"
            )

    def set_model(self, model: BaseModelType) -> None:
        """(Re)Set BaseModelType (Pile, Sheet, Wall) for model."""
        if not issubclass(type(model), BaseModelType):
            raise ValueError(
                f"model should be of subtype BaseModelType, received {model}"
            )

        self.datastructure.input_data.set_model(**model.to_internal(), model=model.model)

    def set_calculation_options(self, calculation_options: CalculationOptions) -> None:
        """Set calculation options.

        Overall calculation options are set in [CALCULATION OPTIONS].

        Args:
            calculation_options: Calculation options
        """
        if not issubclass(type(calculation_options), CalculationOptions):
            raise ValueError(
                f"model should be of subtype CalculationOptions, received {calculation_options}"
            )
        self.datastructure.input_data.set_calculation_options(
            **calculation_options.model_dump()
        )

    def set_curve_settings(
        self,
        curve_settings: CurveSettings,
    ):
        """Set curve settings for soil profiles.

        Curve settings are set in  [SOIL PROFILES].

        Args:
            curve_settings: Curvesettings
        """
        self.datastructure.input_data.set_curve_settings(curve_settings)

    def add_head_line(
        self,
        water_level: WaterLevel,
        side: Side,
        stage_id: int,
    ) -> None:
        """Set water level for a stage.

        If a water level already exists, it is refered to that water level.

        Related to the [WATERLEVELS] block in the .shi file.

        Args:
            water_level: WaterLevel.
            side: Side which determines on which side the surface is added.
            stage_id: WaterLevel is added to this stage.

        Raises:
            ValueError: When non-existing stage_id is passed.
        """
        self._check_if_stage_id_exists(stage_id)
        self.datastructure.input_data.add_water_level(
            stage_id, water_level.to_internal(), side=side
        )

    def add_surface(
        self,
        surface: Surface,
        side: Side,
        stage_id: int,
    ) -> None:
        """Set surface for a stage.

        Surface is added to [SURFACES] if not yet added; reference is done by name.
        A reference in [CONSTRUCTION STAGES] is updated.

        Args:
            surface: Surface.
            side: Side which determines on which side the surface is added.
            stage_id: Surface is added to this stage.

        Raises:
            ValueError: When non-existing stage_id is passed.
        """
        self._check_if_stage_id_exists(stage_id)
        self.datastructure.input_data.add_surface(
            stage_id, surface.to_internal(), side=side
        )

    def add_profile(
        self,
        profile: SoilProfile,
        side: Side,
        stage_id: int,
    ) -> None:
        """Add a Profile on the left or right side of a stage.

        Profile is added to [SOIL PROFILES] if not yet added; reference is done by name.
        A reference in [CONSTRUCTION STAGES] is updated.

        Args:
            profile: Profile.
            side: Side which determines on which side the profile is added.
            stage_id: Surface is added to this stage.

        Raises:
            ValueError: When non-existing stage_id is passed.
        """
        self._check_if_stage_id_exists(stage_id)
        self.datastructure.input_data.add_profile(
            stage_id, profile.to_internal(), side=side
        )

    def set_construction(
        self, top_level: float, elements: list[Sheet | DiaphragmWall | Pile]
    ) -> None:
        """Sets construction for the DSheetPilingModel.

        Elements are added to [SHEET PILING].
        Removes current sheetpiling when called.

        Args:
            top_level: Top level of the sheet piling.
            elements: List of sheet piling elements (can be Sheet, DiaphragmWall, or Pile elements) which are sorted on sheetpilingelementlevel.
        """
        self.datastructure.input_data.set_construction(
            top_level=top_level, elements=[element.to_internal() for element in elements]
        )

    def set_vertical_balance(self, vertical_balance: VerticalBalance) -> None:
        """Sets the vertical balance parameters

        The parameters are set in [VERTICAL BALANCE]

        Args:
            vertical_balance: VerticalBalance, holds the vertical balance parameters
        """
        self.datastructure.input_data.set_vertical_balance(
            vertical_balance=vertical_balance.to_internal()
        )

    def add_load(
        self,
        load: Moment | HorizontalLineLoad | NormalForce | SoilDisplacement | UniformLoad,
        stage_id: int,
    ):
        """Adds other loads of type Moment, HorizontalLineLoad, NormalForce, SoilDisplacement or UniformLoad

        Args:
            load: Add a load with the types of Moment, HorizontalLineLoad, NormalForce, SoilDisplacement or UniformLoad.
            stage_id: Load is added to this stage.

        Note: SoilDisplacement and UniformLoad are only valid for a sheetpiling construction.

        Raises:
            ValueError: When non-existing stage_id is passed.
            ValueError: When a verification calculation is selected but duration_type and load_type are not defined for the load.
        """
        self._check_if_stage_id_exists(stage_id)
        self.datastructure.input_data.add_load(load=load.to_internal(), stage_id=stage_id)

    def add_surcharge_load(self, load: SurchargeLoad, side: Side, stage_id: int) -> None:
        """Add surcharge load to a stage.

        Args:
            load: SurchargeLoad.
            side: Side which determines on which side the load is added.
            stage_id: SurchareLoad is added to this stage.

        Raises:
            ValueError: When non-existing stage_id is passed.
        """
        self._check_if_stage_id_exists(stage_id)
        self.datastructure.input_data.add_surcharge_load(
            stage_id, load.to_internal(), side=side
        )

    def add_anchor_or_strut(
        self,
        support: Anchor | Strut,
        stage_id: int,
        pre_stress: PositiveFloat = 0,
    ) -> None:
        """Add anchor or strut to a stage.

        Anchor and Strut supports are only available for the sheetpiling model.

        Args:
            support: Anchor and Strut.
            pre_stress: Prestress for the support which is added to a construction stage.
                        This is a tension stress for the anchor and a compression stress for the strut.
            stage_id: Support is added to this stage.
        """
        self._check_if_stage_id_exists(stage_id)

        if isinstance(self.model_type, str):
            raise ValueError("Cannot read model type when it's not yet set; set model.")
        if self.model_type == ModelType.SINGLE_PILE:
            raise ValueError(
                f"Only spring and rigid supports can be used for the single pile model; received {support}"
            )

        if isinstance(support, Anchor):
            self.datastructure.input_data.add_anchor(
                stage_id, support.to_internal(), pre_tension=pre_stress
            )
        elif isinstance(support, Strut):
            self.datastructure.input_data.add_strut(
                stage_id, support.to_internal(), pre_compression=pre_stress
            )
        else:
            raise ValueError(f"support should be Anchor or Strut, received {support}")

    def add_support(
        self,
        support: SpringSupport | RigidSupport,
        stage_id: int,
    ) -> None:
        """Add spring or rigid support to a stage.

        Args:
            support: SpringSupport or RigidSupport.
            stage_id: ID of the stage. Of no ID is provided, the current stage ID will be taken.
        """
        self._check_if_stage_id_exists(stage_id)
        if isinstance(support, SpringSupport):
            self.datastructure.input_data.add_spring_support(
                stage_id,
                support.to_internal(),
            )
        elif isinstance(support, RigidSupport):
            self.datastructure.input_data.add_rigid_support(
                stage_id,
                support.to_internal(),
            )
        else:
            raise ValueError(
                f"support should be SpringSupport or RigidSupport, received {support}"
            )

    def add_soil(self, soil: Soil) -> str:
        """Soil is converted in the internal structure and added in soil_collection."""
        soil_internal = soil._to_dsheetpiling()
        new_soil = self.datastructure.input_data.soil_collection.add_soil_if_unique(
            soil_internal
        )
        return new_soil.name

    @property
    def sheets(self):
        """Enables easy access to the sheets in the internal dict-like
        datastructure.

        Also enables edit/delete for individual Sheets.
        """

    @property
    def profiles(self):
        """Enables easy access to the profiles in the internal dict-like
        datastructure.

        Also enables edit/delete for individual Profiles.
        """
