#!/usr/bin/env python3
"""POG-CC PermDevMapper: persistent PTY in front of the FTDI P3 cable.

Owns the physical serial node (/dev/ser_DT1), exposes a long-lived PTY at
/dev/pogcc for DyzineFrame, and writes link status to a JSON status file.
Physical disconnects do not close the PTY — DyzineFrame keeps its handle.
"""

from __future__ import annotations

import argparse
import errno
import fcntl
import json
import os
import select
import signal
import sys
import termios
import time
from typing import Any, Optional

DEFAULT_PHYS = "/dev/ser_DT1"
DEFAULT_PTY_LINK = "/dev/pogcc"
DEFAULT_STATUS = "/run/dyzineframe/pogcc_link.status"
RECONNECT_SEC = 1.0
PUMP_TIMEOUT_SEC = 0.5
BUF_SIZE = 4096

_STOP = False


def _log(msg: str) -> None:
    print(f"pogcc-permdev: {msg}", flush=True)


def _on_signal(signum: int, _frame: Any) -> None:
    global _STOP
    _STOP = True
    _log(f"signal {signum}; shutting down")


def write_status(
    path: str,
    ok: bool,
    reason: str = "",
    phys: str = DEFAULT_PHYS,
    pty: str = DEFAULT_PTY_LINK,
) -> None:
    payload = {
        "ok": bool(ok),
        "ts": time.time(),
        "reason": reason or "",
        "phys": phys,
        "pty": pty,
    }
    directory = os.path.dirname(path) or "."
    os.makedirs(directory, mode=0o755, exist_ok=True)
    tmp = f"{path}.tmp.{os.getpid()}"
    data = json.dumps(payload, separators=(",", ":")) + "\n"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    try:
        os.write(fd, data.encode("utf-8"))
        os.fsync(fd)
    finally:
        os.close(fd)
    os.replace(tmp, path)


def _set_raw(fd: int) -> None:
    attrs = termios.tcgetattr(fd)
    # iflag
    attrs[0] &= ~(
        termios.IGNBRK
        | termios.BRKINT
        | termios.PARMRK
        | termios.ISTRIP
        | termios.INLCR
        | termios.IGNCR
        | termios.ICRNL
        | termios.IXON
    )
    # oflag
    attrs[1] &= ~termios.OPOST
    # cflag
    attrs[2] &= ~(termios.CSIZE | termios.PARENB)
    attrs[2] |= termios.CS8
    # lflag
    attrs[3] &= ~(
        termios.ECHO | termios.ECHONL | termios.ICANON | termios.ISIG | termios.IEXTEN
    )
    attrs[6][termios.VMIN] = 0
    attrs[6][termios.VTIME] = 0
    termios.tcsetattr(fd, termios.TCSANOW, attrs)


def _set_nonblocking(fd: int) -> None:
    flags = fcntl.fcntl(fd, fcntl.F_GETFL)
    fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)


def _configure_phys(fd: int, baud: int = 115200) -> None:
    _set_raw(fd)
    attrs = termios.tcgetattr(fd)
    try:
        speed = getattr(termios, f"B{baud}")
    except AttributeError:
        speed = termios.B115200
    attrs[4] = speed  # ispeed
    attrs[5] = speed  # ospeed
    attrs[2] |= termios.CLOCAL | termios.CREAD
    termios.tcsetattr(fd, termios.TCSANOW, attrs)
    _set_nonblocking(fd)


def create_pty_link(pty_link: str) -> tuple[int, str]:
    master_fd, slave_fd = os.openpty()
    slave_name = os.ttyname(slave_fd)
    os.close(slave_fd)
    _set_raw(master_fd)
    _set_nonblocking(master_fd)
    # Stable app endpoint: replace stale symlink/node.
    try:
        if os.path.islink(pty_link) or os.path.exists(pty_link):
            os.unlink(pty_link)
    except OSError as exc:
        if exc.errno != errno.ENOENT:
            raise
    os.symlink(slave_name, pty_link)
    try:
        os.chmod(slave_name, 0o666)
    except OSError:
        pass
    _log(f"pty ready: {slave_name} -> {pty_link}")
    return master_fd, slave_name


