from collections import OrderedDict, namedtuple
from enum import Enum

from geogst.plots.parameters import *


class DepthReference(Enum):
    """
    The datum that depth values in a source field are measured downwards from,
    when a layer stores depths (e.g. hypocentral depths) instead of elevations.

    SEA_LEVEL: elevation = -depth.
    TOPOGRAPHIC_SURFACE: elevation = (DEM elevation at the point) - depth.
    """
    SEA_LEVEL = 1
    TOPOGRAPHIC_SURFACE = 2


# depth unit label -> length of one unit, in the metres the profiles are built in.
# Seismic catalogues quote hypocentral depths in km, hence the ordering.
depth_units = OrderedDict(
    [
        ("km", 1000.0),
        ("m", 1.0),
    ]
)

depth_references = OrderedDict(
    [
        ("sea level", DepthReference.SEA_LEVEL),
        ("topographic surface (DEM)", DepthReference.TOPOGRAPHIC_SURFACE),
    ]
)


PointsProjectionParameters = namedtuple(
    'PointsProjectionParameters',
    [
        "point_layer",
        "label_field_name",
        "z_attribute_choice",
        "z_field_name",
        "points_are_3d",
        "max_profile_distance",
        # depth handling: only meaningful when the z value comes from a field or
        # from the point geometry, since a DEM always yields an elevation
        "z_is_depth",
        "depth_reference",
        "depth_unit_factor",
        "dem_layer",  # required only for DepthReference.TOPOGRAPHIC_SURFACE
    ]
)



AttitudesProjectionParameters = namedtuple(
'AttitudesProjectionParameters',
    [
      "point_layer",
      "label_field_name",
      "use_dipdir",
      "use_rhr_strike",
      "orient_fld_name",
      "dipangle_fld_name",
      "dem_choice",
      "dem_layer",
      "z_attribute_choice",
      "z_field_name",
      "points_are_3d",
      "max_profile_distance",
    ]
)

FocalMechanismsProjectionParameters = namedtuple(
'FocalMechanismsProjectionParameters',
    [
      "point_layer",
      "label_field_name",
      "use_dipdir",
      "use_rhr_strike",
      "orient_fld_name",
      "dipangle_fld_name",
      "rake_fld_name",
      "dem_choice",
      "dem_layer",
      "z_attribute_choice",
      "z_field_name",
      "points_are_3d",
      "max_profile_distance",
      # depth handling, as for PointsProjectionParameters
      "z_is_depth",
      "depth_reference",
      "depth_unit_factor",
    ]
)

LinesIntersectionParameters = namedtuple(
    'LinesIntersectionParameters',
    [
        "line_layer",
        "id_field_ndx",
    ]
)

PolygonsIntersectionParameters = namedtuple(
    'PolygonsIntersectionParameters',
    [
        "polygon_layer",
    ]
)

ltMarkerStyles = OrderedDict(
    [
        ("circle", "o"),
        ("square", "s"),
        ("diamond", "D"),
        ("triangle", "^"),
        ("star", "*"),
        ("plus", "+")
    ]
)

