#!/usr/bin/env python3
"""
Локальный агрегатор mos-activity-monitor.

Читает snapshot JSON из локальной папки за последние report_minutes минут
и создаёт итоговый отчёт: сколько минут каждая программа была запущена,
активна, неактивна и свёрнута.

Источники полей итогового JSON:

Общие поля отчёта:
  jsonVersion       - константа формата JSON, сейчас "1.1".
  messageId         - новый UUID4, генерируется модулем uuid при
                      каждом запуске.
  report_time       - локальное время запуска отчёта:
                      datetime.now().astimezone().
  period_start      - report_time минус report_minutes из monitor.conf.
  period_end        - report_time.
  sample_minutes    - monitor.conf: sample_minutes, по умолчанию 2.
  report_minutes    - monitor.conf: report_minutes, по умолчанию 30.
  snapshot_rows     - количество snapshot-файлов, попавших в период отчёта.
  program_key       - monitor.conf: program_key, по умолчанию app_id.
  collect_browser_titles - monitor.conf: добавлять ли заголовки
                           активных окон браузеров, по умолчанию true.
  errors            - ошибки чтения/разбора snapshot-файлов и event_time.

Поля идентификации АРМ и сессии:
  hostname          - последнее непустое hostname из snapshot; если snapshot
                      не дал значения, socket.gethostname().
  username          - последнее непустое username из snapshot; fallback USER
                      из окружения процесса отчётчика.
  deviceId          - последнее непустое deviceId из snapshot; fallback
                      /etc/uds-system-agent/registration. Для совместимости
                      также принимается старое поле assistent_id.
  sessionId         - последнее непустое sessionId из snapshot.
  bootId            - последнее непустое bootId из snapshot.
  sessionIds        - множество всех sessionId из snapshot за период.
  bootIds           - множество всех bootId из snapshot за период.

Поля блокировки:
  locked_minutes    - сумма sample_minutes по snapshot, где payload.locked
                      или payload.session.locked приводится к true.
  unlocked_minutes  - сумма sample_minutes по остальным snapshot.
  locked_samples    - количество snapshot с locked=true.
  unlocked_samples  - количество snapshot с locked=false.

Поля touchscreen:
  touchscreen_available - true, если хотя бы в одном snapshot
                          touchscreen.available приводится к true.
  touch_count           - разница между последним и первым total_touches
                          из touchscreen.total_touches за период.
  touch_samples         - количество snapshot с доступным целочисленным
                          touchscreen.total_touches.

Поля systemMetrics:
  systemDiskFreePercent - min/max/avg/last по
                          snapshot.systemMetrics.systemDisk.free_percent.
  memoryAvailablePercent - min/max/avg/last по
                           snapshot.systemMetrics.memory.available_percent.
  Внутри каждой сводки available показывает наличие валидных чисел,
  samples - их количество, min/max/avg/last округляются до двух знаков.

Поля programs[]:
  Данные берутся из snapshot.windows[]. Служебные окна с system_window=true
  исключаются из пользовательского отчёта.

  program          - ключ группировки окна. Источник выбирает program_key:
                     title -> window.title,
                     pid -> window.pid,
                     exe_path -> window.exe_path,
                     иначе window.app_id/application_name/title.
  app_id           - равен вычисленному program.
  application_name - window.application_name, затем window.app_id, затем
                     program или "unknown".
  launched_minutes - sample_minutes за каждый snapshot, где программа была
                     найдена хотя бы в одном окне.
  active_minutes   - sample_minutes за snapshot, где хотя бы одно окно
                     программы active/status=="active".
  minimized_minutes - sample_minutes за snapshot без active, но с
                      minimized/status=="minimized".
  inactive_minutes - sample_minutes за snapshot без active и minimized.
  samples          - количество snapshot, где программа найдена.
  active_samples   - количество snapshot, засчитанных как active.
  minimized_samples - количество snapshot, засчитанных как minimized.
  inactive_samples - количество snapshot, засчитанных как inactive.

Поля browser_titles[]:
  Данные берутся только из активных окон распознанных браузеров.

  browser        - нормализованное имя браузера.
  title          - заголовок активного окна из snapshot.windows[].title.
  active_minutes - sample_minutes за snapshot с этим заголовком.
  samples        - количество таких snapshot.

Файлы:
  Вход:  <output_dir>/snapshots/window_snapshot_*.json, где output_dir берётся
         из monitor.conf или равен /var/lib/mos-activity-monitor.
  Выход: <output_dir>/reports/mos_activity_report_<timestamp>.json и
         <output_dir>/reports/latest_report.json.
"""

