# #region FileIO [C:3] [TYPE Module] [SEMANTICS fileio, archive, zip, yaml, utility]
# @BRIEF Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [LoggerModule]
# @PUBLIC_API  create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import date, datetime
import os
from pathlib import Path
import re
import shutil
import tempfile
from typing import Any, LiteralString
import zipfile
import zlib

from ..logger import belief_scope, logger as app_logger


# #region InvalidZipFormatError [TYPE Class]
# @BRIEF Exception raised when a file is not a valid ZIP archive.
class InvalidZipFormatError(Exception):
    pass
# #endregion InvalidZipFormatError
# #region create_temp_file [TYPE Function]
# @BRIEF Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# @PRE  suffix должен быть строкой, определяющей тип ресурса.
# @POST  Временный ресурс создан и путь к нему возвращен; ресурс удален после выхода из контекста.
# @YIELDS  Path - Путь к временному ресурсу.
@contextmanager
def create_temp_file(content: bytes | None = None, suffix: str = ".zip", mode: str = 'wb', dry_run = False) -> Generator[Path]:
    with belief_scope("Create temporary resource"):
        resource_path = None
        is_dir = suffix.startswith('.dir')
        try:
            if is_dir:
                with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
                    resource_path = Path(temp_dir)
                    app_logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
                    yield resource_path
            else:
                fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
                resource_path = Path(temp_path_str)
                os.close(fd)
                if content:
                    resource_path.write_bytes(content)
                app_logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
                yield resource_path
        finally:
            if resource_path and resource_path.exists() and not dry_run:
                try:
                    if resource_path.is_dir():
                        shutil.rmtree(resource_path)
                        app_logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
                    else:
                        resource_path.unlink()
                        app_logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
                except OSError as e:
                    app_logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
# #endregion create_temp_file
# #region remove_empty_directories [TYPE Function]
# @BRIEF Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
# @PRE  root_dir должен быть путем к существующей директории.
# @POST  Все пустые поддиректории удалены, возвращено их количество.
def remove_empty_directories(root_dir: str) -> int:
    with belief_scope(f"Remove empty directories in {root_dir}"):
        app_logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
        removed_count = 0
        if not os.path.isdir(root_dir):
            app_logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
            return 0
        for current_dir, _, _ in os.walk(root_dir, topdown=False):
            if not os.listdir(current_dir):
                try:
                    os.rmdir(current_dir)
                    removed_count += 1
                    app_logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir)
                except OSError as e:
                    app_logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
        app_logger.reflect(f"Removed {removed_count} empty directories.", extra={"src": "remove_empty_directories"})
        return removed_count
# #endregion remove_empty_directories
# #region read_dashboard_from_disk [TYPE Function]
# @BRIEF Читает бинарное содержимое файла с диска.
# @PRE  file_path должен указывать на существующий файл.
# @POST  Возвращает байты содержимого и имя файла.
def read_dashboard_from_disk(file_path: str) -> tuple[bytes, str]:
    with belief_scope(f"Read dashboard from {file_path}"):
        path = Path(file_path)
        assert path.is_file(), f"Файл дашборда не найден: {file_path}"
        app_logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
        content = path.read_bytes()
        if not content:
            app_logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
        return content, path.name
# #endregion read_dashboard_from_disk
# #region calculate_crc32 [TYPE Function]
# @BRIEF Вычисляет контрольную сумму CRC32 для файла.
# @PRE  file_path должен быть объектом Path к существующему файлу.
# @POST  Возвращает 8-значную hex-строку CRC32.
def calculate_crc32(file_path: Path) -> str:
    with belief_scope(f"Calculate CRC32 for {file_path}"), open(file_path, 'rb') as f:
        crc32_value = zlib.crc32(f.read())
        return f"{crc32_value:08x}"
# #endregion calculate_crc32
# #region RetentionPolicy [TYPE DataClass]
# @BRIEF Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass
class RetentionPolicy:
    daily: int = 7
    weekly: int = 4
    monthly: int = 12
