"""
/***************************************************************************
 ComputeV0
                                 A QGIS plugin
 Topaze
                             -------------------
        begin                : 2026-07-13
        git sha              : $Format:%H$
        copyright            : (C) 2026 by Jean-Marie ARSAC
        email                : jmarsac@azimut.fr
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program 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 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
"""

from __future__ import annotations

import json
import math
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence

from topaze.file_utils import FileUtils
from topaze.report_helpers import fmt
from topaze.report_utils import ReportUtils
from topaze.toolbelt import PlgLogger
from topaze.toolbelt.i18n import tr
from topaze.topaze_calculator import TopazeCalculator

TEMP_FILENAME = "compute_v0.json"
REPORT_BASENAME = "v0_report"
DEFAULT_TOLERANCE_GON = 0.003


def _as_float(value: Any) -> Optional[float]:
    if value is None or value == "":
        return None
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    if not math.isfinite(number):
        return None
    return number


def _circular_mean_gon(values: Sequence[float]) -> float:
    if not values:
        raise ValueError(tr("Cannot compute a circular mean without values."))

    sum_cos = 0.0
    sum_sin = 0.0
    for value in values:
        angle_rad = value * math.pi / 200.0
        sum_cos += math.cos(angle_rad)
        sum_sin += math.sin(angle_rad)

    if abs(sum_cos) < 1e-15 and abs(sum_sin) < 1e-15:
        raise ValueError(tr("The horizontal readings do not define a mean direction."))

    return TopazeCalculator.wrap_gr_0_400(
        math.atan2(sum_sin, sum_cos) * 200.0 / math.pi
    )


def _weighted_circular_mean_gon(
    values: Sequence[float], weights: Sequence[float]
) -> float:
    if not values:
        raise ValueError(tr("No station orientation can be computed."))
    if len(values) != len(weights):
        raise ValueError(tr("Values and weights must have the same length."))

    sum_cos = 0.0
    sum_sin = 0.0
    for value, weight in zip(values, weights):
        angle_rad = value * math.pi / 200.0
        sum_cos += weight * math.cos(angle_rad)
        sum_sin += weight * math.sin(angle_rad)

    if abs(sum_cos) < 1e-15 and abs(sum_sin) < 1e-15:
        raise ValueError(tr("The selected references do not define an orientation."))

    return TopazeCalculator.wrap_gr_0_400(
        math.atan2(sum_sin, sum_cos) * 200.0 / math.pi
    )


def _normalized_horizontal_reading(observation: Dict[str, Any]) -> Optional[float]:
    """Return a horizontal reading reduced to the direct face convention."""
    ah = _as_float(observation.get("ah"))
    if ah is None:
        return None

    face = str(observation.get("face") or "").strip().upper()
    av = _as_float(observation.get("av"))

    # Topaze's legacy convention identifies the reverse face either explicitly
    # or from a zenithal reading greater than 200 gon.
    if face in {"CD", "D", "RIGHT"} or (not face and av is not None and av > 200.0):
        ah -= 200.0

    return TopazeCalculator.wrap_gr_0_400(ah)


def _point_by_id(data: Dict[str, Any]) -> Dict[str, Dict[str, Optional[float]]]:
    points: Dict[str, Dict[str, Optional[float]]] = {}
    for point in data.get("stations", []):
        matricule = str(point.get("matricule") or "").strip()
        if not matricule:
            continue
        points[matricule] = {
            "x": _as_float(point.get("x")),
            "y": _as_float(point.get("y")),
            "z": _as_float(point.get("z")),
        }
    return points


