#!/usr/bin/env python3
"""GMRelay is the official lightweight client for GMNet and PrMers.

GMNet coordinates a public Gaussian Mersenne search at:
https://gmnet.gaussianmersenne.workers.dev/

The client downloads assignments, updates worktodo.txt, renews leases, finds
PrMers JSON result files, and submits them automatically. It supports the
normal record-search scheduler, deep P-1 Stage 1 screening, and factor-only
mixed P-1 Stage 2 plus ECM campaigns, and full GM/GQ pair work for TF, P-1, ECM, PRP and Proth. Standard-library only; Python 3.9+.
"""

from __future__ import annotations

import argparse
import getpass
import hashlib
import json
import os
import platform
import ssl
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional

VERSION = "1.7.0"
USER_AGENT = f"GMRelay/{VERSION} Python/{platform.python_version()}"
WORK_TYPES = ("ANY", "GMTF", "GMPROTH", "GMPRP", "GMPMINUS1", "GMECM", "GMCHAIN")
RESULT_LIMIT = 16 * 1024
DEFAULT_INTERVAL = 60
DEFAULT_SERVER = "https://gmnet.gaussianmersenne.workers.dev"
REQUEST_TYPE_ALIASES = {
    "p1": "GMPMINUS1",
    "p-1": "GMPMINUS1",
    "pm1": "GMPMINUS1",
    "p1s1": "GMPMINUS1",
    "pm1s1": "GMPMINUS1",
    "p1-stage1": "GMPMINUS1",
    "stage1": "GMPMINUS1",
    "tf": "GMTF",
    "trial": "GMTF",
    "trial-factor": "GMTF",
    "ecm": "GMECM",
    "mixed": "GMCHAIN",
    "chain": "GMCHAIN",
    "prp": "GMPRP",
    "exact": "GMPROTH",
    "proth": "GMPROTH",
}
REQUEST_TYPE_PROFILES = {
    "p1s1": "pm1-stage1",
    "pm1s1": "pm1-stage1",
    "p1-stage1": "pm1-stage1",
    "stage1": "pm1-stage1",
    "tf": "tf",
    "trial": "tf",
    "trial-factor": "tf",
    "mixed": "mixed",
    "chain": "mixed",
}
CAMPAIGN_PROFILES = ("default", "tf", "pm1-stage1", "mixed")
CAMPAIGN_CONFIG_KEYS = (
    "campaign_mode", "pm1_b1", "pm1_b2", "base",
    "ecm_b1", "ecm_b2", "curves", "sieve_limit", "chunk_bits",
    "tf_from_bits", "tf_to_bits", "target_family", "tf_chunk_candidates", "tf_sieve_prime",
    "min_exponent", "max_exponent",
)


def default_config_path() -> Path:
    if os.name == "nt":
        base = Path(os.environ.get("APPDATA", Path.home()))
    else:
        base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
    return base / "gmrelay" / "config.json"


def log(message: str) -> None:
    print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True)


def atomic_json(path: Path, value: Any, private: bool = True) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
    try:
        if private and os.name != "nt":
            os.fchmod(fd, 0o600)
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True)
            handle.write("\n")
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(name, path)
        if private and os.name != "nt":
            path.chmod(0o600)
    finally:
        try:
            os.unlink(name)
        except FileNotFoundError:
            pass


def load_json(path: Path, default: Any) -> Any:
    try:
        with path.open("r", encoding="utf-8") as handle:
            return json.load(handle)
    except FileNotFoundError:
        return default
    except (OSError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"Unable to read {path}: {exc}") from exc


def normalize_server(value: str) -> str:
    value = value.strip().rstrip("/")
    parsed = urllib.parse.urlparse(value)
    if parsed.scheme not in ("https", "http") or not parsed.netloc or parsed.path not in ("", "/"):
        raise ValueError("Expected URL format: https://site-name")
    if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
        raise ValueError("Unencrypted HTTP is refused outside local development")
    return value


def api_call(config: Dict[str, Any], path: str, method: str = "GET", payload: Any = None) -> Dict[str, Any]:
    url = config["server"] + path
    headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
    data = None
    if payload is not None:
        data = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    api_key = config.get("api_key")
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"
    request = urllib.request.Request(url, data=data, headers=headers, method=method)
    context = ssl.create_default_context()
    try:
        with urllib.request.urlopen(request, timeout=45, context=context) as response:
            raw = response.read(1024 * 1024)
            result = json.loads(raw.decode("utf-8"))
    except urllib.error.HTTPError as exc:
        raw = exc.read(1024 * 1024)
        try:
            result = json.loads(raw.decode("utf-8"))
            message = result.get("error", {}).get("message", str(exc))
        except Exception:
            message = str(exc)
        raise RuntimeError(f"API {exc.code}: {message}") from exc
    except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"API connection failed: {exc}") from exc
    if not isinstance(result, dict) or result.get("ok") is False:
        raise RuntimeError(result.get("error", {}).get("message", "Invalid API response"))
    return result