class GraphicalParameters:

    def __init__(self):

        self.figure = FigureParams()
        self.axis = AxisPlotParams()
        self.elevations = ElevationPlotParams()
        self.point_projections = PointPlotParams()
        self.attitude_projections = AttitudePlotParams()
        self.focal_mechanism_projections = FocalMechanismPlotParams()
        self.line_intersections = PointPlotParams()
        self.polygon_intersections = None

    def to_dict(self) -> dict:
        """
        Renders the parameters as JSON-serialisable data.

        The style objects hold values JSON has no notion of — enum members and
        the RGB tuples the colour buttons produce — so they are tagged on the
        way out and rebuilt on the way in, rather than dumped directly.
        """

        sections = {}

        for name in GRAPHICAL_PARAMS_SECTIONS:
            sections[name] = _encode_style(getattr(self, name, None))

        sections["polygon_intersections"] = _encode_value(self.polygon_intersections)

        return {
            "format": GRAPHICAL_PARAMS_FORMAT,
            "version": GRAPHICAL_PARAMS_VERSION,
            "sections": sections,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "GraphicalParameters":
        """
        Rebuilds the parameters from :meth:`to_dict` output.

        Each section starts from a default instance and only the attributes it
        actually defines are overwritten, so a file written by an older version
        (one with no focal mechanism styles, say) loads with the new defaults
        filled in instead of failing.
        """

        if not isinstance(data, dict) or data.get("format") != GRAPHICAL_PARAMS_FORMAT:
            raise Exception("Not a qgSurf graphical parameters file")

        version = data.get("version")
        if version != GRAPHICAL_PARAMS_VERSION:
            raise Exception(
                f"Unsupported graphical parameters version {version!r} "
                f"(this version reads {GRAPHICAL_PARAMS_VERSION})"
            )

        sections = data.get("sections") or {}

        params = cls()

        for name in GRAPHICAL_PARAMS_SECTIONS:
            stored = sections.get(name)
            if isinstance(stored, dict):
                _apply_style(getattr(params, name), stored)

        params.polygon_intersections = _decode_value(sections.get("polygon_intersections"))

        return params


GRAPHICAL_PARAMS_FORMAT = "qgSurf.geoprofiler.graphical_parameters"
GRAPHICAL_PARAMS_VERSION = 1

# style sections, i.e. every attribute of GraphicalParameters that is a style
# object; polygon_intersections is plain data and is handled on its own
GRAPHICAL_PARAMS_SECTIONS = (
    "figure",
    "axis",
    "elevations",
    "point_projections",
    "attitude_projections",
    "focal_mechanism_projections",
    "line_intersections",
)

# enum members are stored by name, so the files stay readable and survive a
# renumbering of the enums themselves
_SERIALISABLE_ENUMS = {
    enum_class.__name__: enum_class
    for enum_class in (PointLikePlotTypes, FocalMechanismView, DepthReference)
}

# derived from the style class rather than chosen by the user: rebuilt by the
# constructor, so storing it would only let a stale file override it
_NON_STYLE_ATTRIBUTES = frozenset(["plot_type"])


def _encode_value(value):
    """Tags the values JSON cannot represent on its own."""

    if isinstance(value, Enum):
        return {"__enum__": type(value).__name__, "name": value.name}

    if isinstance(value, tuple):
        # colours round-trip as tuples, not as the lists JSON would give back
        return {"__tuple__": [_encode_value(item) for item in value]}

    if isinstance(value, list):
        return [_encode_value(item) for item in value]

    if isinstance(value, dict):
        # as pairs, so that non-string keys (polygon categories may be numbers)
        # survive: JSON object keys are always strings
        return {"__dict__": [[_encode_value(k), _encode_value(v)] for k, v in value.items()]}

    return value


def _decode_value(value):
    """Inverse of :func:`_encode_value`."""

    if isinstance(value, dict):

        if "__enum__" in value:
            enum_class = _SERIALISABLE_ENUMS.get(value["__enum__"])
            if enum_class is None:
                raise Exception(f"Unknown enumeration {value['__enum__']!r}")
            return enum_class[value["name"]]

        if "__tuple__" in value:
            return tuple(_decode_value(item) for item in value["__tuple__"])

        if "__dict__" in value:
            return {_decode_value(k): _decode_value(v) for k, v in value["__dict__"]}

        return {k: _decode_value(v) for k, v in value.items()}

    if isinstance(value, list):
        return [_decode_value(item) for item in value]

    return value


def _encode_style(style) -> dict:
    """Renders a style object as a mapping of its own attributes."""

    if style is None:
        return {}

    return {
        name: _encode_value(value)
        for name, value in vars(style).items()
        if name not in _NON_STYLE_ATTRIBUTES
    }


def _apply_style(style, stored: dict) -> None:
    """
    Overwrites in place the attributes the style object actually defines.

    Anything else in the file is ignored, so a style written by a newer version
    does not inject stray attributes into an older one.
    """

    if style is None:
        return

    for name, value in stored.items():
        if name in _NON_STYLE_ATTRIBUTES or not hasattr(style, name):
            continue
        setattr(style, name, _decode_value(value))
