__author__ = "Roland Trouville"
__copyright__ = "Copyright 2015+, Consortium MonPaGe"
__license__ = "Creative Commons 4.0 By-Nc-Sa"
__maintainer__ = "Roland Trouville"
__email__ = "contact.monpage@gmail.com"
__status__ = "Production"

import logging
import os
import shutil
import tarfile
from datetime import datetime
from typing import Iterator
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo

from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMessageBox

from tools.csv_manager import CSVManager
from tools.options import Options

logger = logging.getLogger(__name__)


class GeneralTools:
	"""
	A collection of general utility methods used throughout the application.

	This class provides static methods for:

	- Traversing directory trees and searching files with filtering options.
	- Retrieving application metadata such as name, version, and platform suffix.
	- Casting strings to appropriate data types for processing Praat data.
	- Handling file and directory names, including exclusion logic.
	- Reading configuration files like module order.
	- Displaying message boxes within a PyQt application.
	- Managing manifest files for packaging, including generating and removing manifests.
	- Converting archives from tar.gz format to zip format while preserving metadata.
	- Creating Praat TextGrid files for audio annotation based on templates.

	This utility class serves as a central place for common tasks related to file handling,
	application metadata, UI alerts, and packaging operations.
	"""

	@staticmethod
	def get_file_tree(root: str, depth: int) -> Iterator[str]:
		"""
		Recursively traverses a directory tree and yields all file and directory paths.

		This generator function walks through the directory structure starting at the
		specified root path, up to the specified depth, yielding each file or directory
		path it encounters during traversal.

		:param root: The starting directory or file path for the traversal.
		:type root: str
		:param depth: Maximum depth to traverse. If negative, only the root is yielded.
		:type depth: int
		:return: A generator yielding paths encountered during traversal.
		:rtype: Iterator[str]
		"""

		if depth < 0 or not os.path.isdir(root):
			yield root
			return

		for name in os.listdir(root):
			yield from GeneralTools.get_file_tree(os.path.join(root, name), depth - 1)

	@staticmethod
	def get_name():
		"""
		Returns the application name.

		:return: A string containing the application name ('MonPaGe').
		:rtype: str
		"""
		return "MonPaGe"

	@staticmethod
	def get_archive_name():
		"""
		Returns the archive name by combining application name and version number.

		:return: A string containing the archive name (e.g., 'MonPaGe-2.1.0').
		:rtype: str
		"""
		return GeneralTools.get_name() + "-" + GeneralTools.get_version_number()

	@staticmethod
	def get_version_number():
		"""
		Returns the application version number.

		:return: A string containing the version number (e.g., '2.1.0').
		:rtype: str
		"""
		return "2.1.0"

	@staticmethod
	def get_version_code():
		"""
		Returns the version code based on application mode.

		:return: 'R' for research mode, 'S' for screening mode.
		:rtype: str
		"""
		if Options.is_enabled(Options.Option.RESEARCH):
			return "R"
		return "S"

	@staticmethod
	def get_version():
		"""
		Returns the full version string combining version number and code.

		:return: A string containing the version (e.g., '2.1.0-R').
		:rtype: str
		"""
		return GeneralTools.get_version_number() + "-" + GeneralTools.get_version_code()

	@staticmethod
	def get_fullname():
		"""
		Returns the full application name with version.

		:return: A string containing the full application name (e.g., 'MonPaGe v2.1.0-R').
		:rtype: str
		"""
		return GeneralTools.get_name() + " v" + GeneralTools.get_version()

	@staticmethod
	def get_os_suffix():
		"""
		Returns a suffix indicating the current operating system.

		:return: 'WIN' for Windows systems, 'MAC' for macOS/other systems.
		:rtype: str
		"""
		if Options.is_windows():
			return "WIN"
		return "MAC"

	@staticmethod
	def praat_type_cast(s: str):
		"""
		Attempts to cast the input string `s` to a specific type.

		The method tries to convert the input string to a float first. If that fails,
		it attempts to convert it to an integer. If both conversions fail, it strips
		any leading or trailing whitespace from the string and checks for specific
		cases like an empty string or a placeholder value (`"--undefined--"`),
		returning `None` in those cases. If none of these conditions are met,
		the original string is returned.

		:param s: The input string to be type-cast.
		:return: The type-cast value, which could be a float, int, None, or the original string.
		"""
		if s is None:
			return None
		try:
			return float(s)
		except ValueError:
			try:
				return int(s)
			except ValueError:
				s = s.strip()
				if s == "":
					return None
				if s == "--undefined--":
					return None
				return s

	@staticmethod
	def get_file_name(path) -> str:
		"""
		Extracts the file or directory name from a path.

		This method handles both file paths and directory paths that may end with a separator.

		:param path: The file or directory path.
		:type path: str
		:return: The file or directory name extracted from the path.
		:rtype: str
		"""
		if path.split("/")[-1] == "":
			return path.split("/")[-2]
		return path.split("/")[-1]

	@staticmethod
	def find_exclude_file(path, file_exclude) -> bool:
		"""
		Determines if a file should be excluded based on its name.

		Checks if the filename extracted from the path is present in the
		provided list of files to exclude.

		:param path: Path to the file or directory to check.
		:type path: str
		:param file_exclude: List of filenames to exclude.
		:type file_exclude: list
		:return: True if the file should be excluded, False otherwise.
		:rtype: bool
		"""
		return (
			file_exclude is not None
			and GeneralTools.get_file_name(path) in file_exclude
		)

	@staticmethod
	def find_files(
		base_path: str,
		end_pattern: str,
		recursive: bool = True,
		file_exclude: list[str] = ["Analyse"],
	) -> Iterator[str]:
		"""
		Recursively finds files matching a specified pattern.

		This generator function yields file paths that match the given end pattern,
		optionally searching recursively through subdirectories while excluding
		specified files or directories.

		:param base_path: Root directory or file path to start the search from.
		:type base_path: str
		:param end_pattern: Pattern that file names should end with (e.g., '.txt', '.wav').
		:type end_pattern: str
		:param recursive: Whether to search recursively through subdirectories.
		:type recursive: bool
		:param file_exclude: List of file or directory names to exclude from the search.
		:type file_exclude: list
		:return: A generator yielding paths of matching files.
		:rtype: Iterator[str]
		"""

		def check_file_matches(filepath: str) -> bool:
			return filepath.endswith(end_pattern)

		if os.path.isfile(base_path):
			if check_file_matches(base_path):
				yield base_path
		elif os.path.isdir(base_path):
			if GeneralTools.find_exclude_file(base_path, file_exclude):
				return

			for entry in os.listdir(base_path):
				entry_path = os.path.join(base_path, entry)
				if os.path.isfile(entry_path):
					if check_file_matches(entry_path):
						yield entry_path
				elif os.path.isdir(entry_path):
					if recursive:
						yield from GeneralTools.find_files(
							entry_path, end_pattern, recursive
						)

	@staticmethod
	def open_module_order_file() -> list[str]:
		"""
		Reads and processes the module order configuration file.

		Opens the 'module_order.csv' file, reads its tab-delimited content,
		and extracts the first column values which represents the module CSV filename.

		:return: A list of module CSV names in their specified order.
		:rtype: List[str]
		"""
		ret = []
		for row in CSVManager.read_file("module_order.csv", "\t", -1):
			ret.append(row[0])
		return ret

	@staticmethod
	def alert_box(parent, text=None, informative_text=None, modal=False):
		"""
		Creates and displays a message box with specified content.

		Creates a QMessageBox with the application icon and title, allowing
		for main text, informative text, and modal behavior control.

		:param parent: Parent widget for the message box.
		:type parent: QWidget
		:param text: Main text to display in the message box.
		:type text: str or None
		:param informative_text: Additional informative text to display.
		:type informative_text: str or None
		:param modal: Whether the message box should be modal (block interaction with other windows).
		:type modal: bool
		:return: None
		"""
		tmp = QMessageBox(parent)
		if text is not None:
			tmp.setText(text)
		if informative_text is not None:
			tmp.setInformativeText(informative_text)
		if modal:
			tmp.setModal(modal)
		tmp.setWindowIcon(QIcon("./ui/icons/icon.png"))
		tmp.setWindowTitle(GeneralTools.get_fullname())
		tmp.open()

	@staticmethod
	def remove_manifest():
		"""
		Removes the MANIFEST.in file if it exists.

		This method is typically used before generating a new manifest file
		to ensure no outdated information is preserved.

		:return: None
		"""
		if os.path.isfile("./MANIFEST.in"):
			os.remove("./MANIFEST.in")

	@staticmethod
	def generate_manifest(research=True):
		"""
		Generates a MANIFEST.in file for packaging based on platform and mode.

		Creates a MANIFEST.in file by combining multiple manifest template files
		based on the target platform (Windows or macOS) and application mode
		(research or screening). The manifest file is used during the packaging
		process to determine which files to include.

		:param windows: Whether the target platform is Windows (True) or macOS (False).
		:type windows: bool
		:param research: Whether to include research mode files (True) or screening mode files (False).
		:type research: bool
		:return: None
		"""
		filenames = ["MANIFEST_BASE.in"]
		GeneralTools.remove_manifest()
		if research:
			filenames.append("MANIFEST_RESEARCH.in")

		with open("./MANIFEST.in", "w") as outfile:
			for fname in filenames:
				with open("./package/" + fname) as infile:
					outfile.write(infile.read())

	@staticmethod
	def convert_targz_to_zip(source_archive: str, target_archive: str, keep_tar=False):
		"""
		Converts a tar.gz archive to a zip archive.

		Extracts files from a tar.gz archive and repackages them into a zip archive,
		preserving file metadata and permissions. Some files are excluded from the
		conversion. The original tar.gz archive can optionally be kept or removed
		after conversion.

		:param source_archive: Path to the source tar.gz archive.
		:type source_archive: str
		:param target_archive: Path for the output zip archive.
		:type target_archive: str
		:param keep_tar: Whether to keep the original tar.gz archive after conversion.
		:type keep_tar: bool
		:return: None
		"""
		print("Conversion de " + source_archive + " en " + target_archive)
		excluded_files = (
			"MonPaGe_R.egg-info",
			"PKG-INFO",
			"setup.cfg",
			"SOURCES.txt",
			"top_level.txt",
			"dependency_links.txt",
		)
		compresslevel = 9
		compression = ZIP_DEFLATED
		if os.path.isfile(source_archive):
			with tarfile.open(name=source_archive, mode="r|gz") as tarf:
				with ZipFile(
					file=target_archive,
					mode="w",
					compression=compression,
					compresslevel=compresslevel,
				) as zipf:
					for m in tarf:
						if not m.name.endswith(excluded_files):
							mtime = datetime.fromtimestamp(m.mtime)
							# print(f'{mtime} - {m.name}')
							zinfo: ZipInfo = ZipInfo(
								filename=m.name,
								date_time=(
									mtime.year,
									mtime.month,
									mtime.day,
									mtime.hour,
									mtime.minute,
									mtime.second,
								),
							)
							if not m.isfile():
								# for directories and other types
								continue
							if m.name.endswith(".command"):
								zinfo.external_attr = 0o777 << 16
							f = tarf.extractfile(m)
							fl = f.read()
							zipf.writestr(
								zinfo,
								fl,
								compress_type=compression,
								compresslevel=compresslevel,
							)
			print("OK.")
			if not keep_tar:
				os.remove(source_archive)
				print("Archive tar.gz effacée")
		else:
			print("Pas de fichier " + source_archive + " trouvé en")

	@staticmethod
	def create_text_grid(
		pattern_filename: str,
		judge_code: str,
		start_interval: float,
		duration_interval: float,
		step,
	):
		"""
		Creates a TextGrid file based on a template for audio annotation.

		Copies a template TextGrid file and customizes it with participant information,
		timing intervals, and other metadata. The TextGrid file is typically used
		with Praat for audio analysis and annotation.

		:param pattern_filename: Filename of the template TextGrid to use as a base.
		:type pattern_filename: str
		:param judge_code: The code of the judge that is doing the cotation
		:type judge_code: str
		:param start_interval: The value of the start interval
		:type start_interval: float
		:param duration_interval: The value of the interval duration
		:type duration_interval: float
		:param step: An object containing information about the current processing step,
		             including file paths, audio information, and speaker information.
		:type step: CotationAcousticMeasures
		:return: None
		"""
		xmax = step.duration
		end_interval = start_interval + duration_interval
		audio_filename = step.get_audio_file_name()
		newfile = f"{step.file_full_dir_path}{os.path.sep}{audio_filename[:-4]}.{judge_code}.TextGrid"
		participant_code = step.speaker_code
		shutil.copyfile(f"./data/cotation/{pattern_filename}", newfile)
		with open(newfile, "r+") as file:
			contents = file.read()
			contents = contents.replace("__name_participant", participant_code)
			contents = contents.replace("__x_1", str(f"{start_interval:.4f}"))
			contents = contents.replace("__x_2", str(f"{end_interval:.4f}"))
			contents = contents.replace("__x_3 ", str(f"{xmax:.4f}"))

			file.seek(0)
			file.write(contents)
			file.truncate()

		return
