#! /usr/bin/env python
# filename: tops_azimut.py
# Copyright 2022 Jean-Marie Arsac <jmarsac@azimut.fr>
#
# This file is part of Total Open Station.
#
# Total Open Station is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Total Open Station is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Total Open Station.  If not, see
# <http://www.gnu.org/licenses/>.

import math

from . import Builder


class OutputFormat(Builder):
    """Export :class:`Feature` objects to the Topaze ``.obs`` format."""

    def __init__(self, data):
        self.data = data

    @staticmethod
    def _has_value(value):
        return value is not None and value != "" and value != "?"

    @classmethod
    def _point_has_coordinates(cls, point):
        if point is None:
            return False
        x = getattr(point, "x", None)
        y = getattr(point, "y", None)
        if x is None or y is None:
            return False
        try:
            if not math.isfinite(float(x)) or not math.isfinite(float(y)):
                return False
        except (TypeError, ValueError):
            return False

        # UNKNOWN_POINT is represented by (-1, -1, -1) in Total Open Station.
        z = getattr(point, "z", None)
        if x == -1 and y == -1 and (z is None or z == -1):
            return False
        return True

    @classmethod
    def _coordinate_observation(cls, key, name, point, extra=None):
        if not name or not cls._point_has_coordinates(point):
            return ""
        fields = [f"{key}={name}", f"x={point.x}", f"y={point.y}"]
        z = getattr(point, "z", None)
        if z is not None:
            fields.append(f"z={z}")
        if extra:
            fields.extend(extra)
        return " ".join(fields)

    def process(self):
        lines = []
        for feature in self.data:
            properties = feature.properties
            row = {
                "type": feature.desc,
                "pnt": properties.get("point_name"),
                "st": properties.get("st_name") or properties.get("station_name"),
                "ref": properties.get("ref_name"),
                "hi": properties.get("ih"),
                "hp": properties.get("th"),
                "v0": properties.get("v0"),
                "hz0": properties.get("hz0"),
                "code": properties.get("code"),
                "infos": properties.get("attrib"),
                "av": properties.get("z_angle"),
                "di": None,
                "dh": None,
                "dz": properties.get("z_distance"),
            }

            # Historical parsers expose the station orientation as ``hz0``.
            # In a station record this value has the same meaning as Topaze's
            # ``v0``; keep ``hz0`` unchanged on reference records.
            if (
                row["type"] in {"ST", "S"}
                and not self._has_value(row["v0"])
                and self._has_value(row["hz0"])
            ):
                row["v0"] = row["hz0"]

            # Some parsers expose an absolute azimuth, while others expose the
            # measured horizontal circle reading as ``angle``.
            azimuth = properties.get("azimuth")
            angle = properties.get("angle")
            row["ah"] = azimuth if self._has_value(azimuth) else angle

            if self._has_value(row["ah"]) and row["code"] == "REF":
                row["type"] = "R"

            dist_type = properties.get("dist_type")
            distance = properties.get("dist")
            if dist_type == "s":
                row["di"] = distance
            elif dist_type == "h":
                row["dh"] = distance

            # A few formats provide only horizontal and vertical distances.
            # Preserve ``dh`` but derive missing slope distance / zenith angle
            # when enough data is available.
            if (
                dist_type == "h"
                and self._has_value(row["dh"])
                and self._has_value(row["dz"])
            ):
                horizontal_distance = float(row["dh"])
                vertical_distance = float(row["dz"])
                if not self._has_value(row["di"]):
                    row["di"] = round(
                        math.hypot(horizontal_distance, vertical_distance), 4
                    )
                if not self._has_value(row["av"]):
                    row["av"] = round(
                        math.atan2(horizontal_distance, vertical_distance)
                        / math.pi
                        * 200.0,
                        4,
                    )

            observation = self.to_obs(feature.geometry, row)
            if observation:
                lines.extend(observation.splitlines())

        return "\n".join(lines)

    def _reference_coordinate_observation(self, point, row):
        extras = []
        if self._has_value(row.get("hz0")):
            extras.append(f"hz0={row['hz0']}")
        return self._coordinate_observation("ref", row.get("ref"), point, extra=extras)

    def _measurement_observation(self, key, name, row):
        if not name:
            return ""
        if not any(
            self._has_value(row.get(field)) for field in ("ah", "av", "di", "dh", "dz")
        ):
            return ""
        fields = [f"{key}={name}"]
        for field in ("ah", "av"):
            if self._has_value(row.get(field)):
                fields.append(f"{field}={row[field]}")

        if self._has_value(row.get("di")):
            fields.append(f"di={row['di']}")
        elif self._has_value(row.get("dh")):
            fields.append(f"dh={row['dh']}")

        if self._has_value(row.get("dz")) and not self._has_value(row.get("av")):
            fields.append(f"dz={row['dz']}")
        if self._has_value(row.get("hp")):
            fields.append(f"hp={row['hp']}")
        return " ".join(fields)

    def to_obs(self, point, row):
        lines = []
        row_type = row.get("type")

        if row_type in {"ST", "S"}:
            station_name = row.get("st") or row.get("pnt")
            if station_name:
                fields = [f"st={station_name}"]
                if self._has_value(row.get("hi")):
                    fields.append(f"hi={row['hi']}")
                if self._has_value(row.get("v0")):
                    fields.append(f"v0={row['v0']}")
                lines.append(" ".join(fields))
                coordinate = self._coordinate_observation("st", station_name, point)
                if coordinate:
                    lines.append(coordinate)

        elif row_type in {"R"}:
            reference_name = row.get("ref") or row.get("pnt")
            row["ref"] = reference_name
            measurement = self._measurement_observation("ref", reference_name, row)
            if measurement:
                lines.append(measurement)
            coordinate = self._reference_coordinate_observation(point, row)
            if coordinate:
                lines.append(coordinate)

        elif row_type in {"PT", "PO", "P"}:
            point_name = row.get("pnt")
            has_measurement = any(
                self._has_value(row.get(field))
                for field in ("ah", "av", "di", "dh", "dz")
            )
            if point_name and has_measurement:
                measurement = self._measurement_observation("pnt", point_name, row)
                if measurement:
                    lines.append(measurement)
            else:
                coordinate = self._coordinate_observation("pnt", point_name, point)
                if coordinate:
                    lines.append(coordinate)

        elif row_type == "CO":
            infos = row.get("infos")
            if self._has_value(infos):
                if isinstance(infos, str):
                    lines.append(infos.replace(" ", ";"))
                else:
                    lines.append(str(infos))

        elif self._has_value(row.get("infos")):
            lines.append(f"infos={row['infos']}")

        code = row.get("code")
        if self._has_value(code) and str(code).strip():
            lines.append(f"code={code}")

        return "\n".join(lines)