def open_phys(phys: str) -> Optional[int]:
    if not os.path.exists(phys):
        return None
    try:
        fd = os.open(phys, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
    except OSError as exc:
        _log(f"open {phys} failed: {exc}")
        return None
    try:
        _configure_phys(fd)
    except OSError as exc:
        _log(f"configure {phys} failed: {exc}")
        try:
            os.close(fd)
        except OSError:
            pass
        return None
    return fd


def _safe_close(fd: Optional[int]) -> None:
    if fd is None:
        return
    try:
        os.close(fd)
    except OSError:
        pass


def _drain_write(fd: int, data: bytes) -> bool:
    view = memoryview(data)
    while view:
        try:
            n = os.write(fd, view)
        except BlockingIOError:
            _, w, _ = select.select([], [fd], [], PUMP_TIMEOUT_SEC)
            if not w:
                return False
            continue
        except OSError:
            return False
        if n <= 0:
            return False
        view = view[n:]
    return True


def pump_until_fail(master_fd: int, phys_fd: int, phys_path: str) -> str:
    """Bidirectional copy until phys I/O fails. Returns reason string."""
    while not _STOP:
        if not os.path.exists(phys_path):
            return "phys_gone"
        try:
            r, _, x = select.select(
                [master_fd, phys_fd], [], [master_fd, phys_fd], PUMP_TIMEOUT_SEC
            )
        except (OSError, ValueError) as exc:
            return f"select_failed:{exc}"
        if x:
            return "fd_exception"
        if master_fd in r:
            try:
                chunk = os.read(master_fd, BUF_SIZE)
            except BlockingIOError:
                chunk = b""
            except OSError as exc:
                if exc.errno == errno.EIO:
                    # A PTY master reports EIO while no process has its slave
                    # open. DyzineFrame may be stopped/restarting; this is not
                    # a failure of the physical POG-CC link.
                    time.sleep(0.05)
                    continue
                return f"pty_read:{exc}"
            if chunk:
                if not _drain_write(phys_fd, chunk):
                    return "phys_write_fail"
        if phys_fd in r:
            try:
                chunk = os.read(phys_fd, BUF_SIZE)
            except BlockingIOError:
                chunk = b""
            except OSError as exc:
                return f"phys_read:{exc}"
            if chunk == b"":
                # Non-blocking empty read is normal (VMIN=0). Fail only if FD died.
                try:
                    os.fstat(phys_fd)
                except OSError:
                    return "phys_gone"
            elif chunk:
                if not _drain_write(master_fd, chunk):
                    # App side closed briefly — keep PTY/phys; do not tear phys.
                    pass
    return "mapper_stopped"


def run_loop(phys: str, pty_link: str, status_path: str) -> int:
    master_fd, _slave_name = create_pty_link(pty_link)
    write_status(status_path, False, "waiting_phys", phys=phys, pty=pty_link)

    try:
        while not _STOP:
            phys_fd = open_phys(phys)
            if phys_fd is None:
                write_status(status_path, False, "phys_gone", phys=phys, pty=pty_link)
                time.sleep(RECONNECT_SEC)
                continue

            write_status(status_path, True, "", phys=phys, pty=pty_link)
            _log(f"phys up: {phys}")
            reason = pump_until_fail(master_fd, phys_fd, phys)
            _safe_close(phys_fd)
            if _STOP or reason == "mapper_stopped":
                write_status(
                    status_path, False, "mapper_stopped", phys=phys, pty=pty_link
                )
                break
            _log(f"phys down ({reason}); reconnecting")
            write_status(status_path, False, reason or "phys_gone", phys=phys, pty=pty_link)
            time.sleep(RECONNECT_SEC)
    finally:
        write_status(status_path, False, "mapper_stopped", phys=phys, pty=pty_link)
        _safe_close(master_fd)
        try:
            if os.path.islink(pty_link):
                os.unlink(pty_link)
        except OSError:
            pass

    return 0


def main(argv: Optional[list[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="POG-CC PermDevMapper")
    parser.add_argument("--phys", default=DEFAULT_PHYS)
    parser.add_argument("--pty-link", default=DEFAULT_PTY_LINK)
    parser.add_argument("--status", default=DEFAULT_STATUS)
    args = parser.parse_args(argv)

    signal.signal(signal.SIGTERM, _on_signal)
    signal.signal(signal.SIGINT, _on_signal)

    _log(
        f"start phys={args.phys} pty={args.pty_link} status={args.status}"
    )
    return run_loop(args.phys, args.pty_link, args.status)


if __name__ == "__main__":
    sys.exit(main())