def has_leading_zero_bits(digest: bytes, difficulty: int) -> bool:
    full_bytes, remaining = divmod(difficulty, 8)
    if any(digest[index] != 0 for index in range(full_bytes)):
        return False
    if remaining == 0:
        return True
    return digest[full_bytes] >> (8 - remaining) == 0


def solve_registration_challenge(challenge: str, difficulty: int) -> str:
    if not 16 <= difficulty <= 26:
        raise RuntimeError("Unsupported registration challenge difficulty")
    started = time.monotonic()
    nonce = 0
    while True:
        candidate = str(nonce)
        digest = hashlib.sha256(f"{challenge}:{candidate}".encode("utf-8")).digest()
        if has_leading_zero_bits(digest, difficulty):
            log(f"Registration proof solved in {time.monotonic() - started:.1f}s")
            return candidate
        nonce += 1
        if nonce % 250000 == 0:
            log(f"Creating secure profile... {nonce:,} attempts")


def quick_join(config_path: Path, nickname: str, workdir: Optional[Path] = None) -> Dict[str, Any]:
    nickname = nickname.strip()
    if not nickname:
        raise ValueError("A public nickname is required")
    if config_path.exists():
        raise RuntimeError(f"Configuration already exists: {config_path}. Use --loop or --setup.")
    target_dir = (workdir or Path.cwd()).expanduser().resolve()
    target_dir.mkdir(parents=True, exist_ok=True)
    bootstrap = {"server": DEFAULT_SERVER}
    query = urllib.parse.urlencode({"public_name": nickname})
    challenge_data = api_call(bootstrap, f"/api/contributors/bootstrap-challenge?{query}")
    challenge = challenge_data["challenge"]
    difficulty = int(challenge_data["difficulty_bits"])
    nonce = solve_registration_challenge(challenge, difficulty)
    registration = api_call(bootstrap, "/api/contributors/bootstrap", "POST", {
        "public_name": nickname,
        "challenge_id": challenge_data["challenge_id"],
        "nonce": nonce,
        "device_id": str(uuid.uuid4()),
    })
    config = {
        "server": DEFAULT_SERVER,
        "api_key": registration["api_key"],
        "workdir": str(target_dir),
        "work_file": "worktodo.txt",
        "work_type": "ANY",
        "campaign_mode": "default",
        "cache": 4,
        "interval": DEFAULT_INTERVAL,
        "device_id": str(uuid.uuid4()),
        "client_name": "GMRelay",
        "client_version": VERSION,
    }
    atomic_json(config_path, config, private=True)
    me = api_call(config, "/api/me")
    log(f"Profile ready: {me['contributor']['public_name']}")
    log(f"Configuration saved to {config_path}")
    return config


class SingleInstance:
    def __init__(self, path: Path) -> None:
        self.path = path
        self.fd: Optional[int] = None

    def __enter__(self) -> "SingleInstance":
        self.path.parent.mkdir(parents=True, exist_ok=True)
        try:
            self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
        except FileExistsError:
            age = time.time() - self.path.stat().st_mtime
            if age > 6 * 3600:
                self.path.unlink(missing_ok=True)
                self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
            else:
                raise RuntimeError(f"Another instance appears to be active: {self.path}")
        os.write(self.fd, f"{os.getpid()}\n".encode("ascii"))
        return self

    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
        if self.fd is not None:
            os.close(self.fd)
        self.path.unlink(missing_ok=True)


def setup(config_path: Path) -> None:
    current = load_json(config_path, {})
    print("GMRelay configuration")
    server_default = current.get("server", DEFAULT_SERVER)
    server = input(f"GMNet address [{server_default}]: ").strip() or server_default
    server = normalize_server(server)
    workdir_default = current.get("workdir", str(Path.cwd()))
    workdir = Path(input(f"PrMers directory [{workdir_default}]: ").strip() or workdir_default).expanduser().resolve()
    workdir.mkdir(parents=True, exist_ok=True)
    work_type_default = current.get("work_type", "ANY")
    work_type = (input(f"Preferred work type {WORK_TYPES} [{work_type_default}]: ").strip() or work_type_default).upper()
    if work_type not in WORK_TYPES:
        raise ValueError("Invalid work type")
    cache_default = int(current.get("cache", 4))
    cache = int(input(f"Active assignments to keep [{cache_default}]: ").strip() or cache_default)
    if cache < 1 or cache > 4:
        raise ValueError("Cache must be between 1 and 4")
    key = getpass.getpass("API key gm_live_: ").strip() or current.get("api_key", "")
    if not key.startswith("gm_live_") or len(key) < 48:
        raise ValueError("Invalid API key")
    config = {
        "server": server,
        "api_key": key,
        "workdir": str(workdir),
        "work_file": current.get("work_file", "worktodo.txt"),
        "work_type": work_type,
        "cache": cache,
        "interval": int(current.get("interval", DEFAULT_INTERVAL)),
        "device_id": current.get("device_id", str(uuid.uuid4())),
        "client_name": "GMRelay",
        "client_version": VERSION,
    }
    for optional_key in CAMPAIGN_CONFIG_KEYS:
        if optional_key in current:
            config[optional_key] = current[optional_key]
    atomic_json(config_path, config, private=True)
    api_call(config, "/api/me")
    print(f"Configuration saved to {config_path}")
    if os.name != "nt":
        print("File permissions: 600")


