"""
NATURESCAPES shoreline analysis package.
"""

import time

import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from shapely import make_valid, reverse
from shapely.geometry import (
    LinearRing,
    LineString,
    MultiLineString,
    Polygon,
)
from shapely.ops import linemerge

from .errors import CrsError, InputError, SlopeError
from .utils.clipping import clip_rays_by_polygon, remove_intersecting_lines
from .utils.perpendicular import calculate_slopes
from .utils.resolution import redistribute_vertices
from .utils.shoal_break import line_of_breaking, line_of_shoaling
from .utils.smoother import cast_smoothed_rays


class AnalysisResult:
    """Result of a ShorelineAnalyzer evaluation"""

    def __init__(
        self,
        crs,
        lat_crs,
        hat_crs,
        lat,
        hat,
        period,
        height,
        res,
        lat_line,
        shore_line,
        slopes,
        shoaling,
        breaking,
        intertidal,
    ):
        super().__init__()
        self.metadata = dict()
        self.metadata["analysis_crs"] = crs
        self.metadata["timestamp"] = int(time.time())
        self.metadata["lat_crs"] = lat_crs
        self.metadata["hat_crs"] = hat_crs
        self.metadata["lat"] = lat
        self.metadata["hat"] = hat
        self.metadata["wave_period"] = period
        self.metadata["wave_height"] = height
        self.metadata["ray_resolution"] = res
        self.lat_line = lat_line
        self.shore_line = shore_line
        self.slopes = slopes
        self.metrics = self._intertidal_gradient_metrics()
        self.shoaling = shoaling
        self.breaking = breaking
        self.intertidal = intertidal

    def _intertidal_gradient_metrics(self):
        """
        Calculate various intertidal gradient metrics from pre-calculated ray data.

        Parameters
        ----------
        self.slopes : GeoDataFrame
            A GeoDataFrame containing ray information with the following columns:
            - geometry: LineString geometry of each ray
            - length: 2D length of the ray
            - start_depth: depth at the offshore end (LAT)
            - end_depth: depth at the onshore end (HAT)
            - slope: raw slope value
            - slope_degrees: slope in degrees
            - slope_radians: slope in radians

        Returns
        -------
        dict
            Dictionary containing various slope metrics

        References
        ----------
        - Short, A. D., & Wright, L. D. (1983). Physical variability of sandy beaches.
          In Sandy beaches as ecosystems (pp. 133-144). Springer, Dordrecht.
        - Masselink, G., & Short, A. D. (1993). The effect of tide range on beach
          morphodynamics and morphology: a conceptual beach model. Journal of
          Coastal Research, 9(3), 785-800.
        - Masselink, G., & Hegge, B. (1995). Morphodynamics of meso-and macrotidal
          beaches: examples from central Queensland, Australia. Marine Geology, 129(1-2), 1-23.
        - Plant, N. G., Holland, K. T., & Puleo, J. A. (2002). Analysis of the scale of
          errors in nearshore bathymetric data. Marine Geology, 191(1-2), 71-86.
        - Hegge, B., Eliot, I., & Hsu, J. (1996). Sheltered sandy beaches of southwestern
          Australia. Journal of Coastal Research, 12(3), 748-760.

        """
        # Extract relevant data from the GeoDataFrame
        slopes = self.slopes["slope"].values
        slope_angles = self.slopes["slope_degrees"].values
        distances = self.slopes["length"].values

        # Calculate ray lengths in 3D space (using Pythagorean theorem with vertical distance)
        # Note: This assumes start_depth and end_depth are in the same units as length
        vertical_distances = np.abs(
            self.slopes["end_depth"].values - self.slopes["start_depth"].values
        )
        ray_lengths = np.sqrt(distances**2 + vertical_distances**2)

        # Use a common vertical distance for overall metrics (assume it's constant as per your description)
        vert_dist = np.abs(
            self.slopes["end_depth"].iloc[0] - self.slopes["start_depth"].iloc[0]
        )

        # Calculate intertidal gradient metrics

        # 1. Basic intertidal gradient (Short & Wright, 1983 approach)
        basic_intertidal_gradient = vert_dist / np.mean(distances)
        basic_intertidal_angle = np.degrees(np.arctan(basic_intertidal_gradient))

        # 2. Weighted intertidal gradient (Plant et al., 2002 inspired approach)
        # Weight by ray length to give more importance to longer transects
        weighted_intertidal_gradient = vert_dist / np.average(
            distances, weights=ray_lengths
        )
        weighted_intertidal_angle = np.degrees(np.arctan(weighted_intertidal_gradient))

        # 3. Segment-based calculations (Masselink & Hegge, 1995 inspired)
        # In Masselink & Hegge (1995), segment gradients consider local morphology
        # Since we already have pre-calculated slopes, we'll differentiate this by
        # using a distance-normalized calculation that gives more weight to steeper
        # short segments (which is more aligned with their approach)

        # The original differentiation in the literature typically comes from:

        # Mean slope: A simple statistical measure not accounting for spatial relationships
        # Segment gradient: Accounts for how local morphology contributes to overall coastal character

        # The inverse distance weighting approach implemented here is consistent with coastal
        # geomorphology literature, where shorter, steeper segments often indicate important
        # morphological features (like scarps or berms) that have disproportionate influence on
        # processes like wave reflection and erosion.
        # This modification ensures the two metrics will now provide different values, with mean
        # segment gradient giving more emphasis to steep short rays, which is conceptually aligned
        # with the approach described in Masselink & Hegge (1995).
        segment_weights = 1.0 / distances  # Inverse distance weighting
        mean_segment_gradient = np.average(slopes, weights=segment_weights)
        # also apply IDW approach here
        mean_segment_angle = np.average(slope_angles, weights=segment_weights)

        # TODO: revisit this when you aren't exhausted
        # 4. Area-weighted approach
        # Calculate the area between adjacent rays using the constant ray resolution
        #
        # Note on area-weighted metrics with constant ray spacing:
        # Even with constant ray spacing, this approach remains valid and useful because:
        # 1. The areas still vary based on the lengths of adjacent rays
        # 2. It weights coastal segments based on their spatial extent (area)
        # 3. It gives more importance to wider sections of the beach
        # 4. It differs from length-weighted metrics by considering adjacent pairs of rays
        #    rather than individual rays, better representing the actual coastal surface
        #
        # This is particularly relevant for:
        # - Sediment volume considerations
        # - Wave energy distribution calculations
        # - Potential flooding impact assessments
        # - Any analysis where the spatial extent of coastal segments matters
        #
        # Reference: This approach is conceptually similar to area-weighted methods used
        # in Hegge et al. (1996) and later coastal morphology studies that consider the
        # contribution of different beach segments to overall character.

        # Calculate the area between adjacent rays using the (constant!) ray resolution
        # Calculate approximate areas between adjacent rays (trapezoidal)
        areas = []
        for i in range(len(distances) - 1):
            # Calculate trapezoidal area using constant ray resolution
            area = (
                0.5
                * (distances[i] + distances[i + 1])
                * self.metadata["ray_resolution"]
            )
            areas.append(area)
        # Use areas as weights for appropriate segments
        if areas and len(slopes) > 1:  # Ensure we have data
            area_weighted_gradient = np.average(slopes[:-1], weights=areas)
            area_weighted_angle = np.degrees(np.arctan(area_weighted_gradient))
        else:
            area_weighted_gradient = np.nan
            area_weighted_angle = np.nan

        # Calculate various statistical metrics
        metrics = {
            # Basic statistics
            "mean_slope": np.mean(slopes),
            "median_slope": np.median(slopes),
            "std_slope": np.std(slopes),
            "percentiles": np.percentile(slopes, [10, 25, 50, 75, 90]),
            # Angle-based statistics
            "mean_angle": np.mean(slope_angles),
            "median_angle": np.median(slope_angles),
            "std_angle": np.std(slope_angles),
            # Length-weighted statistics
            "length_weighted_mean_slope": np.average(slopes, weights=ray_lengths),
            "length_weighted_mean_angle": np.average(slope_angles, weights=ray_lengths),
            # Intertidal gradient metrics (various approaches)
            "basic_intertidal_gradient": basic_intertidal_gradient,
            "basic_intertidal_angle": basic_intertidal_angle,
            "weighted_intertidal_gradient": weighted_intertidal_gradient,
            "weighted_intertidal_angle": weighted_intertidal_angle,
            "mean_segment_gradient": mean_segment_gradient,
            "mean_segment_angle": mean_segment_angle,
            "area_weighted_gradient": area_weighted_gradient,
            "area_weighted_angle": area_weighted_angle,
            # Variation metrics
            "slope_variation_coef": np.std(slopes) / np.mean(slopes)
            if np.mean(slopes) > 0
            else np.nan,
            "angle_variation_coef": np.std(slope_angles) / np.mean(slope_angles)
            if np.mean(slope_angles) > 0
            else np.nan,
            # Range metrics
            "max_slope": np.max(slopes),
            "min_slope": np.min(slopes),
            "slope_range": np.max(slopes) - np.min(slopes),
            # Raw data references (for future use)
            "self.slopes_index": self.slopes.index.tolist(),
        }

        return metrics

    def visualize_coastal_slopes(self, plot_indices=True):
        """
        Visualize analyzed coastal slopes with multiple plots showing various metrics

        Parameters
        ----------
        self.slopes : GeoDataFrame
            A GeoDataFrame containing ray information
        self.lat_line : LineString
            The offshore line (LAT)
        self.shore_line : LineString
            The onshore line (HAT)
        self.metrics : dict
            The self.metrics calculated by self.intertidal_gradient_metrics
        plot_indices : bool
            Whether to include plot showing different gradient indices

        Returns
        -------
        tuple
            (fig1, fig2) containing the generated figures

        """
        # Extract slope data
        slope_angles = self.slopes["slope_degrees"].values

        # Create first figure - coastal profile
        fig1, ax1 = plt.subplots(figsize=(10, 7))

        # Plot the offshore and onshore lines
        self.shore_line.plot(ax=ax1, color="#B5B5B5", lw=0.25, zorder=6)
        wd = self.slopes.plot(  # noqa: F841
            ax=ax1,
            column="slope_degrees",
            legend=True,
            legend_kwds={
                "label": "Slope (degrees)",
            },
            zorder=0,
        )

        min_x, max_x, min_y, max_y = self._get_expanded_bounds(500)
        ax1.set_xlim(min_x, max_x)
        ax1.set_ylim(min_y, max_y)

        latp = self.lat_line.plot(  # noqa: F841
            ax=ax1, linestyle="--", color="#39FF14", lw=1.0, label="LAT"
        )
        shoaling = self.shoaling.plot(  # noqa: F841
            ax=ax1, linestyle="-", color="#FAD02C", lw=0.5, label="Shoaling", zorder=9
        )
        itz = self.intertidal.plot(  # noqa: F841
            ax=ax1,
            color="white",
            fc="#e60995",
            lw=0.1,
            alpha=0.3,
            hatch="///////////",
            zorder=8,
        )
        breaking = self.breaking.plot(  # noqa: F841
            ax=ax1, color="red", lw=0.5, label="Breaking", zorder=9
        )

        ax1.set_title(
            f"HAT {self.metadata['hat']}, LAT {self.metadata['lat']} , Period: {self.metadata['wave_period']} s, Wave height: {self.metadata['wave_height']} m"
        )
        ax1.axis("off")
        fig1.patch.set_alpha(1)
        fig1.set_dpi(200)
        ax1.patch.set_alpha(1)
        leg = ax1.legend(loc="best")
        leg.set_zorder(10)
        for line in leg.get_lines():
            line.set_linewidth(2.0)
        fig1.tight_layout()

        ax1.set_title(
            f"Coastal Profile with Sampling Rays (Ray resolution: {self.metadata['ray_resolution']} m)"
        )
        ax1.set_aspect("equal")

        # Create second figure - analysis plots
        if plot_indices:
            fig2, axs = plt.subplots(2, 1)
        else:
            fig2, axs = plt.subplots(1, 1)
            axs = [axs]
        fig2.set_dpi(200)

        # Plot 1: Angle distribution histogram
        axs[0].hist(
            slope_angles,
            histtype="stepfilled",
            bins="auto",
            alpha=0.5,
            edgecolor="#3b3b3b",
            # lw=0.5,
            color="#ff4d04",
        )
        hlw = 1.0
        axs[0].axvline(
            self.metrics["mean_angle"],
            lw=hlw,
            color="#000000",
            linestyle="--",
            label=f"Mean: {self.metrics['mean_angle']:.2f}°",
        )
        axs[0].axvline(
            self.metrics["median_angle"],
            lw=hlw,
            color="#0088FF",
            linestyle="--",
            label=f"Median: {self.metrics['median_angle']:.2f}°",
        )
        axs[0].axvline(
            self.metrics["length_weighted_mean_angle"],
            lw=hlw,
            color="r",
            linestyle="--",
            label=f"Weighted Mean: {self.metrics['length_weighted_mean_angle']:.2f}°",
        )

        axs[0].set_title("Slope Angle Distribution")
        axs[0].set_xlabel("Angle (degrees)")
        axs[0].set_ylabel("Frequency")
        axs[0].legend()

        if plot_indices:
            # Plot 2: Comparison of different gradient indices
            indices = [
                ("Basic Intertidal", self.metrics["basic_intertidal_angle"]),
                ("Weighted Intertidal", self.metrics["weighted_intertidal_angle"]),
                ("Mean Segment", self.metrics["mean_segment_angle"]),
                ("Area Weighted", self.metrics["area_weighted_angle"]),
            ]

            names = [x[0] for x in indices]
            values = [x[1] for x in indices]

            plt.rcParams["hatch.linewidth"] = 0.1
            axs[1].bar(
                names,
                values,
                ec="#000000",
                color="#90D5FF",  # we could assign them separately in a list if we wanted to
                lw=1.0,
                hatch="///",
                zorder=2,
            )
            axs[1].set_title("Comparison of Gradient Indices")
            axs[1].set_ylabel("Angle (degrees)")
            axs[1].set_ylim(0, max(values) * 1.2)
            axs[1].grid(axis="y", zorder=1, lw=0.5)

            # for i, v in enumerate(values):
            #     axs[1][0].text(i, v + 0.5, f"{v:.2f}°", ha="center")

        # Show summary statistics table
        print("Summary Statistics:")
        self.summary_stats()

        plt.tight_layout()
        return fig1, fig2

    def summary_stats(self):
        """
        Return summary statistics for the analysed shoreline
        """
        print(
            pd.DataFrame(
                {
                    "Metric": [
                        "Mean Slope",
                        "Median Slope",
                        "Std Deviation",
                        "Mean Angle (°)",
                        "Median Angle (°)",
                        "Basic Intertidal Gradient",
                        "Basic Intertidal Angle (°)",
                        "Weighted Intertidal Gradient",
                        "Weighted Intertidal Angle (°)",
                        "Mean Segment Gradient",
                        "Mean Segment Angle (°)",
                        "Area Weighted Gradient",
                        "Area Weighted Angle (°)",
                        "Slope Variation Coefficient",
                    ],
                    "Value": [
                        self.metrics["mean_slope"],
                        self.metrics["median_slope"],
                        self.metrics["std_slope"],
                        self.metrics["mean_angle"],
                        self.metrics["median_angle"],
                        self.metrics["basic_intertidal_gradient"],
                        self.metrics["basic_intertidal_angle"],
                        self.metrics["weighted_intertidal_gradient"],
                        self.metrics["weighted_intertidal_angle"],
                        self.metrics["mean_segment_gradient"],
                        self.metrics["mean_segment_angle"],
                        self.metrics["area_weighted_gradient"],
                        self.metrics["area_weighted_angle"],
                        self.metrics["slope_variation_coef"],
                    ],
                }
            ).to_string(index=False)
        )

    def _get_expanded_bounds(self, buffer_distance=1000):
        """
        Return a reasonable bounding box for plotting, if lat line and hat line are the inputs
        """
        # Get combined total bounds
        combined_bounds = gpd.GeoDataFrame(
            pd.concat([self.lat_line, self.slopes])
        ).total_bounds

        # Expand by buffer distance
        min_x = combined_bounds[0] - buffer_distance
        min_y = combined_bounds[1] - buffer_distance
        max_x = combined_bounds[2] + buffer_distance
        max_y = combined_bounds[3] + buffer_distance

        return min_x, max_x, min_y, max_y