# #endregion RetentionPolicy
# #region archive_exports [TYPE Function]
# @BRIEF Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# @PRE  output_dir должен быть путем к существующей директории.
# @POST  Старые или дублирующиеся архивы удалены согласно политике.
# @RELATION CALLS -> [apply_retention_policy]
# @RELATION CALLS -> [calculate_crc32]
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False) -> None:
    with belief_scope(f"Archive exports in {output_dir}"):
        output_path = Path(output_dir)
        if not output_path.is_dir():
            app_logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir)
            return
        app_logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)

        # 1. Collect all zip files
        zip_files = list(output_path.glob("*.zip"))
        if not zip_files:
            app_logger.info("[archive_exports][State] No zip files found in %s", output_dir)
            return
        # 2. Deduplication
        if deduplicate:
            app_logger.info("[archive_exports][State] Starting deduplication...")
            checksums = {}
            files_to_remove = []

            # Sort by modification time (newest first) to keep the latest version
            zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)

            for file_path in zip_files:
                try:
                    crc = calculate_crc32(file_path)
                    if crc in checksums:
                        files_to_remove.append(file_path)
                        app_logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name)
                    else:
                        checksums[crc] = file_path
                except Exception as e:
                    app_logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e)

            for f in files_to_remove:
                try:
                    f.unlink()
                    zip_files.remove(f)
                    app_logger.info("[archive_exports][State] Removed duplicate: %s", f.name)
                except OSError as e:
                    app_logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e)
        # 3. Retention Policy
        files_with_dates = []
        for file_path in zip_files:
            # Try to extract date from filename
            # Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip
            match = re.search(r'_(\d{8})_', file_path.name)
            file_date = None
            if match:
                try:
                    date_str = match.group(1)
                    file_date = datetime.strptime(date_str, "%Y%m%d").date()
                except ValueError:
                    app_logger.debug("Could not parse date from filename %s", file_path.name)

            if not file_date:
                # Fallback to modification time
                file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date()

            files_with_dates.append((file_path, file_date))
        files_to_keep = apply_retention_policy(files_with_dates, policy)

        for file_path, _ in files_with_dates:
            if file_path not in files_to_keep:
                try:
                    file_path.unlink()
                    app_logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name)
                except OSError as e:
                    app_logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e)
# #endregion archive_exports
# #region apply_retention_policy [TYPE Function]
# @BRIEF (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
# @PRE  files_with_dates is a list of (Path, date) tuples.
# @POST  Returns a set of files to keep.
def apply_retention_policy(files_with_dates: list[tuple[Path, date]], policy: RetentionPolicy) -> set:
    with belief_scope("Apply retention policy"):
        # Сортируем по дате (от новой к старой)
        sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True)
        # Словарь для хранения файлов по категориям
        daily_files = []
        weekly_files = []
        monthly_files = []
        today = date.today()
        for file_path, file_date in sorted_files:
            # Ежедневные
            if (today - file_date).days < policy.daily:
                daily_files.append(file_path)
            # Еженедельные
            elif (today - file_date).days < policy.weekly * 7:
                weekly_files.append(file_path)
            # Ежемесячные
            elif (today - file_date).days < policy.monthly * 30:
                monthly_files.append(file_path)
        # Возвращаем множество файлов, которые нужно сохранить
        files_to_keep = set()
        files_to_keep.update(daily_files)
        files_to_keep.update(weekly_files[:policy.weekly])
        files_to_keep.update(monthly_files[:policy.monthly])
        app_logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep))
        return files_to_keep
# #endregion apply_retention_policy
# #region save_and_unpack_dashboard [TYPE Function]
# @BRIEF Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
# @PRE  zip_content должен быть байтами валидного ZIP-архива.
# @POST  ZIP-файл сохранен, и если unpack=True, он распакован в output_dir.
def save_and_unpack_dashboard(zip_content: bytes, output_dir: str | Path, unpack: bool = False, original_filename: str | None = None) -> tuple[Path, Path | None]:
    with belief_scope("Save and unpack dashboard"):
        app_logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
        try:
            output_path = Path(output_dir)
            output_path.mkdir(parents=True, exist_ok=True)
            zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
            zip_path = output_path / zip_name
            zip_path.write_bytes(zip_content)
            app_logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
            if unpack:
                with zipfile.ZipFile(zip_path, 'r') as zip_ref:
                    zip_ref.extractall(output_path)
                app_logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path)
                return zip_path, output_path
            return zip_path, None
        except zipfile.BadZipFile as e:
            app_logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
            raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