def state_path(config: Dict[str, Any]) -> Path:
    return Path(config["workdir"]) / ".gmrelay-state.json"


def load_state(config: Dict[str, Any]) -> Dict[str, Any]:
    state = load_json(state_path(config), {"assignments": {}, "sent": []})
    state.setdefault("assignments", {})
    state.setdefault("sent", [])
    return state


def save_state(config: Dict[str, Any], state: Dict[str, Any]) -> None:
    state["sent"] = list(dict.fromkeys(state.get("sent", [])))[-20000:]
    atomic_json(state_path(config), state, private=True)


def append_worktodo(config: Dict[str, Any], line: str) -> None:
    path = Path(config["workdir"]) / config.get("work_file", "worktodo.txt")
    existing = []
    if path.exists():
        existing = path.read_text(encoding="utf-8", errors="strict").splitlines()
    if line in (item.strip() for item in existing):
        return
    content = "\n".join(existing + [line]) + "\n"
    fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(name, path)
    finally:
        try:
            os.unlink(name)
        except FileNotFoundError:
            pass
    log(f"Added to {path.name}: {line}")


def remove_worktodo_line(config: Dict[str, Any], line: str) -> None:
    path = Path(config["workdir"]) / config.get("work_file", "worktodo.txt")
    if not path.exists():
        return
    lines = path.read_text(encoding="utf-8", errors="strict").splitlines()
    filtered = [item for item in lines if item.strip() != line.strip()]
    if len(filtered) == len(lines):
        return
    content = "\n".join(filtered)
    if content:
        content += "\n"
    fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(name, path)
    finally:
        try:
            os.unlink(name)
        except FileNotFoundError:
            pass
    log(f"Removed completed work from {path.name}: {line}")


def sync_assignments(
    config: Dict[str, Any],
    state: Dict[str, Any],
    append_active: bool = True,
) -> List[Dict[str, Any]]:
    response = api_call(config, "/api/work/mine")
    assignments = response.get("assignments", [])
    state["assignments"] = {item["assignment_id"]: item for item in assignments}
    if append_active:
        for item in assignments:
            if item.get("status") == "active" and not assignment_has_local_result(config, item):
                append_worktodo(config, item["worktodo_line"])
    return assignments


def _integer_option(source: Dict[str, Any], key: str, default: int, minimum: int, maximum: int) -> int:
    value = source.get(key, default)
    try:
        integer = int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{key} must be an integer") from exc
    if integer < minimum or integer > maximum:
        raise ValueError(f"{key} must be between {minimum} and {maximum}")
    return integer


def campaign_request_payload(
    profile: str,
    source: Dict[str, Any],
    configured_work_type: str = "ANY",
) -> Dict[str, Any]:
    profile = str(profile or "default").lower()
    if profile not in CAMPAIGN_PROFILES:
        raise ValueError("campaign_mode must be default, tf, pm1-stage1, or mixed")

    if profile == "default":
        payload = {"work_type": configured_work_type}
        if configured_work_type in ("GMPROTH", "GMPRP", "GMPMINUS1", "GMECM", "GMCHAIN"):
            target_family = str(source.get("target_family", "BOTH")).upper()
            if target_family not in ("GM", "GQ", "BOTH"):
                raise ValueError("target_family must be GM, GQ, or BOTH")
            payload["target_family"] = target_family
        return payload

    if profile == "tf":
        from_bits = _integer_option(source, "tf_from_bits", 40, 8, 63)
        to_bits = _integer_option(source, "tf_to_bits", 52, 9, 64)
        if to_bits <= from_bits:
            raise ValueError("tf_to_bits must be greater than tf_from_bits")
        target_family = str(source.get("target_family", "BOTH")).upper()
        if target_family not in ("GM", "GQ", "BOTH"):
            raise ValueError("target_family must be GM, GQ, or BOTH")
        return {
            "work_type": "GMTF",
            "generate_if_missing": True,
            "request_profile": "tf",
            "tf_from_bits": from_bits,
            "tf_to_bits": to_bits,
            "target_family": target_family,
            "tf_chunk_candidates": _integer_option(source, "tf_chunk_candidates", 4_194_304, 1024, 268_435_456),
            "tf_sieve_prime": _integer_option(source, "tf_sieve_prime", 65_536, 97, 2_000_000),
        }

    target_family = str(source.get("target_family", "BOTH")).upper()
    if target_family not in ("GM", "GQ", "BOTH"):
        raise ValueError("target_family must be GM, GQ, or BOTH")

    sieve_limit = _integer_option(
        source, "sieve_limit", 1_000_000_000_000, 0, 9_999_999_999_999_999_999
    )
    chunk_bits = _integer_option(source, "chunk_bits", 262_144, 1, 16_777_216)

    if profile == "pm1-stage1":
        b1 = _integer_option(source, "pm1_b1", 250_000, 2, 4_294_967_295)
        requested_b2 = _integer_option(source, "pm1_b2", b1, 2, 4_294_967_295)
        if requested_b2 != b1:
            raise ValueError("pm1-stage1 requires pm1_b2 to equal pm1_b1")
        base = _integer_option(source, "base", 3, 2, 1_000_000_000_000)
        return {
            "work_type": "GMPMINUS1",
            "generate_if_missing": True,
            "request_profile": "pm1-stage1",
            "pm1_b1": b1,
            "pm1_b2": b1,
            "base": base,
            "sieve_limit": sieve_limit,
            "chunk_bits": chunk_bits,
            "target_family": target_family,
        }

    pm1_b1 = _integer_option(source, "pm1_b1", 100_000, 2, 4_294_967_295)
    pm1_b2 = _integer_option(source, "pm1_b2", 2_000_000, 3, 4_294_967_295)
    ecm_b1 = _integer_option(source, "ecm_b1", 5_000, 2, 4_294_967_295)
    ecm_b2 = _integer_option(source, "ecm_b2", 250_000, 3, 4_294_967_295)
    curves = _integer_option(source, "curves", 5, 1, 10_000_000)
    if pm1_b2 <= pm1_b1:
        raise ValueError("mixed campaign requires pm1_b2 greater than pm1_b1")
    if ecm_b2 <= ecm_b1:
        raise ValueError("mixed campaign requires ecm_b2 greater than ecm_b1")
    return {
        "work_type": "GMCHAIN",
        "generate_if_missing": True,
        "request_profile": "mixed",
        "pm1_b1": pm1_b1,
        "pm1_b2": pm1_b2,
        "ecm_b1": ecm_b1,
        "ecm_b2": ecm_b2,
        "curves": curves,
        "sieve_limit": sieve_limit,
        "chunk_bits": chunk_bits,
        "target_family": target_family,
    }