class ShorelineAnalyzer:
    """
    Shoreline steepness analysis

    Args:
        crs: like "EPSG:25829". CRS must be projected
        shoreline: LineString representing the line of highest astronomical tide (HAT), or its best approximation
        tideline: LineString representing the line of lowest astronomical tide (LAT), or its best approximation
        hat: height of the highest astronomical tide. Should be given as a positive value above mean sea level
        lat: depth of the lowest astronomical tide. Should be given as a positive value below mean sea level
        wave_period: the duration between two waves, in seconds
        wave_height: the height of the wave
        ray_resolution: this will cast a ray from sea to shore every [resolution] meters. 50 by default
        ray_length: distance in meters to ensure that rays intersect the shoreline. 10000 by default
        smoothing_window: window to use for smoothing the ray angle. Values between 250 and 500 will
        suffice for most applications. 250 by default
        direction: direction in which to cast rays, relative to the tideline.
        Specify "west" to cast rays east to west / south to north, or "east" to cast rays west to east / north to south. Defaults to "west"

    Notes:
        The CRS of your input geometries does NOT have to be a projected CRS (i.e. GeoJSON is fine)
        and they do NOT need to match: Input geometries will be re-projected to your specified input CRS

    """

    def __init__(
        self,
        crs,
        shoreline,
        tideline,
        hat,
        lat,
        wave_period,
        wave_height,
        ray_resolution=50.0,
        ray_length=10000,
        smoothing_window=250.0,
        direction="west",
    ):
        """
        Initialize the ShorelineAnalyzer.
        """
        if crs in ["EPSG:3857", "EPSG:4326"]:
            err = "You must use a projected CRS. We're not good at spherical trig"
            raise CrsError(err)
        self.crs = crs
        self.ray_resolution = ray_resolution

        self.shoreline = gpd.read_file(shoreline)
        self.hat_crs = self.shoreline.crs
        self.shoreline.to_crs(crs, inplace=True)

        # populated by _build_lat
        self.lat_crs = None
        self.tideline = self._build_lat(tideline)
        self.tideline = redistribute_vertices(self.tideline, self.ray_resolution)

        if hat <= lat:
            err = f"HAT ({hat}) cannot be equal to or lower than LAT ({lat}). The implications for what a shoreline is would be huge"
            raise SlopeError(err)
        if hat <= 0:
            err = "HAT must be a positive value, usually given above MSL"
            raise InputError(err)
        self.hat = hat
        if lat >= 0:
            err = "LAT must be a negative value, usually given below MSL"
            raise InputError(err)
        self.lat = lat
        if wave_period <= 0.0:
            err = f"The wave period must be a positive value in this physical universe. {wave_period} is not"
            raise InputError(err)
        self.wave_period = wave_period
        if wave_height <= 0.0:
            err = "While waves certainly have a subsurface component, we require the height above the sea surface. That is a positive value"
            raise InputError(err)
        self.wave_height = wave_height

        if ray_length <= 0.0:
            err = "Ray length must be positive, in meters"
            raise InputError(err)
        self.ray_length = ray_length
        if smoothing_window <= 0.0:
            err = "The smoothing window should be positive. Try 250.0, unless you're sure you know what you're doing"
            raise InputError(err)
        self.smoothing_window = smoothing_window
        if direction.lower() not in ["east", "west"]:
            err = "You must specify 'east' or 'west'"
            raise InputError(err)
        self.direction = direction.lower()

        self.log = {"timestamp": int(time.time())}

        # intermediate products
        self.rays = None
        self.trimmed = None
        self.shoal_line = None
        self.break_line = None
        self.intertidal = None

    def _build_lat(self, isobath):
        geoms = gpd.read_file(isobath)
        self.lat_crs = geoms.crs
        geoms.to_crs(self.crs, inplace=True)
        isobath = geoms.geometry.union_all()
        if isinstance(isobath, MultiLineString):
            isobath = linemerge(isobath)
        return isobath

    def _cast_rays(self):
        if self.direction != "west":
            right = True
        else:
            right = False
        rays = cast_smoothed_rays(
            self.tideline,
            self.smoothing_window,
            self.ray_length,
            right=right,
        )
        self.rays = gpd.GeoDataFrame(geometry=[ray for ray in rays], crs=self.crs)

    def _trim(self):
        self.trimmed = clip_rays_by_polygon(self.rays, self.shoreline)
        self.trimmed["length"] = self.trimmed.geometry.length

    def _remove_intersections(self):
        self.trimmed = remove_intersecting_lines(self.trimmed)

    def _assign_depths(self):
        self.trimmed["start_depth"] = self.lat
        self.trimmed["end_depth"] = self.hat

    def _assign_slopes(self):
        self.trimmed["slope"] = (
            self.trimmed["end_depth"] - self.trimmed["start_depth"]
        ) / self.trimmed["length"]
        self.trimmed["slope_degrees"] = calculate_slopes(self.trimmed)
        self.trimmed["slope_radians"] = np.deg2rad(self.trimmed["slope_degrees"])

    def _shoaling(self):
        self.shoal_line = line_of_shoaling(self.wave_period, self.trimmed, start=False)

    def _breaking(self):
        self.break_line = line_of_breaking(self.wave_height, self.trimmed)

    def _build_intertidal(self):
        end = LineString(
            [
                self.break_line.geometry.iloc[0].coords[-1],
                self.shoal_line.geometry.iloc[0].coords[-1],
            ]
        )

        start = LineString(
            [
                self.shoal_line.geometry.iloc[0].coords[0],
                self.break_line.geometry.iloc[0].coords[0],
            ]
        )
        lr = Polygon(
            LinearRing(
                (
                    *start.coords,
                    *self.shoal_line.geometry.iloc[0].coords,
                    *end.coords,
                    *reverse(self.break_line.geometry.iloc[0]).coords,
                )
            )
        )
        # fix it up if necessary
        lr = make_valid(lr)
        self.intertidal = gpd.GeoDataFrame(geometry=[lr], crs=self.crs)

    def evaluate(self):
        """
        Perform the shoreline slope analysis, returning an analysis result object

        The object contains:
        metadata
        lat line
        hat line
        calculated rays containing length and slope data
        shoal line
        break line
        intertidal zone

        """
        self._cast_rays()
        self._trim()
        self._assign_depths()
        self._assign_slopes()
        self._shoaling()
        self._breaking()
        self._build_intertidal()
        result = AnalysisResult(
            self.crs,
            self.lat_crs,
            self.hat_crs,
            self.lat,
            self.hat,
            self.wave_period,
            self.wave_height,
            self.ray_resolution,
            gpd.GeoDataFrame(geometry=[self.tideline], crs=self.crs),
            self.shoreline,
            self.trimmed,
            self.shoal_line,
            self.break_line,
            self.intertidal,
        )
        return result


# Expose main class at package level
__all__ = ["ShorelineAnalyzer"]