import datetime as dt
import glob
import json
import os
import socket
import sys
import uuid
from collections import defaultdict
from pathlib import Path

DEFAULT_CONFIG = "/etc/mos-activity-monitor/monitor.conf"

BROWSER_ALIASES = (
    ("firefox", ("firefox", "mozilla-firefox")),
    ("google-chrome", ("google-chrome", "chrome")),
    ("chromium", ("chromium", "chromium-browser")),
    ("brave-browser", ("brave", "brave-browser")),
    ("microsoft-edge", ("microsoft-edge", "msedge")),
    ("yandex-browser", ("yandex-browser", "yandex")),
    ("vivaldi", ("vivaldi",)),
    ("opera", ("opera",)),
)


def read_key_value_config(path: str) -> dict:
    """Читает key=value конфигурацию агента и возвращает словарь настроек."""
    cfg = {
        "output_dir": "/var/lib/mos-activity-monitor",
        "sample_minutes": "2",
        "report_minutes": "30",
        "program_key": "app_id",
        "collect_browser_titles": "true",
    }
    if not os.path.exists(path):
        return cfg
    with open(path, "r", encoding="utf-8") as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            cfg[key.strip()] = value.strip()
    return cfg


def parse_event_time(value: str):
    """Преобразует строковое время snapshot в timezone-aware datetime."""
    if not value:
        return None

    value = str(value).strip()

    # New format: 2026-05-07T17:35:00+03:00
    try:
        parsed = dt.datetime.fromisoformat(value)
        if parsed.tzinfo is None:
            parsed = parsed.astimezone()
        else:
            parsed = parsed.astimezone()
        return parsed
    except ValueError:
        pass

    # Backward compatibility with old local timestamps without timezone.
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
        try:
            parsed = dt.datetime.strptime(value, fmt)
            return parsed.astimezone()
        except ValueError:
            pass
    return None


def now_local() -> dt.datetime:
    """Возвращает текущее локальное время с таймзоной без микросекунд."""
    return dt.datetime.now().astimezone().replace(microsecond=0)


def iso_with_timezone(value: dt.datetime) -> str:
    """Форматирует datetime в ISO 8601 с указанием таймзоны."""
    if value.tzinfo is None:
        value = value.astimezone()
    return value.isoformat(timespec="seconds")


def choose_program_id(window: dict, program_key: str) -> str:
    """Выбирает стабильный идентификатор программы для группировки отчёта."""
    if program_key == "title":
        return window.get("title") or window.get("app_id") or "unknown"
    if program_key == "pid":
        return str(window.get("pid") or "unknown")
    if program_key == "exe_path":
        return window.get("exe_path") or window.get("app_id") or "unknown"
    return (
        window.get("app_id")
        or window.get("application_name")
        or window.get("title")
        or "unknown"
    )


def choose_application_name(window: dict, program_id: str) -> str:
    """Возвращает человекочитаемое имя приложения для отчёта."""
    return (
        window.get("application_name")
        or window.get("app_id")
        or program_id
        or "unknown"
    )


def detect_browser(window: dict) -> str:
    """Возвращает имя известного браузера или пустую строку."""
    values = []
    for key in (
        "app_id",
        "application_name",
        "resource_name",
        "resource_class",
        "process_name",
    ):
        value = str(window.get(key) or "").strip().lower()
        if value:
            values.append(value.replace("_", "-").replace(" ", "-"))

    exe_name = os.path.basename(str(window.get("exe_path") or ""))
    if exe_name:
        values.append(exe_name.strip().lower().replace("_", "-"))

    for browser, aliases in BROWSER_ALIASES:
        for value in values:
            for alias in aliases:
                if (
                    value == alias
                    or value.startswith(f"{alias}-")
                    or value.endswith(f".{alias}")
                ):
                    return browser
    return ""


def as_bool(value) -> bool:
    """Нормализует значение из JSON/строки в bool."""
    if isinstance(value, bool):
        return value
    if value is None:
        return False
    return str(value).strip().lower() in ("1", "true", "yes", "on", "locked")

def as_float(value):
    """Безопасно преобразует значение в float или возвращает None."""
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def summarize_values(values):
    """Считает min/max/avg/last для числовой серии метрик."""
    clean = [float(v) for v in values if v is not None]
    if not clean:
        return {
            "available": False,
            "samples": 0,
            "min": None,
            "max": None,
            "avg": None,
            "last": None,
        }
    return {
        "available": True,
        "samples": len(clean),
        "min": round(min(clean), 2),
        "max": round(max(clean), 2),
        "avg": round(sum(clean) / len(clean), 2),
        "last": round(clean[-1], 2),
    }