def campaign_options_from_args(args: argparse.Namespace) -> Dict[str, Any]:
    output: Dict[str, Any] = {}
    for argument, key in (
        ("pm1_b1", "pm1_b1"),
        ("pm1_b2", "pm1_b2"),
        ("base", "base"),
        ("ecm_b1", "ecm_b1"),
        ("ecm_b2", "ecm_b2"),
        ("curves", "curves"),
        ("sieve_limit", "sieve_limit"),
        ("chunk_bits", "chunk_bits"),
        ("tf_from_bits", "tf_from_bits"),
        ("tf_to_bits", "tf_to_bits"),
        ("target_family", "target_family"),
        ("tf_chunk_candidates", "tf_chunk_candidates"),
        ("tf_sieve_prime", "tf_sieve_prime"),
    ):
        value = getattr(args, argument, None)
        if value is not None:
            output[key] = value
    return output


def apply_campaign_overrides(config: Dict[str, Any], args: argparse.Namespace) -> Dict[str, Any]:
    updated = dict(config)
    if args.campaign is not None:
        updated["campaign_mode"] = args.campaign
    if args.exponent is not None:
        updated["min_exponent"] = args.exponent
        updated["max_exponent"] = args.exponent
    elif args.exponent_range is not None:
        updated["min_exponent"], updated["max_exponent"] = args.exponent_range
    updated.update(campaign_options_from_args(args))
    # Validate and normalize the custom profile before entering the loop.
    campaign_request_payload(
        updated.get("campaign_mode", "default"),
        updated,
        str(updated.get("work_type", "ANY")),
    )
    return updated


def request_work(config: Dict[str, Any], state: Dict[str, Any]) -> None:
    active = [a for a in state["assignments"].values() if a.get("status") == "active"]
    target = min(4, max(1, int(config.get("cache", 1))))
    while len(active) < target:
        profile = str(config.get("campaign_mode", "default"))
        payload = campaign_request_payload(
            profile,
            config,
            str(config.get("work_type", "ANY")),
        )
        payload.update({
            "client_name": str(config.get("client_name", "GMRelay")),
            "client_version": VERSION,
            "device_id": config["device_id"],
        })
        if config.get("min_exponent") is not None:
            payload["min_exponent"] = int(config["min_exponent"])
        if config.get("max_exponent") is not None:
            payload["max_exponent"] = int(config["max_exponent"])
        try:
            response = api_call(config, "/api/work/request", "POST", payload)
        except RuntimeError as exc:
            message = str(exc)
            if (
                "No work" in message
                or "No compatible work is available" in message
            ):
                log("No compatible work is currently available")
                return
            raise
        item = response["assignment"]
        state["assignments"][item["assignment_id"]] = {**item, "status": "active"}
        active.append(item)
        append_worktodo(config, item["worktodo_line"])
        log(
            f"Work assigned: {item['assignment_id']} "
            f"campaign={item.get('campaign') or profile} "
            f"check {item['check_number']}/{item['required_runs']}"
        )