def _reference_details(data: Dict[str, Any]) -> List[Dict[str, Any]]:
    calculation = data.get("calcul", [])
    if not calculation:
        raise ValueError(tr("No station was selected for the V0 computation."))

    station_id = str(calculation[0])
    selected_references = [str(value) for value in data.get("references", [])]
    if not selected_references:
        raise ValueError(tr("Select at least one reference point."))

    points = _point_by_id(data)
    station = points.get(station_id)
    if not station or station.get("x") is None or station.get("y") is None:
        raise ValueError(
            tr("Station {station} has no known planimetric coordinates.").format(
                station=station_id
            )
        )

    observations_by_reference: Dict[str, List[Dict[str, Any]]] = {
        reference_id: [] for reference_id in selected_references
    }
    for observation in data.get("obs", []):
        if str(observation.get("origine")) != station_id:
            continue
        target_id = str(observation.get("cible") or "")
        if target_id in observations_by_reference:
            observations_by_reference[target_id].append(observation)

    details: List[Dict[str, Any]] = []
    missing: List[str] = []
    station_x = float(station["x"])
    station_y = float(station["y"])

    for reference_id in selected_references:
        reference = points.get(reference_id)
        readings = [
            reading
            for reading in (
                _normalized_horizontal_reading(observation)
                for observation in observations_by_reference.get(reference_id, [])
            )
            if reading is not None
        ]

        if (
            reference is None
            or reference.get("x") is None
            or reference.get("y") is None
            or not readings
        ):
            missing.append(reference_id)
            continue

        reference_x = float(reference["x"])
        reference_y = float(reference["y"])
        ah_mean = _circular_mean_gon(readings)
        bearing = TopazeCalculator.bearing_gon(
            station_x, station_y, reference_x, reference_y
        )
        horizontal_distance = math.hypot(
            reference_x - station_x, reference_y - station_y
        )
        individual_v0 = TopazeCalculator.wrap_gr_0_400(bearing - ah_mean)

        details.append(
            {
                "reference": reference_id,
                "x": reference_x,
                "y": reference_y,
                "z": reference.get("z"),
                "bearing_gon": bearing,
                "horizontal_distance": horizontal_distance,
                "ah_gon": ah_mean,
                "v0_gon": individual_v0,
                "reading_count": len(readings),
            }
        )

    if missing:
        raise ValueError(
            tr(
                "The following references have no usable coordinates or horizontal reading: {references}"
            ).format(references=", ".join(missing))
        )

    return details


def _orientation_from_references(
    details: Sequence[Dict[str, Any]], weight_by_distance: bool
) -> float:
    values = [float(detail["v0_gon"]) for detail in details]
    if weight_by_distance:
        weights = [
            max(float(detail["horizontal_distance"]), 1e-12) for detail in details
        ]
    else:
        weights = [1.0] * len(details)
    return _weighted_circular_mean_gon(values, weights)


def _apply_tolerance_filter(
    details: Sequence[Dict[str, Any]],
    weight_by_distance: bool,
    force_references: bool,
    tolerance_gon: float,
) -> Dict[str, Any]:
    used = [dict(detail) for detail in details]
    rejected: List[Dict[str, Any]] = []

    # As in COVADIS, checked references can be forced. Otherwise the largest
    # discrepancy is removed iteratively, while always preserving at least two
    # references when more than one was supplied.
    while not force_references and len(used) > 2:
        mean_v0 = _orientation_from_references(used, weight_by_distance)
        for detail in used:
            detail["residual_gon"] = TopazeCalculator.wrap_gr_m200_200(
                float(detail["v0_gon"]) - mean_v0
            )
        worst = max(used, key=lambda item: abs(float(item["residual_gon"])))
        if abs(float(worst["residual_gon"])) <= tolerance_gon:
            break
        used.remove(worst)
        worst["rejection_residual_gon"] = worst["residual_gon"]
        rejected.append(worst)

    v0_after = _orientation_from_references(used, weight_by_distance)
    for detail in used:
        detail["residual_gon"] = TopazeCalculator.wrap_gr_m200_200(
            float(detail["v0_gon"]) - v0_after
        )
        # COVADIS displays eV0 in the 0..400 gon interval.
        detail["ev0_gon"] = TopazeCalculator.wrap_gr_m200_200(
            v0_after - float(detail["v0_gon"])
        )
        detail["used"] = True

    for detail in rejected:
        detail["used"] = False

    within_tolerance = all(
        abs(float(detail["residual_gon"])) <= tolerance_gon for detail in used
    )

    return {
        "v0_after": v0_after,
        "used": used,
        "rejected": rejected,
        "within_tolerance": within_tolerance,
    }


def _md_escape(value: Any) -> str:
    if value is None:
        return ""
    return str(value).replace("|", "\\|").replace("\n", " ")


def _format_angle(value: Optional[float], decimals: int = 4) -> str:
    if value is None:
        return "—"
    return ("{:." + str(decimals) + "f}g").format(value)