def read_device_id() -> str:
    """
    Читает идентификатор АРМ из /etc/uds-system-agent/registration.

    Если файл существует, используется первая строка. Если файла нет или он
    пустой, возвращается пустая строка. Поле в отчёте называется deviceId,
    чтобы совпадать с форматом snapshot-файлов агента.
    """
    path = "/etc/uds-system-agent/registration"
    try:
        with open(path, "r", encoding="utf-8") as f:
            return f.readline().strip()
    except FileNotFoundError:
        return ""
    except Exception:
        return ""


def load_snapshots(
    snapshot_dir: Path,
    period_start: dt.datetime,
    period_end: dt.datetime,
):
    """Загружает snapshot-файлы за указанный отчётный период."""
    snapshots = []
    errors = []
    pattern = str(snapshot_dir / "window_snapshot_*.json")
    for path in sorted(glob.glob(pattern)):
        try:
            with open(path, "r", encoding="utf-8") as f:
                payload = json.load(f)
            event_time = parse_event_time(payload.get("event_time", ""))
            if event_time is None:
                errors.append(
                    {"file": path, "error": "event_time is missing or invalid"}
                )
                continue
            if period_start <= event_time < period_end:
                snapshots.append((event_time, path, payload))
        except Exception as exc:
            errors.append({"file": path, "error": str(exc)})
    return snapshots, errors