# #endregion save_and_unpack_dashboard
# #region update_yamls [TYPE Function]
# @BRIEF Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
# @PRE  path должен быть существующей директорией.
# @POST  Все YAML файлы в директории обновлены согласно переданным параметрам.
# @RELATION CALLS -> [_update_yaml_file]
def update_yamls(db_configs: list[dict[str, Any]] | None = None, path: str = "dashboards", regexp_pattern: LiteralString | None = None, replace_string: LiteralString | None = None) -> None:
    with belief_scope("Update YAML configurations"):
        app_logger.info("[update_yamls][Enter] Starting YAML configuration update.")
        dir_path = Path(path)
        assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"

        configs: list[dict[str, Any]] = db_configs or []

        for file_path in dir_path.rglob("*.yaml"):
            _update_yaml_file(file_path, configs, regexp_pattern, replace_string)
# #endregion update_yamls
# #region _update_yaml_file [TYPE Function]
# @BRIEF (Helper) Обновляет один YAML файл.
# @PRE  file_path должен быть объектом Path к существующему YAML файлу.
# @POST  Файл обновлен согласно переданным конфигурациям или регулярному выражению.
def _update_yaml_file(file_path: Path, db_configs: list[dict[str, Any]], regexp_pattern: str | None, replace_string: str | None) -> None:
    with belief_scope(f"Update YAML file: {file_path}"):
        # Читаем содержимое файла
        try:
            with open(file_path, encoding='utf-8') as f:
                content = f.read()
        except Exception as e:
            app_logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e)
            return
        # Если задан pattern и replace_string, применяем замену по регулярному выражению
        if regexp_pattern and replace_string:
            try:
                new_content = re.sub(regexp_pattern, replace_string, content)
                if new_content != content:
                    with open(file_path, 'w', encoding='utf-8') as f:
                        f.write(new_content)
                    app_logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
            except Exception as e:
                app_logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
        # Если заданы конфигурации, заменяем значения (поддержка old/new)
        if db_configs:
            try:
                # Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
                modified_content = content
                for cfg in db_configs:
                    # Ожидаем структуру: {'old': {...}, 'new': {...}}
                    old_cfg = cfg.get('old', {})
                    new_cfg = cfg.get('new', {})
                    for key, old_val in old_cfg.items():
                        if key in new_cfg:
                            new_val = new_cfg[key]
                            # Заменяем только точные совпадения старого значения в тексте YAML, используя ключ для контекста
                            if isinstance(old_val, str):
                                # Ищем паттерн: key: "value" или key: value
                                key_pattern = re.escape(key)
                                val_pattern = re.escape(old_val)
                                # Группы: 1=ключ+разделитель, 2=открывающая кавычка (опц), 3=значение, 4=закрывающая кавычка (опц)
                                pattern = rf'({key_pattern}\s*:\s*)(["\']?)({val_pattern})(["\']?)'

                                # #region replacer [TYPE Function]
                                # @PURPOSE: Функция замены, сохраняющая кавычки если они были.
                                # @PRE  match должен быть объектом совпадения регулярного выражения.
                                # @POST  Возвращает строку с новым значением, сохраняя префикс и кавычки.
                                def replacer(match):
                                    prefix = match.group(1)
                                    quote_open = match.group(2)
                                    quote_close = match.group(4)
                                    return f"{prefix}{quote_open}{new_val}{quote_close}"
                                # #endregion replacer
                                modified_content = re.sub(pattern, replacer, modified_content)
                                app_logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path)
                # Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование
                with open(file_path, 'w', encoding='utf-8') as f:
                    f.write(modified_content)
            except Exception as e:
                app_logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e)
