from __future__ import annotations

import argparse
from importlib.metadata import PackageNotFoundError, version
import signal
import subprocess
import sys
import threading
import time
from typing import Sequence

from govoplan_core.core.runtime_coordination import (
    LeaseClaim,
    acquire_lease,
    release_lease,
    renew_lease,
    runtime_identity,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Run one process while holding a database-fenced deployment lease."
    )
    parser.add_argument(
        "--resource", required=True, help="Stable cluster-wide lease key."
    )
    parser.add_argument("--ttl-seconds", type=int, default=60)
    parser.add_argument("--renew-seconds", type=int, default=15)
    parser.add_argument("--wait-seconds", type=int, default=0)
    parser.add_argument("command", nargs=argparse.REMAINDER)
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    command = list(args.command)
    if command and command[0] == "--":
        command.pop(0)
    if not command:
        raise SystemExit("fenced-run requires a command after --")
    if args.ttl_seconds < 10:
        raise SystemExit("--ttl-seconds must be at least 10")
    if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
        raise SystemExit("--renew-seconds must be less than half the lease TTL")
    configure_database(settings.database_url)
    identity = runtime_identity(
        settings,
        software_version=_core_version(),
        role=str(settings.runtime_role or "deployment"),
    )
    claim = _wait_for_lease(
        resource=args.resource,
        identity=identity,
        ttl_seconds=args.ttl_seconds,
        wait_seconds=max(0, args.wait_seconds),
    )
    if claim is None:
        print(
            f"lease unavailable: {args.resource}",
            file=sys.stderr,
        )
        return 75

    process = subprocess.Popen(command)  # noqa: S603 - argv is an operator-owned container command
    stop = threading.Event()
    fence_lost = threading.Event()
    renewer = threading.Thread(
        target=_renew_loop,
        kwargs={
            "claim": claim,
            "ttl_seconds": args.ttl_seconds,
            "renew_seconds": args.renew_seconds,
            "stop": stop,
            "fence_lost": fence_lost,
            "process": process,
        },
        daemon=True,
        name=f"govoplan-fence:{args.resource}",
    )
    renewer.start()
    previous_handlers = _forward_signals(process)
    try:
        return_code = process.wait()
    finally:
        stop.set()
        renewer.join(timeout=args.renew_seconds + 2)
        _restore_signals(previous_handlers)
        _release(claim)
    if fence_lost.is_set():
        return 74
    return int(return_code)


def _wait_for_lease(
    *,
    resource: str,
    identity,
    ttl_seconds: int,
    wait_seconds: int,
) -> LeaseClaim | None:
    deadline = time.monotonic() + wait_seconds
    while True:
        with get_database().SessionLocal() as session:
            claim = acquire_lease(
                session,
                installation_id=identity.installation_id,
                resource_key=resource,
                holder_node_id=identity.node_id,
                holder_incarnation=identity.incarnation,
                ttl_seconds=ttl_seconds,
                metadata={"role": identity.role},
            )
            session.commit()
        if claim is not None or time.monotonic() >= deadline:
            return claim
        time.sleep(min(2, max(0.1, deadline - time.monotonic())))


def _renew_loop(
    *,
    claim: LeaseClaim,
    ttl_seconds: int,
    renew_seconds: int,
    stop: threading.Event,
    fence_lost: threading.Event,
    process: subprocess.Popen[bytes],
) -> None:
    active_claim = claim
    while not stop.wait(renew_seconds):
        try:
            with get_database().SessionLocal() as session:
                active_claim = renew_lease(
                    session,
                    active_claim,
                    ttl_seconds=ttl_seconds,
                )
                session.commit()
        except Exception:  # noqa: BLE001 - any renewal failure loses authority
            fence_lost.set()
            _terminate_process(process)
            return


def _terminate_process(
    process: subprocess.Popen[bytes],
    *,
    timeout_seconds: float = 5.0,
) -> None:
    if process.poll() is not None:
        return
    process.terminate()
    try:
        process.wait(timeout=timeout_seconds)
    except subprocess.TimeoutExpired:
        process.kill()
        process.wait(timeout=timeout_seconds)


def _release(claim: LeaseClaim) -> None:
    try:
        with get_database().SessionLocal() as session:
            release_lease(session, claim)
            session.commit()
    except Exception:  # noqa: BLE001 - authority is already lost; release is best effort
        return


def _forward_signals(
    process: subprocess.Popen[bytes],
) -> dict[int, signal.Handlers]:
    previous: dict[int, signal.Handlers] = {}

    def forward(signum, _frame) -> None:
        if process.poll() is None:
            process.send_signal(signum)

    for signum in (signal.SIGTERM, signal.SIGINT):
        previous[signum] = signal.getsignal(signum)
        signal.signal(signum, forward)
    return previous


def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
    for signum, handler in previous.items():
        signal.signal(signum, handler)


def _core_version() -> str:
    try:
        return version("govoplan-core")
    except PackageNotFoundError:
        return "development"


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