#!/usr/bin/env python3
"""
Счётчик касаний сенсорного экрана для mos-activity-monitor-local.

Работает как отдельный long-running service. Читает /proc/bus/input/devices,
выбирает только прямые сенсорные устройства с INPUT_PROP_DIRECT и исключает
тачпады с INPUT_PROP_POINTER/Touchpad. Считает только события
EV_KEY / BTN_TOUCH / value=1, чтобы одно касание экрана давало один инкремент.

Состояние записывается в:
  /var/lib/mos-activity-monitor/touch/touch_counter.json

Для чтения /dev/input/event* обычно нужны права root или доступ к группе input.
"""

import argparse
import datetime as dt
import json
import os
import re
import select
import struct
import time
from pathlib import Path

EV_KEY = 0x01
BTN_TOUCH = 0x14A
EVENT_STRUCT = struct.Struct("llHHI")

# Linux input properties. В /proc/bus/input/devices они представлены
# как hex-битмаска B: PROP=...
INPUT_PROP_POINTER = 0x00  # непрямой указатель: мышь/тачпад
INPUT_PROP_DIRECT = 0x01   # прямое устройство: touchscreen


def now_local_iso() -> str:
    """Возвращает локальное время в ISO 8601 для состояния счётчика."""
    return dt.datetime.now().astimezone().replace(microsecond=0).isoformat()


def load_config(path: str) -> dict:
    """Читает конфигурацию touch-counter из key=value файла."""
    cfg = {"output_dir": "/var/lib/mos-activity-monitor"}
    try:
        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
                k, v = line.split("=", 1)
                cfg[k.strip()] = v.strip()
    except FileNotFoundError:
        pass
    return cfg


def parse_hex_bitmask_words(value: str) -> set:
    """Разбирает hex-битмаску Linux input из /proc/bus/input/devices."""
    bits = set()
    words = value.strip().split()
    for word_index, word in enumerate(reversed(words)):
        try:
            n = int(word, 16)
        except ValueError:
            continue
        base = word_index * 32
        bit = 0
        while n:
            if n & 1:
                bits.add(base + bit)
            n >>= 1
            bit += 1
    return bits


def parse_block_field(block: str, prefix: str) -> str:
    """Достаёт значение строки с указанным префиксом из блока."""
    for line in block.splitlines():
        if line.startswith(prefix):
            return line[len(prefix):].strip()
    return ""


def block_name(block: str) -> str:
    """Возвращает имя input-устройства из блока описания ядра."""
    line = parse_block_field(block, "N: Name=")
    return line.strip().strip('"')


def block_props(block: str) -> set:
    """Возвращает множество INPUT_PROP_* свойств устройства."""
    match = re.search(r"^B:\s+PROP=([0-9A-Fa-f ]+)$", block, re.MULTILINE)
    if not match:
        return set()
    return parse_hex_bitmask_words(match.group(1))


def block_handlers(block: str) -> list:
    """Возвращает список handlers, включая eventN, из блока устройства."""
    match = re.search(r"^H:\s+Handlers=(.*)$", block, re.MULTILINE)
    if not match:
        return []
    return match.group(1).split()


def block_has_btn_touch(block: str) -> bool:
    """Проверяет наличие BTN_TOUCH в capabilities устройства."""
    # В большинстве устройств touchscreen BTN_TOUCH есть в KEY bitmask.
    # Для некоторых устройств эта проверка может быть недостаточной,
    # поэтому ниже
    # она используется как дополнительный плюс, а не единственный критерий.
    return "BTN_TOUCH" in block


def is_direct_touchscreen_block(block: str) -> bool:
    """Определяет, является ли устройство прямым touchscreen, а не touchpad."""
    name = block_name(block)
    lower_name = name.lower()
    lower_block = block.lower()
    props = block_props(block)

    has_direct = INPUT_PROP_DIRECT in props
    has_pointer = INPUT_PROP_POINTER in props

    # Надёжный основной признак: экран отдаёт INPUT_PROP_DIRECT.
    if has_direct and not has_pointer:
        return True

    # Тачпад явно исключаем даже при странных битмасках.
    if "touchpad" in lower_name or "touch pad" in lower_name:
        return False

    if has_pointer and not has_direct:
        return False

    # Fallback для старых/неполных драйверов, если в имени явно
    # написано touchscreen.
    if "touchscreen" in lower_block or "touch screen" in lower_block:
        return True

    return False