def request_specific_work(
    config: Dict[str, Any],
    request_type: str,
    exponent: Optional[int] = None,
    exponent_range: Optional[List[int]] = None,
    count: int = 1,
    campaign_options: Optional[Dict[str, Any]] = None,
) -> None:
    request_key = request_type.lower()
    canonical = REQUEST_TYPE_ALIASES.get(request_key)
    profile = REQUEST_TYPE_PROFILES.get(request_key, "default")
    if canonical is None:
        raise ValueError(
            "Unknown request type. Use tf, p1, p1s1, ecm, mixed, prp, exact, or proth."
        )
    if exponent is not None and exponent_range is not None:
        raise ValueError("Use --exponent or --range, not both.")
    if exponent is not None and not 3 <= exponent <= 2_147_483_647:
        raise ValueError("--exponent must be between 3 and 2147483647")
    if exponent_range is not None:
        minimum, maximum = exponent_range
        if minimum < 3 or maximum > 2_147_483_647 or minimum > maximum:
            raise ValueError("Invalid exponent range")
    if count < 1 or count > 4:
        raise ValueError("--count must be between 1 and 4")
    if exponent is not None and count != 1:
        raise ValueError("An exact exponent request accepts only --count 1")

    options = dict(campaign_options or {})
    workdir = Path(config["workdir"])
    workdir.mkdir(parents=True, exist_ok=True)
    with SingleInstance(workdir / ".gmrelay.lock"):
        state = load_state(config)
        sync_assignments(config, state, append_active=False)
        submit_results(config, state)
        checkin(config, state)
        sync_assignments(config, state, append_active=True)

        assigned = 0
        for _ in range(count):
            payload = campaign_request_payload(profile, options, canonical)
            if profile == "default":
                payload["generate_if_missing"] = True
            payload.update({
                "client_name": "GMRelay",
                "client_version": VERSION,
                "device_id": config["device_id"],
            })
            if exponent is not None:
                payload["exact_exponent"] = exponent
            elif exponent_range is not None:
                payload["min_exponent"], payload["max_exponent"] = exponent_range
            try:
                response = api_call(config, "/api/work/request", "POST", payload)
            except RuntimeError as exc:
                if "No compatible work is available" in str(exc):
                    log("No additional compatible work is available for this request")
                    break
                raise
            item = response["assignment"]
            state["assignments"][item["assignment_id"]] = {**item, "status": "active"}
            append_worktodo(config, item["worktodo_line"])
            assigned += 1
            log(
                f"Requested {request_type}: {item['assignment_id']} "
                f"p={item['exponent']} campaign={item.get('campaign') or profile} "
                f"check {item['check_number']}/{item['required_runs']}"
            )
        save_state(config, state)
        if assigned == 0:
            raise RuntimeError("No work was assigned for the requested type and exponent range")


def parse_worktodo_signature(line: str) -> Dict[str, Any]:
    try:
        work_type, payload = line.strip().split("=", 1)
        parts = [part.strip() for part in payload.split(",")]
        exponent = int(parts[0])
    except (ValueError, IndexError):
        return {}
    work_type = work_type.upper()
    signature: Dict[str, Any] = {"work_type": work_type, "exponent": exponent}
    families = {"GM", "GQ", "BOTH"}

    if work_type == "GMTF" and len(parts) >= 3:
        try:
            signature.update({
                "tf_from_bits": int(parts[1]),
                "tf_to_bits": int(parts[2]),
                "target_family": (parts[3] if len(parts) >= 4 else "BOTH").upper(),
                "tf_chunk_candidates": int(parts[4]) if len(parts) >= 5 else 4_194_304,
                "tf_sieve_prime": int(parts[5]) if len(parts) >= 6 else 65_536,
            })
        except ValueError:
            return {}
        return signature

    target_family = "GM"
    if len(parts) > 1 and parts[-1].upper() in families:
        target_family = parts[-1].upper()
        parts = parts[:-1]
    signature["target_family"] = target_family

    try:
        if work_type in ("GMPROTH", "GMPRP"):
            if len(parts) >= 2:
                signature["sieve_limit"] = parts[1]
        elif work_type == "GMPMINUS1" and len(parts) >= 3:
            signature.update({"B1": parts[1], "B2": parts[2]})
            if len(parts) >= 4:
                signature["base"] = parts[3]
        elif work_type == "GMECM" and len(parts) >= 4:
            signature.update({
                "B1": parts[1], "B2": parts[2], "curves": int(parts[3]),
            })
            if len(parts) >= 5:
                signature["sigma"] = parts[4]
        elif work_type == "GMCHAIN" and len(parts) >= 3:
            signature.update({"pm1_B1": parts[1], "pm1_B2": parts[2]})
            if len(parts) >= 6:
                signature.update({
                    "ecm_B1": parts[3],
                    "ecm_B2": parts[4],
                    "curves": int(parts[5]),
                })
            if len(parts) >= 9:
                signature["finish"] = parts[8].lower()
    except (ValueError, IndexError):
        return {}
    return signature


def optional_text(value: Any) -> Optional[str]:
    if value is None:
        return None
    return str(value)