def _build_markdown_report(data: Dict[str, Any], result: Dict[str, Any]) -> str:
    meta = data.get("meta", {})
    station = result["station"]
    all_references = result["references"]
    used_references = result["used_references"]
    rejected_references = result["rejected_references"]

    project_path = meta.get("project_path") or meta.get("project_name") or "—"
    observation_path = meta.get("observations_path") or "—"
    crs_description = meta.get("crs_description") or ""
    crs_authid = meta.get("crs_authid") or ""
    projection = " - ".join(value for value in (crs_authid, crs_description) if value)
    if not projection:
        projection = tr("No projection")

    md: List[str] = []
    md.append("# {}".format(tr("TOPOGRAPHIC CALCULATIONS")))
    md.append("")
    md.append("## {}".format(tr("STATION V0 COMPUTATION")))
    md.append("")
    md.append("- **{}**: {}".format(tr("QGIS project"), _md_escape(project_path)))
    md.append(
        "- **{}**: {}".format(tr("Observation file"), _md_escape(observation_path))
    )
    md.append("- **{}**: {}".format(tr("Projection used"), _md_escape(projection)))
    md.append(
        "- **{}**: {}".format(
            tr("Calculation date"),
            _md_escape(
                meta.get("date") or datetime.now().isoformat(timespec="seconds")
            ),
        )
    )
    md.append("")
    md.append("---")
    md.append("")
    md.append(
        "### {}".format(
            tr("Calculation of station {station}, occurrence {occurrence}").format(
                station=station["matricule"], occurrence=station["occurrence"]
            )
        )
    )
    md.append("")
    md.append("#### {}".format(tr("Control point coordinates")))
    md.append("")
    md.append(tr("| Role | Point | X | Y | Z |"))
    md.append("|---|---:|---:|---:|---:|")
    md.append(
        "| {} | {} | {} | {} | {} |".format(
            tr("Station"),
            _md_escape(station["matricule"]),
            fmt(station.get("x"), "{:.3f}"),
            fmt(station.get("y"), "{:.3f}"),
            fmt(station.get("z"), "{:.3f}"),
        )
    )
    for reference in all_references:
        md.append(
            "| {} | {} | {} | {} | {} |".format(
                tr("Reference"),
                _md_escape(reference["reference"]),
                fmt(reference.get("x"), "{:.3f}"),
                fmt(reference.get("y"), "{:.3f}"),
                fmt(reference.get("z"), "{:.3f}"),
            )
        )

    md.append("")
    md.append(
        "#### {}".format(
            tr(
                "V0 computation for station {station} ({occurrence}) "
                "(tolerance = {tolerance:.4f}g)"
            ).format(
                station=station["matricule"],
                occurrence=station["occurrence"],
                tolerance=result["tolerance_gon"],
            )
        )
    )
    md.append("")
    md.append(tr("| Reference | Gi | Dh | Ah | V0 | eV0 |"))
    md.append("|---:|---:|---:|---:|---:|---:|")
    for reference in used_references:
        md.append(
            "| {} | {} | {} | {} | {} | {} |".format(
                _md_escape(reference["reference"]),
                _format_angle(reference.get("bearing_gon"), 5),
                fmt(reference.get("horizontal_distance"), "{:.3f}"),
                _format_angle(reference.get("ah_gon", 5)),
                _format_angle(reference.get("v0_gon"), 5),
                _format_angle(reference.get("ev0_gon"), 5),
            )
        )

    md.append("")
    md.append("|  |  |")
    md.append("|---|---:|")
    md.append(
        "| **{}** | **{}** |".format(
            tr("V0 value before computation"),
            _format_angle(result.get("v0_before"), 5),
        )
    )
    result_label = (
        tr("V0 value after weighted computation")
        if result["weight_by_distance"]
        else tr("V0 value after unweighted computation")
    )
    md.append(
        "| **{}** | **{}** |".format(
            result_label,
            _format_angle(result.get("v0_after"), 5),
        )
    )

    if rejected_references:
        md.append("")
        md.append("#### {}".format(tr("References rejected by the tolerance check")))
        md.append("")
        md.append(tr("| Reference | Residual at rejection |"))
        md.append("|---:|---:|")
        for reference in rejected_references:
            md.append(
                "| {} | {} |".format(
                    _md_escape(reference["reference"]),
                    _format_angle(reference.get("rejection_residual_gon"), 5),
                )
            )

    md.append("")
    if result["force_references"]:
        md.append(
            "> {}".format(
                tr("The checked references were forced for this computation.")
            )
        )
    elif result["within_tolerance"]:
        md.append(
            "> **{}**".format(tr("All retained references are within tolerance."))
        )
    else:
        md.append(
            "> **{}**".format(
                tr(
                    "The retained reference discrepancies exceed the configured tolerance."
                )
            )
        )

    return "\n".join(md)