# #endregion _update_yaml_file
# #region create_dashboard_export [TYPE Function]
# @BRIEF Создает ZIP-архив из указанных исходных путей.
# @PRE  source_paths должен содержать существующие пути.
# @POST  ZIP-архив создан по пути zip_path.
def create_dashboard_export(zip_path: str | Path, source_paths: list[str | Path], exclude_extensions: list[str] | None = None) -> bool:
    with belief_scope(f"Create dashboard export: {zip_path}"):
        app_logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
        try:
            exclude_ext = [ext.lower() for ext in exclude_extensions or []]
            with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
                for src_path_str in source_paths:
                    src_path = Path(src_path_str)
                    assert src_path.exists(), f"Путь не найден: {src_path}"
                    for item in src_path.rglob('*'):
                        if item.is_file() and item.suffix.lower() not in exclude_ext:
                            arcname = item.relative_to(src_path.parent)
                            zipf.write(item, arcname)
            app_logger.reflect(f"Archive created: {zip_path}", extra={"src": "create_dashboard_export"})
            return True
        except (OSError, zipfile.BadZipFile, AssertionError) as e:
            app_logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
            return False
# #endregion create_dashboard_export
# #region sanitize_filename [TYPE Function]
# @BRIEF Очищает строку от символов, недопустимых в именах файлов.
# @PRE  filename должен быть строкой.
# @POST  Возвращает строку без спецсимволов.
def sanitize_filename(filename: str) -> str:
    with belief_scope(f"Sanitize filename: {filename}"):
        return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
# #endregion sanitize_filename
# #region get_filename_from_headers [TYPE Function]
# @BRIEF Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# @PRE  headers должен быть словарем заголовков.
# @POST  Возвращает имя файла или None, если заголовок отсутствует.
def get_filename_from_headers(headers: dict) -> str | None:
    with belief_scope("Get filename from headers"):
        content_disposition = headers.get("Content-Disposition", "")
        if match := re.search(r'filename="?([^"]+)"?', content_disposition):
            return match.group(1).strip()
        return None
# #endregion get_filename_from_headers
# #region consolidate_archive_folders [TYPE Function]
# @BRIEF Консолидирует директории архивов на основе общего слага в имени.
# @PRE  root_directory должен быть объектом Path к существующей директории.
# @POST  Директории с одинаковым префиксом объединены в одну.
def consolidate_archive_folders(root_directory: Path) -> None:
    with belief_scope(f"Consolidate archives in {root_directory}"):
        assert isinstance(root_directory, Path), "root_directory must be a Path object."
        assert root_directory.is_dir(), "root_directory must be an existing directory."

        app_logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
        # Собираем все директории с архивами
        archive_dirs = []
        for item in root_directory.iterdir():
            if item.is_dir():
                # Проверяем, есть ли в директории ZIP-архивы
                if any(item.glob("*.zip")):
                    archive_dirs.append(item)
        # Группируем по слагу (части имени до первого '_')
        slug_groups = {}
        for dir_path in archive_dirs:
            dir_name = dir_path.name
            slug = dir_name.split('_')[0] if '_' in dir_name else dir_name
            if slug not in slug_groups:
                slug_groups[slug] = []
            slug_groups[slug].append(dir_path)
        # Для каждой группы консолидируем
        for slug, dirs in slug_groups.items():
            if len(dirs) <= 1:
                continue
            # Создаем целевую директорию
            target_dir = root_directory / slug
            target_dir.mkdir(exist_ok=True)
            app_logger.info("[consolidate_archive_folders][State] Consolidating %d directories under %s", len(dirs), target_dir)
            # Перемещаем содержимое
            for source_dir in dirs:
                if source_dir == target_dir:
                    continue
                for item in source_dir.iterdir():
                    dest_item = target_dir / item.name
                    try:
                        if item.is_dir():
                            shutil.move(str(item), str(dest_item))
                        else:
                            shutil.move(str(item), str(dest_item))
                    except Exception as e:
                        app_logger.error("[consolidate_archive_folders][Failure] Failed to move %s to %s: %s", item, dest_item, e)
                # Удаляем исходную директорию
                try:
                    source_dir.rmdir()
                    app_logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
                except Exception as e:
                    app_logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
# #endregion consolidate_archive_folders
# #endregion FileIO