def assignment_matches_result(assignment: Dict[str, Any], result: Dict[str, Any]) -> bool:
    try:
        exponent = int(result.get("exponent"))
    except (TypeError, ValueError):
        return False
    mode = str(result.get("mode", ""))
    if int(assignment.get("exponent", -1)) != exponent:
        return False
    if not compatible(str(assignment.get("work_type")), mode):
        return False

    signature = parse_worktodo_signature(str(assignment.get("worktodo_line", "")))
    if not signature:
        return True
    work_type = signature.get("work_type")
    requested_family = str(signature.get("target_family", "GM")).upper()
    result_family = str(result.get("target_family", "GM")).upper()
    if work_type != "GMTF":
        if requested_family == "BOTH":
            if result_family not in ("GM", "GQ"):
                return False
        elif requested_family != result_family:
            return False

    if work_type in ("GMPROTH", "GMPRP"):
        return True

    if work_type == "GMTF":
        try:
            return (
                int(signature.get("tf_from_bits")) == int(result.get("tf_from_bits"))
                and int(signature.get("tf_to_bits")) == int(result.get("tf_to_bits"))
                and str(signature.get("target_family", "BOTH")).upper()
                    == str(result.get("target_family", "BOTH")).upper()
                and int(signature.get("tf_chunk_candidates"))
                    == int(result.get("tf_chunk_candidates", signature.get("tf_chunk_candidates")))
                and int(signature.get("tf_sieve_prime"))
                    == int(result.get("tf_sieve_prime", signature.get("tf_sieve_prime")))
            )
        except (TypeError, ValueError):
            return False

    result_b1 = optional_text(result.get("B1"))
    result_b2 = optional_text(result.get("B2"))

    if work_type == "GMPMINUS1":
        if signature.get("B1") != result_b1:
            return False
        return result_b2 is None or signature.get("B2") == result_b2

    if work_type == "GMECM":
        if signature.get("B1") != result_b1:
            return False
        if result_b2 is not None and signature.get("B2") != result_b2:
            return False
        try:
            if int(signature.get("curves")) != int(result.get("curves")):
                return False
        except (TypeError, ValueError):
            return False
        requested_sigma = optional_text(signature.get("sigma"))
        actual_sigma = optional_text(result.get("sigma"))
        if requested_sigma not in (None, "0") and actual_sigma is not None and requested_sigma != actual_sigma:
            return False
        return True

    if work_type == "GMCHAIN" and mode == "gm-pm1":
        if signature.get("pm1_B1") != result_b1:
            return False
        return result_b2 is None or signature.get("pm1_B2") == result_b2

    if work_type == "GMCHAIN" and mode == "gm-ecm":
        if signature.get("ecm_B1") != result_b1:
            return False
        if result_b2 is not None and signature.get("ecm_B2") != result_b2:
            return False
        try:
            return int(signature.get("curves")) == int(result.get("curves"))
        except (TypeError, ValueError):
            return False

    return False


def result_completes_assignment_family(signature: Dict[str, Any], result: Dict[str, Any]) -> bool:
    work_type = str(signature.get("work_type", ""))
    mode = str(result.get("mode", ""))
    outcome = str(result.get("outcome", ""))
    if work_type == "GMTF" or outcome == "factor":
        return True
    if work_type in ("GMPROTH", "GMPRP", "GMECM"):
        return True
    if work_type == "GMPMINUS1":
        try:
            stage = int(result.get("stage"))
            return stage == (1 if int(signature["B2"]) <= int(signature["B1"]) else 2)
        except (KeyError, TypeError, ValueError):
            return False
    if work_type != "GMCHAIN":
        return False
    finish = str(signature.get("finish", "proth")).lower()
    if mode in ("gm-proth", "gm-prp"):
        return True
    if mode == "gm-ecm":
        return finish == "factor"
    if mode != "gm-pm1":
        return False
    try:
        stage = int(result.get("stage"))
        pm1_final = 1 if int(signature["pm1_B2"]) <= int(signature["pm1_B1"]) else 2
    except (KeyError, TypeError, ValueError):
        return False
    return stage == pm1_final and signature.get("ecm_B1") is None and finish == "factor"


def assignment_has_local_result(config: Dict[str, Any], assignment: Dict[str, Any]) -> bool:
    workdir = Path(config["workdir"])
    signature = parse_worktodo_signature(str(assignment.get("worktodo_line", "")))
    requested_family = str(signature.get("target_family", "GM")).upper()
    completed_families = set()
    for path in result_files(workdir):
        try:
            if path.stat().st_size > RESULT_LIMIT:
                continue
            result = json.loads(path.read_text(encoding="utf-8"))
            if not assignment_matches_result(assignment, result):
                continue
            if not result_completes_assignment_family(signature, result):
                continue
            if signature.get("work_type") == "GMTF":
                return True
            family = str(result.get("target_family", "GM")).upper()
            if family in ("GM", "GQ"):
                completed_families.add(family)
            if requested_family != "BOTH" and family == requested_family:
                return True
        except (OSError, ValueError, json.JSONDecodeError):
            continue
    return requested_family == "BOTH" and completed_families == {"GM", "GQ"}


def compatible(work_type: str, mode: str) -> bool:
    return {
        "GMTF": mode == "gm-tf",
        "GMPROTH": mode == "gm-proth",
        "GMPRP": mode == "gm-prp",
        "GMPMINUS1": mode == "gm-pm1",
        "GMECM": mode == "gm-ecm",
        "GMCHAIN": mode in ("gm-pm1", "gm-ecm", "gm-proth", "gm-prp"),
    }.get(work_type, False)