def detect_touchscreen_events(verbose: bool = False) -> list:
    """Находит event-устройства для подсчёта касаний экрана."""
    path = Path("/proc/bus/input/devices")
    if not path.exists():
        return []

    text = path.read_text(errors="replace")
    result = []

    for block in text.split("\n\n"):
        if not block.strip():
            continue

        name = block_name(block)
        handlers = block_handlers(block)
        props = block_props(block)
        is_direct = is_direct_touchscreen_block(block)

        event_paths = [
            "/dev/input/" + token
            for token in handlers
            if token.startswith("event")
        ]
        if not event_paths:
            continue

        if verbose:
            print(
                f"device: {name or '<unknown>'} | "
                f"handlers={','.join(event_paths)} | "
                f"props={sorted(props)} | direct_touchscreen={is_direct}",
                flush=True,
            )

        if is_direct:
            result.extend(event_paths)

    return sorted(set(result))


def read_previous_total(state_path: Path) -> int:
    """Читает ранее сохранённое число касаний из state-файла."""
    try:
        with state_path.open("r", encoding="utf-8") as f:
            payload = json.load(f)
        return int(payload.get("total_touches") or 0)
    except Exception:
        return 0


def write_state(
    state_path: Path,
    total_touches: int,
    devices: list,
    error: str = "",
) -> None:
    """Записывает состояние счётчика касаний в JSON-файл."""
    state_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = state_path.with_suffix(".tmp")
    payload = {
        "jsonVersion": "1.1",
        "updated_at": now_local_iso(),
        "available": bool(devices),
        "total_touches": int(total_touches),
        "devices": devices,
        "source": "linux-evdev-input-prop-direct-btn-touch",
        "counting_rule": (
            "EV_KEY BTN_TOUCH value=1 on INPUT_PROP_DIRECT devices only"
        ),
        "error": error,
    }
    with tmp.open("w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
    tmp.replace(state_path)


def open_devices(devices: list):
    """Открывает найденные evdev-устройства в неблокирующем режиме."""
    opened = []
    for dev in devices:
        try:
            fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK)
            opened.append((fd, dev))
        except PermissionError:
            pass
        except FileNotFoundError:
            pass
        except OSError:
            pass
    return opened


def run(
    config_path: str,
    refresh_seconds: int,
    write_seconds: int,
    verbose: bool,
) -> int:
    """Основной цикл long-running сервиса подсчёта касаний."""
    cfg = load_config(config_path)
    output_dir = Path(cfg.get("output_dir", "/var/lib/mos-activity-monitor"))
    state_path = output_dir / "touch" / "touch_counter.json"
    total = read_previous_total(state_path)

    devices = detect_touchscreen_events(verbose=verbose)
    opened = open_devices(devices)
    last_refresh = 0.0
    last_write = 0.0

    if verbose:
        print(f"selected touchscreen devices: {devices}", flush=True)

    if not opened:
        write_state(
            state_path,
            total,
            devices,
            "no direct touchscreen event device found or permission denied",
        )

    while True:
        now = time.time()
        if now - last_refresh >= refresh_seconds:
            for fd, _ in opened:
                try:
                    os.close(fd)
                except OSError:
                    pass
            devices = detect_touchscreen_events(verbose=verbose)
            opened = open_devices(devices)
            last_refresh = now
            if verbose:
                print(f"selected touchscreen devices: {devices}", flush=True)

        fds = [fd for fd, _ in opened]
        if not fds:
            if now - last_write >= write_seconds:
                write_state(
                    state_path,
                    total,
                    devices,
                    "no direct touchscreen event device found or "
                    "permission denied",
                )
                last_write = now
            time.sleep(2)
            continue

        readable, _, _ = select.select(fds, [], [], 1.0)
        for fd in readable:
            try:
                data = os.read(fd, EVENT_STRUCT.size * 64)
            except BlockingIOError:
                continue
            except OSError:
                continue
            for i in range(
                0, len(data) - EVENT_STRUCT.size + 1, EVENT_STRUCT.size
            ):
                chunk = data[i:i + EVENT_STRUCT.size]
                _, _, etype, code, value = EVENT_STRUCT.unpack(chunk)
                if etype == EV_KEY and code == BTN_TOUCH and value == 1:
                    total += 1
                    if verbose:
                        dev = next(
                            (d for f, d in opened if f == fd), "unknown"
                        )
                        print(
                            f"{now_local_iso()} | {dev} | TOUCH_DOWN | "
                            f"total_touches={total}",
                            flush=True,
                        )

        if time.time() - last_write >= write_seconds:
            write_state(state_path, total, devices, "")
            last_write = time.time()


def main() -> int:
    """Точка входа CLI-утилиты генерации отчёта."""
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "config",
        nargs="?",
        default="/etc/mos-activity-monitor/monitor.conf",
    )
    parser.add_argument("--refresh-seconds", type=int, default=60)
    parser.add_argument("--write-seconds", type=int, default=10)
    parser.add_argument("--verbose", action="store_true")
    args = parser.parse_args()
    return run(
        args.config,
        args.refresh_seconds,
        args.write_seconds,
        args.verbose,
    )


if __name__ == "__main__":
    raise SystemExit(main())