def build_report(config_path: str) -> dict:
    """Строит 30-минутный агрегированный отчёт по snapshot-файлам."""
    cfg = read_key_value_config(config_path)
    output_dir = Path(cfg.get("output_dir", "/var/lib/mos-activity-monitor"))
    sample_minutes = int(cfg.get("sample_minutes", "2"))
    report_minutes = int(cfg.get("report_minutes", "30"))
    program_key = cfg.get("program_key", "app_id")
    collect_browser_titles = as_bool(
        cfg.get("collect_browser_titles", "true")
    )

    now = now_local()
    period_end = now
    period_start = period_end - dt.timedelta(minutes=report_minutes)

    snapshot_dir = output_dir / "snapshots"
    report_dir = output_dir / "reports"
    report_dir.mkdir(parents=True, exist_ok=True)

    snapshots, errors = load_snapshots(snapshot_dir, period_start, period_end)

    programs = defaultdict(
        lambda: {
            "program": "",
            "app_id": "",
            "application_name": "",
            "launched_minutes": 0,
            "active_minutes": 0,
            "inactive_minutes": 0,
            "minimized_minutes": 0,
            "samples": 0,
            "active_samples": 0,
            "inactive_samples": 0,
            "minimized_samples": 0,
        }
    )
    browser_titles = defaultdict(
        lambda: {
            "browser": "",
            "title": "",
            "active_minutes": 0,
            "samples": 0,
        }
    )

    hostname = socket.gethostname()
    username = os.environ.get("USER", "")
    device_id = read_device_id()
    session_id = ""
    boot_id = ""
    session_ids = set()
    boot_ids = set()
    locked_minutes = 0
    unlocked_minutes = 0
    locked_samples = 0
    unlocked_samples = 0
    touchscreen_available = False
    touch_first_total = None
    touch_last_total = None
    touch_samples = 0

    disk_free_percent_values = []
    memory_available_percent_values = []

    for event_time, path, payload in snapshots:
        hostname = payload.get("hostname") or hostname
        username = payload.get("username") or username
        device_id = (
            payload.get("deviceId")
            or payload.get("assistent_id")
            or device_id
        )
        session_id = payload.get("sessionId") or session_id
        boot_id = payload.get("bootId") or boot_id
        if payload.get("sessionId"):
            session_ids.add(str(payload.get("sessionId")))
        if payload.get("bootId"):
            boot_ids.add(str(payload.get("bootId")))

        touchscreen = payload.get("touchscreen") or {}
        if as_bool(touchscreen.get("available")):
            touchscreen_available = True
            total = touchscreen.get("total_touches")
            if isinstance(total, int):
                if touch_first_total is None:
                    touch_first_total = total
                touch_last_total = total
                touch_samples += 1

        locked = as_bool(payload.get("locked")) or as_bool(
            (payload.get("session") or {}).get("locked")
        )
        if locked:
            locked_minutes += sample_minutes
            locked_samples += 1
        else:
            unlocked_minutes += sample_minutes
            unlocked_samples += 1

        system_metrics = payload.get("systemMetrics") or {}
        disk = system_metrics.get("systemDisk") or {}
        memory = system_metrics.get("memory") or {}
        disk_free_percent_values.append(as_float(disk.get("free_percent")))
        memory_available_percent_values.append(
            as_float(memory.get("available_percent"))
        )

        # В рамках одного среза программа считается запущенной один раз,
        # даже если открыто несколько окон этой программы.
        seen_programs = set()
        per_program_status = defaultdict(
            lambda: {"active": False, "inactive": False, "minimized": False}
        )
        per_program_names = {}
        seen_browser_titles = set()

        for window in payload.get("windows", []):
            # Служебные окна рабочего стола, панели и shell-компоненты
            # сохраняются в snapshot для диагностики, но не участвуют
            # в пользовательском отчете по запущенным программам.
            if window.get("system_window") is True:
                continue

            program = choose_program_id(window, program_key)
            if not program:
                program = "unknown"
            seen_programs.add(program)
            per_program_names.setdefault(
                program, choose_application_name(window, program)
            )

            status = window.get("status")
            if status == "active" or window.get("active") is True:
                per_program_status[program]["active"] = True
                if collect_browser_titles:
                    browser = detect_browser(window)
                    title = str(window.get("title") or "").strip()
                    if browser and title:
                        seen_browser_titles.add((browser, title))
            elif status == "minimized" or window.get("minimized") is True:
                per_program_status[program]["minimized"] = True
            else:
                per_program_status[program]["inactive"] = True

        for program in seen_programs:
            row = programs[program]
            row["program"] = program
            row["app_id"] = program
            row["application_name"] = (
                per_program_names.get(program)
                or row.get("application_name")
                or program
            )
            row["launched_minutes"] += sample_minutes
            row["samples"] += 1

            states = per_program_status[program]
            if states["active"]:
                row["active_minutes"] += sample_minutes
                row["active_samples"] += 1
            elif states["minimized"]:
                row["minimized_minutes"] += sample_minutes
                row["minimized_samples"] += 1
            else:
                row["inactive_minutes"] += sample_minutes
                row["inactive_samples"] += 1

        for browser, title in seen_browser_titles:
            row = browser_titles[(browser, title)]
            row["browser"] = browser
            row["title"] = title
            row["active_minutes"] += sample_minutes
            row["samples"] += 1

    program_rows = sorted(
        programs.values(),
        key=lambda x: (
            -x["launched_minutes"],
            x["application_name"] or x["program"],
        ),
    )
    browser_title_rows = sorted(
        browser_titles.values(),
        key=lambda x: (-x["active_minutes"], x["browser"], x["title"]),
    )
    touch_count = 0
    if (
        touch_first_total is not None
        and touch_last_total is not None
        and touch_last_total >= touch_first_total
    ):
        touch_count = touch_last_total - touch_first_total

    report = {
        "jsonVersion": "1.1",
        "messageId": str(uuid.uuid4()),
        "report_time": iso_with_timezone(now),
        "period_start": iso_with_timezone(period_start),
        "period_end": iso_with_timezone(period_end),
        "hostname": hostname,
        "username": username,
        "deviceId": device_id,
        "sessionId": session_id,
        "bootId": boot_id,
        "sessionIds": sorted(session_ids),
        "bootIds": sorted(boot_ids),
        "locked_minutes": locked_minutes,
        "unlocked_minutes": unlocked_minutes,
        "locked_samples": locked_samples,
        "unlocked_samples": unlocked_samples,
        "touchscreen_available": touchscreen_available,
        "touch_count": touch_count,
        "touch_samples": touch_samples,
        "systemMetrics": {
            "systemDiskFreePercent": summarize_values(
                disk_free_percent_values
            ),
            "memoryAvailablePercent": summarize_values(
                memory_available_percent_values
            ),
        },
        "sample_minutes": sample_minutes,
        "report_minutes": report_minutes,
        "snapshot_rows": len(snapshots),
        "program_key": program_key,
        "programs": program_rows,
        "browser_titles": browser_title_rows,
        "errors": errors,
    }

    stamp = now.strftime("%Y%m%d_%H%M%S")
    report_path = report_dir / f"mos_activity_report_{stamp}.json"
    with open(report_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    latest_path = report_dir / "latest_report.json"
    with open(latest_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    report["report_path"] = str(report_path)
    report["latest_report_path"] = str(latest_path)
    return report


def main():
    """Точка входа CLI-утилиты генерации отчёта."""
    config_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CONFIG
    report = build_report(config_path)
    print(f"Saved report: {report['report_path']}")
    print(f"Latest report: {report['latest_report_path']}")
    print(f"Snapshots used: {report['snapshot_rows']}")
    print(f"Programs: {len(report['programs'])}")


if __name__ == "__main__":
    main()