def result_files(workdir: Path) -> Iterable[Path]:
    for path in sorted(workdir.rglob("*.json")):
        name = path.name.lower()
        if name.startswith(("gm_", "gq_")) and name.endswith("_result.json"):
            yield path


def submit_results(config: Dict[str, Any], state: Dict[str, Any]) -> None:
    sent = set(state.get("sent", []))
    workdir = Path(config["workdir"])
    for path in result_files(workdir):
        try:
            if path.stat().st_size > RESULT_LIMIT:
                log(f"Skipped oversized file: {path}")
                continue
            raw = path.read_bytes()
            digest = hashlib.sha256(raw).hexdigest()
            if digest in sent:
                continue
            result = json.loads(raw.decode("utf-8"))
            exponent = int(result.get("exponent"))
            mode = str(result.get("mode", ""))
            same_mode = [a for a in state["assignments"].values()
                         if a.get("status") == "active"
                         and int(a.get("exponent", -1)) == exponent
                         and compatible(str(a.get("work_type")), mode)]
            matches = [a for a in same_mode if assignment_matches_result(a, result)]
            if len(matches) > 1:
                log(f"Submission deferred because multiple assignments match {path}")
                continue
            if same_mode and not matches:
                log(f"Submission deferred because no active assignment has matching bounds for {path}")
                continue
            envelope: Dict[str, Any] = {"result": result}
            if matches:
                envelope["assignment_id"] = matches[0]["assignment_id"]
            response = api_call(config, "/api/submit", "POST", envelope)
            sent.add(digest)
            state["sent"] = list(sent)
            assignment = response.get("assignment")
            if assignment and assignment.get("completed"):
                aid = assignment["assignment_id"]
                if aid in state["assignments"]:
                    completed_item = state["assignments"][aid]
                    completed_item["status"] = "completed"
                    remove_worktodo_line(config, str(completed_item.get("worktodo_line", "")))
                log(f"Result submitted, assignment completed: {aid}")
                if assignment.get("required_runs") == 2:
                    log(f"Double-check: {assignment.get('double_check_status')}")
            else:
                log(f"Result submitted: {path.name}")
            save_state(config, state)
        except (OSError, ValueError, json.JSONDecodeError, RuntimeError) as exc:
            log(f"Error for {path}: {exc}")


def checkin(config: Dict[str, Any], state: Dict[str, Any]) -> None:
    for item in list(state["assignments"].values()):
        if item.get("status") != "active":
            continue
        try:
            response = api_call(config, "/api/work/checkin", "POST", {"assignment_id": item["assignment_id"]})
            item["lease_expires_at"] = response["lease_expires_at"]
        except RuntimeError as exc:
            log(f"Check-in failed for {item['assignment_id']}: {exc}")


def run_cycle(config: Dict[str, Any]) -> None:
    workdir = Path(config["workdir"])
    workdir.mkdir(parents=True, exist_ok=True)
    with SingleInstance(workdir / ".gmrelay.lock"):
        state = load_state(config)
        sync_assignments(config, state, append_active=False)
        submit_results(config, state)
        checkin(config, state)
        sync_assignments(config, state, append_active=True)
        request_work(config, state)
        save_state(config, state)


def print_status(config: Dict[str, Any]) -> None:
    assignments = api_call(config, "/api/work/mine").get("assignments", [])
    if not assignments:
        print("No assigned work")
        return
    for item in assignments:
        print(f"{item['status']:9} {item['assignment_id']} {item['worktodo_line']} "
              f"check {item['check_number']}/{item['required_runs']} expires {item['lease_expires_at']}")


