# SPDX-FileCopyrightText: Copyright © 2024-2026 Damian Anders <damian.anders@tu-dresden.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later

"""
Determine reference angles and offsets from signal flanks.

This module provides functions to process periodic signals containing
positive impulses (flanks). It calculates the angular positions of these
impulses relative to an angle array, offering features like automatic
threshold detection and lowpass filtering.
"""

import numpy as np
import numpy.typing as npt
from scipy import signal as ssig

from ._set_position_zero import _set_position_zero

__all__ = ["angle_position_flanks"]


def angle_position_flanks(phi: npt.NDArray[np.float64],
						  signal: npt.NDArray[np.float64],
						  threshold_percentile: float | None = None,
						  lowpass_harmonics: int | None = 500,
) -> tuple[npt.NDArray[np.float64], np.float64]:
	"""
	Calculate the angular position of signal flanks and the offset.

	This function processes a 2D signal array and its corresponding angle
	array to find the centers of positive impulses. It optionally applies
	a lowpass filter and can automatically determine an optimal threshold
	percentile for flank detection if none is provided.

	Parameters
	----------
	phi : numpy.ndarray of numpy.float64
		A 1D array representing the angular positions corresponding to the
		signal samples.
	signal : numpy.ndarray of numpy.float64
		A 2D array representing the signals to be analyzed, where each
		column is a separate channel. Note that the impulses/flanks must
		deflect in the positive direction.
	threshold_percentile : float or None, optional
		The percentile to use as a threshold for flank detection (must be
		between 0 and 100). If None, an optimal percentile is automatically
		calculated. Default is None.
	lowpass_harmonics : int or None, optional
		The cutoff frequency for the Butterworth lowpass filter, expressed
		as a number of harmonics. If None, no filtering is applied.
		Default is 500.

	Returns
	-------
	phi_center : numpy.ndarray of numpy.float64
		A 1D array containing the relative angular positions of the impulse
		centers for each channel, shifted so that the first channel is at 0.
	offset : numpy.float64
		The absolute angular position of the center of the first channel's
		impulse relative to the input `phi` array.

	Raises
	------
	ValueError
		If `phi` and `signal` do not have the same number of samples (rows).
		If `signal` is not a 2D array.
		If `threshold_percentile` is provided but is not between 0 and 100.
	"""
	if phi.size != signal.shape[0]:
		raise ValueError("\"phi\" and \"signal\" must have the same length.")
	if signal.ndim != 2:
		raise ValueError("\"signal\" must be a 2D array.")
	if threshold_percentile is not None and not (0 < threshold_percentile < 100):
		raise ValueError("\"threshold_percentile\" must be between 0 and 100.")

	# Tiefpassfilterung
	if lowpass_harmonics is None:
		signal_tiefpass = signal
	else:
		sos = ssig.butter(3,
						  lowpass_harmonics,
						  fs=np.float64(2) * np.pi / np.mean(np.diff(phi)),
						  output="sos")
		signal_tiefpass = ssig.sosfiltfilt(sos, signal, axis=0)

	# Ermitteln des optimalen Prozentwerts zur Flankenermittlung
	if threshold_percentile is None:
		threshold_percentile = _optimise_flank_percentage(signal_tiefpass)

	# Erkennung der zusammengehörigen steigenden und fallenden Flanken sowie deren Breite
	perzentile = np.percentile(signal_tiefpass, threshold_percentile, axis=0, keepdims=True)	# y-Schnittpunkt mit Flanken
	flanken = np.diff(np.sign(signal_tiefpass - perzentile), axis=0, prepend=0)	# !=0 für den ersten Punkt HINTER überquerter Perzentil-Linie

	phi_center = np.zeros(signal.shape[1])
	for p in range(signal.shape[1]):	# jeder Kanal separat
		in_flanke = False	# kennzeichnen, wenn sich Algorithmus innerhalb eines Ausschlags befindet
		idx_flanken_roh = np.flatnonzero(np.abs(flanken[:, p]) >= 1)	# Indizes aller Überquerungen der Perzentil-Linie

		idx_flanken_gueltig = np.zeros((np.floor_divide(idx_flanken_roh.size, 2), 2), dtype=np.int_)	# Array für Index-Paare gültiger Flanken
		z = 0
		for r in range(idx_flanken_roh.size):	# jede gefundene Flanke
			if idx_flanken_roh[r] == 0:	# Flanken im ersten Punkt des Signals ignorieren
				continue
			elif in_flanke:								# wenn sich Algorithmus innerhalb eines Ausschlags befindet
				if flanken[idx_flanken_roh[r], p] < 0:	#	und die aktuelle Flanke fallend ist: Punkte übernehmen
					idx_flanken_gueltig[z, 0] = idx_flanken_roh[r - 1] - 1	# letzter Punkt vor/unterhalb Perzentil-Linie
					idx_flanken_gueltig[z, 1] = idx_flanken_roh[r]	# erster Punkt hinter/unterhalb Perzentil-Linie
					z = z + 1
				in_flanke = False	# Ausschlag wurde verlassen; wird auch bei zwei steigenden Flanken hintereinander gesetzt und verwirft sie damit
			elif flanken[idx_flanken_roh[r], p] > 0:	# nicht innerhalb Ausschlag und aktuelle Flanke ist steigend
				in_flanke = True						#	Algorithmus befindet sich nun innerhalb des Ausschlags

		idx_flanken_gueltig = np.delete(idx_flanken_gueltig, np.all(idx_flanken_gueltig == 0, axis=1), axis=0)	# nicht verwendete Zeilen des Arrays entfernen

		phi_flanken_gueltig = phi[idx_flanken_gueltig]	# den Flanken-Indizes ihren Winkelwert zuordnen
		phi_flanken_mitte = np.mean(phi_flanken_gueltig, axis=1)	# Winkelwerte der Mitten der Flanken
		phi_center[p] = np.median(np.mod(phi_flanken_mitte, 2 * np.pi))	# Mitten auf den Bereich [0, 2*pi) beschränken und mitteln

	# Daten zur Ausgabe vorbereiten
	offset = phi_center[0]	# Position der Mitte des Ausschlags relativ zu phi
	phi_center = _set_position_zero(phi_center)	# Position verschieben, sodass Ausschlag d. 1. Kanals bei 0 liegt

	return phi_center, offset