def _write_reports(
    data: Dict[str, Any], result: Dict[str, Any], output_dir: Path
) -> Dict[str, Optional[Path]]:
    output_dir.mkdir(parents=True, exist_ok=True)
    markdown = _build_markdown_report(data, result)

    md_path = output_dir / (REPORT_BASENAME + ".md")
    md_path.write_text(markdown, encoding="utf-8")
    html_path = output_dir / (REPORT_BASENAME + ".html")
    pdf_path = output_dir / (REPORT_BASENAME + ".pdf")
    odt_path = output_dir / (REPORT_BASENAME + ".odt")
    title = tr("Station V0 computation report")

    try:
        ReportUtils.markdown_to_html(str(md_path), str(html_path), title=title)
    except Exception as error:
        PlgLogger.log(
            tr("HTML export failed: {error}").format(error=str(error)),
            log_level=2,
        )

    try:
        ReportUtils.markdown_to_pdf(
            str(md_path),
            str(pdf_path),
            title=title,
            margins_mm=(7, 15, 7, 15),
        )
    except Exception as error:
        PlgLogger.log(
            tr("PDF export failed: {error}").format(error=str(error)),
            log_level=2,
        )

    try:
        ReportUtils.markdown_to_odt(str(md_path), str(odt_path))
    except Exception as error:
        PlgLogger.log(
            tr("ODT export failed: {error}").format(error=str(error)),
            log_level=2,
        )

    return {
        "md": md_path,
        "html": html_path if html_path.exists() else None,
        "pdf": pdf_path if pdf_path.exists() else None,
        "odt": odt_path if odt_path.exists() else None,
    }


class ComputeV0:
    """Compute and report the orientation V0 of one station occurrence."""

    @classmethod
    def compute_v0(
        cls,
        output_dir: Optional[Path] = None,
        temp_filename: str = TEMP_FILENAME,
    ) -> Dict[str, Any]:
        """Read the temporary JSON file, compute V0 and generate reports."""
        json_content = FileUtils.load_temp_file(temp_filename)
        if not json_content:
            raise RuntimeError(
                tr("No data were found in the temporary V0 calculation file.")
            )

        try:
            data = json.loads(json_content)
        except json.JSONDecodeError as error:
            raise ValueError(
                tr("Invalid V0 calculation JSON file: {error}").format(error=str(error))
            ) from error

        if output_dir is None:
            from qgis.core import QgsProject

            output_dir = Path(QgsProject.instance().homePath()) / "rapport"
        else:
            output_dir = Path(output_dir)

        result = cls.compute_v0_from_data(data)
        result["report_paths"] = _write_reports(data, result, output_dir)
        return result

    @staticmethod
    def compute_v0_from_data(data: Dict[str, Any]) -> Dict[str, Any]:
        calculation = data.get("calcul", [])
        if not calculation:
            raise ValueError(tr("No station was selected for the V0 computation."))

        station_id = str(calculation[0])
        occurrence = int(data.get("occurrence", 1))
        points = _point_by_id(data)
        station_coordinates = points.get(station_id, {})
        config = data.get("config", {})

        weight_by_distance = bool(config.get("weight_by_distance", True))
        force_references = bool(config.get("force_references", False))
        tolerance_gon = _as_float(config.get("tolerance_gon"))
        if tolerance_gon is None or tolerance_gon < 0.0:
            tolerance_gon = DEFAULT_TOLERANCE_GON

        references = _reference_details(data)
        filtered = _apply_tolerance_filter(
            references,
            weight_by_distance=weight_by_distance,
            force_references=force_references,
            tolerance_gon=tolerance_gon,
        )

        return {
            "station": {
                "matricule": station_id,
                "occurrence": occurrence,
                "x": station_coordinates.get("x"),
                "y": station_coordinates.get("y"),
                "z": station_coordinates.get("z"),
            },
            "references": references,
            "used_references": filtered["used"],
            "rejected_references": filtered["rejected"],
            "v0_before": _as_float(data.get("v0_before")),
            "v0_after": filtered["v0_after"],
            "weight_by_distance": weight_by_distance,
            "force_references": force_references,
            "tolerance_gon": tolerance_gon,
            "within_tolerance": filtered["within_tolerance"],
        }