def return_assignment(config: Dict[str, Any], assignment_id: str) -> None:
    assignments = api_call(config, "/api/work/mine").get("assignments", [])
    item = next((row for row in assignments if row.get("assignment_id") == assignment_id), None)
    api_call(config, "/api/work/return", "POST", {"assignment_id": assignment_id})
    if item:
        remove_worktodo_line(config, str(item.get("worktodo_line", "")))
    print(f"Work returned: {assignment_id}")


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="GMNet client for automatic PrMers Gaussian Mersenne work",
        epilog=(
            "Examples: --request p1s1 --range 10000019 15317226 --b1 250000 --count 4 | "
            "--campaign pm1-stage1 --range 10000019 15317226 --b1 250000 --loop | "
            "--campaign mixed --range 15317251 16000000 --b1 100000 --b2 2000000 "
            "--ecm-b1 5000 --ecm-b2 250000 --curves 5 --loop"
        ),
    )
    p.add_argument("--version", action="version", version=f"GMRelay {VERSION}")
    p.add_argument("--config", type=Path, default=default_config_path())
    p.add_argument("--setup", action="store_true", help="advanced interactive configuration")
    p.add_argument("--join", metavar="NICKNAME", help="create a GMNet profile using only a nickname")
    p.add_argument("--start", nargs="?", const="", metavar="NICKNAME", help="join when needed, then run continuously")
    p.add_argument(
        "--request", choices=tuple(REQUEST_TYPE_ALIASES),
        help="request GPU TF, P-1, Stage-1-only P-1, ECM, mixed P-1+ECM, PRP, or Proth work",
    )
    p.add_argument(
        "--campaign", choices=CAMPAIGN_PROFILES,
        help="continuous request profile: default, tf, pm1-stage1, or mixed",
    )
    scope = p.add_mutually_exclusive_group()
    scope.add_argument("--exponent", type=int, metavar="P", help="use one prime exponent")
    scope.add_argument(
        "--range", nargs=2, type=int, metavar=("MIN", "MAX"),
        dest="exponent_range", help="use a prime-exponent range",
    )
    p.add_argument("--count", type=int, default=1, help="one-shot assignments to request, from 1 to 4")
    p.add_argument("--b1", dest="pm1_b1", type=int, help="P-1 B1 bound")
    p.add_argument("--b2", dest="pm1_b2", type=int, help="P-1 B2 bound; equal to B1 disables Stage 2")
    p.add_argument("--base", type=int, help="P-1 base, default 3")
    p.add_argument("--ecm-b1", type=int, help="ECM B1 bound for mixed campaigns")
    p.add_argument("--ecm-b2", type=int, help="ECM B2 bound for mixed campaigns")
    p.add_argument("--curves", type=int, help="ECM curve count for mixed campaigns")
    p.add_argument("--sieve-limit", type=int, help="admissible q=4kp+1 sieve limit")
    p.add_argument("--chunk-bits", type=int, help="P-1/ECM Stage 2 product chunk size")
    p.add_argument("--tf-from-bits", type=int, help="GPU TF lower q bit, inclusive")
    p.add_argument("--tf-to-bits", type=int, help="GPU TF upper q bit, exclusive")
    p.add_argument("--target-family", choices=("GM", "GQ", "BOTH"), help="Gaussian family for TF, P-1, ECM, PRP and Proth")
    p.add_argument("--tf-chunk-candidates", type=int, help="raw k span per GPU TF checkpoint")
    p.add_argument("--tf-sieve-prime", type=int, help="largest host sieve prime before GPU TF")
    mode = p.add_mutually_exclusive_group()
    mode.add_argument("--once", action="store_true", help="synchronize once and exit")
    mode.add_argument("--loop", action="store_true", help="synchronize continuously")
    mode.add_argument("--status", action="store_true", help="show assigned work")
    mode.add_argument("--return-work", metavar="ASSIGNMENT_ID", help="return an assignment")
    return p


def run_loop(config: Dict[str, Any]) -> None:
    interval = max(60, int(config.get("interval", DEFAULT_INTERVAL)))
    while True:
        try:
            run_cycle(config)
        except Exception as exc:
            log(f"Cycle error: {exc}")
        time.sleep(interval)


def main() -> int:
    args = parser().parse_args()
    config_path = args.config.expanduser()
    custom_cli_used = any(
        value is not None for value in (
            args.pm1_b1, args.pm1_b2, args.base, args.ecm_b1,
            args.ecm_b2, args.curves, args.sieve_limit, args.chunk_bits,
            args.tf_from_bits, args.tf_to_bits, args.target_family,
            args.tf_chunk_candidates, args.tf_sieve_prime,
        )
    )
    try:
        if args.setup:
            setup(config_path)
            return 0
        if args.join:
            quick_join(config_path, args.join)
            return 0
        config = load_json(config_path, None)
        if args.start is not None:
            if not config:
                if not args.start.strip():
                    raise ValueError("First start requires a nickname: python3 gmrelay.py --start YOUR_NICKNAME")
                config = quick_join(config_path, args.start)
            config["server"] = normalize_server(config.get("server", DEFAULT_SERVER))
            if args.campaign or args.exponent is not None or args.exponent_range is not None or custom_cli_used:
                config = apply_campaign_overrides(config, args)
            run_loop(config)
            return 0
        if not config:
            print("Quick start: python3 gmrelay.py --start YOUR_NICKNAME", file=sys.stderr)
            print("Advanced setup: python3 gmrelay.py --setup", file=sys.stderr)
            return 2
        config["server"] = normalize_server(config.get("server", DEFAULT_SERVER))
        if args.request:
            request_specific_work(
                config,
                args.request,
                exponent=args.exponent,
                exponent_range=args.exponent_range,
                count=args.count,
                campaign_options=campaign_options_from_args(args),
            )
            return 0
        if args.campaign is not None:
            if args.count != 1:
                raise ValueError("--count is only valid with --request")
            config = apply_campaign_overrides(config, args)
            if args.status:
                print_status(config)
                return 0
            if args.return_work:
                return_assignment(config, args.return_work)
                return 0
            if args.loop:
                run_loop(config)
                return 0
            run_cycle(config)
            return 0
        if args.exponent is not None or args.exponent_range is not None or custom_cli_used or args.count != 1:
            raise ValueError(
                "--exponent, --range, bounds, and --count require --request or --campaign"
            )
        if args.status:
            print_status(config)
            return 0
        if args.return_work:
            return_assignment(config, args.return_work)
            return 0
        if args.loop:
            run_loop(config)
            return 0
        run_cycle(config)
        return 0
    except KeyboardInterrupt:
        return 130
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1


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