def _optimise_flank_percentage(data: npt.NDArray[np.float64],
) -> np.float64:
	"""
	Determine the optimal percentile threshold for flank detection.

	This internal function calculates the optimal percentile to threshold
	the data by finding the steepest region in the signal. It computes the
	gradient of the percentage with respect to the percentile values and
	minimizes the sum of squared gradients across all channels.

	Parameters
	----------
	data : numpy.ndarray of numpy.float64
		A 2D array of signal data where each column is a separate channel.

	Returns
	-------
	flank_percentage : numpy.float64
		The calculated optimal percentile (between 0 and 100) for flank
		detection.
	"""
	anzahl_channel = data.shape[1]

	# Perzentile kleinschrittig ermitteln
	prozentwert = np.arange(10001, dtype=np.float64) / 100	# Auflösung 0.01%
	perzentil = np.percentile(data, prozentwert, axis=0)

	# Änderung der Prozentwerte in Abhängigkeit der Perzentilwerte;
	# geringe Änderung bedeutet starken Anstieg in "data"
	prozentwert_gradient = np.zeros_like(perzentil)
	for ch in range(anzahl_channel):
		prozentwert_gradient[:, ch] = np.gradient(prozentwert, perzentil[:, ch], axis=0)

	# kanalweise Index des Werts ermitteln, an dem das Perzentil
	# gegenüber den Prozentwerten am stärksten steigt
	gradient_minimum_idx = np.argmin(prozentwert_gradient, axis=0)

	# im Bereich aller gefundenen Minima das beste gemeinsame
	# Minimum finden (Summe der Quadrate der Gradienten -> LSQ)
	idx_min = np.min(gradient_minimum_idx)	# Bereich ermitteln
	idx_max = np.max(gradient_minimum_idx)
	gradient_optimum = np.inf	# Startwerte; werden in erster Iteration überschrieben
	gradient_optimum_idx = idx_min

	for idx in range(idx_min, idx_max + 1):
		temp_summe = np.sum(np.square(prozentwert_gradient[idx, :]))	# SSQ
		if temp_summe < gradient_optimum:
			gradient_optimum = temp_summe
			gradient_optimum_idx = idx

	# optimaler Prozentwert
	flank_percentage = np.float64(prozentwert[gradient_optimum_idx])	# Fix für Type Checker

	return flank_percentage
