#!/usr/bin/env python3
"""
Miner API Layer - Whatsminer M60 data collection.
Standalone module with no GUI dependency.

Antminer (stock) and Braiins collectors plus the firmware-detection probe were
removed 2026-06-02: the fleet is 100% Whatsminer M60, so callers go straight to
collect_whatsminer() instead of paying ~4s of failing Braiins/Antminer probes
per scan. See git history for the retired collectors.
"""

import socket
import json
import logging

logger = logging.getLogger(__name__)

TIMEOUT_TCP = 8
CGMINER_PORT = 4028


# ---------------------------------------------------------------------------
# TCP helpers
# ---------------------------------------------------------------------------

def cgminer_command(ip, command):
    """Send a CGMiner-style command over TCP port 4028 (Whatsminer devdetails)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(TIMEOUT_TCP)
    try:
        sock.connect((ip, CGMINER_PORT))
        sock.sendall(json.dumps({"command": command}).encode() + b"\x00")
        buf = b""
        while len(buf) < 131072:
            chunk = sock.recv(8192)
            if not chunk:
                break
            buf += chunk
            if buf.endswith(b"\x00"):
                break
        text = buf.decode("utf-8", errors="ignore").replace("\x00", "")
        if not text:
            return None
        start = text.find("{")
        end = text.rfind("}")
        if start >= 0 and end > start:
            return json.loads(text[start:end + 1])
        return None
    finally:
        sock.close()


def whatsminer_command(ip, cmd):
    """Send a Whatsminer-style command over TCP port 4028 (read-only, unencrypted)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(TIMEOUT_TCP)
    try:
        sock.connect((ip, CGMINER_PORT))
        sock.sendall(json.dumps({"cmd": cmd}).encode())
        buf = b""
        while len(buf) < 65536:
            chunk = sock.recv(4096)
            if not chunk:
                break
            buf += chunk
            if buf.count(b"{") == buf.count(b"}") and buf.count(b"{") > 0:
                break
        text = buf.decode("utf-8", errors="ignore").strip("\x00")
        if not text:
            return None
        return json.loads(text)
    finally:
        sock.close()


# ---------------------------------------------------------------------------
# Whatsminer collection
# ---------------------------------------------------------------------------

def _to_mhs(value):
    """Normalize a Whatsminer hashrate reading to MH/s.

    2024 firmware reports MH/s (~57,000,000/board); 2025+ firmware reports
    TH/s (~57/board). Values below 1e6 are treated as TH/s and scaled up.
    """
    try:
        v = float(value)
    except (TypeError, ValueError):
        return None
    if v <= 0:
        return 0.0
    return v * 1_000_000 if v < 1_000_000 else v


def _reconcile_whatsminer_firmware(result):
    """Fill missing mining fields and normalize hashrate units across firmwares.

    2025+ Whatsminer firmware strips the cgminer `summary` response (no
    "MHS 5s"/"Power"/"Power Limit") and reports hashrate in TH/s instead of
    MH/s. `devs`/`get_psu`/`status` still work on every firmware, so the data
    is recovered from there. All hashrate fields are normalized to MH/s, which
    is the unit the frontend MinerView expects.
    """
    boards = result.get("hashboards") or []

    # Normalize per-board hashrate to MH/s. 2025+ devs lacks "MHS 5s" but has
    # "HS RT" — use it as the board's realtime figure when 5s is absent.
    for b in boards:
        for k in ("hashrate_mhs_5s", "hashrate_mhs_avg", "hashrate_mhs_rt"):
            if b.get(k) is not None:
                b[k] = _to_mhs(b[k])
        if b.get("hashrate_mhs_5s") is None and b.get("hashrate_mhs_rt") is not None:
            b["hashrate_mhs_5s"] = b["hashrate_mhs_rt"]

    # Realtime/5s aggregate.
    if result.get("hashrate_mhs_5s") is None and boards:
        rt = sum(b["hashrate_mhs_5s"] for b in boards if b.get("hashrate_mhs_5s"))
        if rt:
            result["hashrate_mhs_5s"] = rt

    # Average hashrate.
    if result.get("hashrate_mhs_avg") is not None:
        result["hashrate_mhs_avg"] = _to_mhs(result["hashrate_mhs_avg"])
    elif boards:
        av = sum(b["hashrate_mhs_avg"] for b in boards if b.get("hashrate_mhs_avg"))
        if av:
            result["hashrate_mhs_avg"] = av

    # Power draw: get_psu Msg.pin (collected as psu_power_in).
    if result.get("power_watts") is None and result.get("psu_power_in") is not None:
        try:
            result["power_watts"] = int(float(result["psu_power_in"]))
        except (TypeError, ValueError):
            pass

    # Shares: 2025+ summary omits Accepted/Rejected totals — sum across pools.
    if result.get("accepted") is None and result.get("pools"):
        result["accepted"] = sum(p.get("accepted") or 0 for p in result["pools"])
        result["rejected"] = sum(p.get("rejected") or 0 for p in result["pools"])

    # Board temp: 2025+ summary omits the single "Temperature" — use the
    # hottest per-board temp from devs.
    if result.get("board_temp") is None and boards:
        board_temps = [b.get("temp") for b in boards if b.get("temp")]
        if board_temps:
            result["board_temp"] = max(board_temps)

    # hashrate_mhs_rt is an intermediate only — drop it from the output.
    for b in boards:
        b.pop("hashrate_mhs_rt", None)


def collect_whatsminer(ip):
    """Collect diagnostics from a Whatsminer using unencrypted read-only commands."""
    result = {
        "firmware_type": "whatsminer",
        "ip": ip,
    }

    # --- summary ---
    # Two response shapes in the field:
    #   2024 fw (cgminer-style): {"STATUS":[{...}], "SUMMARY":[{...}]}
    #   2025+ fw (btminer api_ver 2.2.x): {"STATUS":"S", "Msg":{...}} — same
    #     fields, different envelope, and no "MHS 5s"/"Accepted"/"Rejected".
    try:
        data = whatsminer_command(ip, "summary")
        if data:
            status = data.get("STATUS")
            if isinstance(status, list) and len(status) > 0:
                status = status[0].get("STATUS")
            if status == "S":
                msg = data.get("Msg")
                if isinstance(msg, dict):
                    s = msg                           # 2025+ fw: fields under Msg
                else:
                    s = data.get("SUMMARY", [{}])[0]  # 2024 fw: cgminer array
                # 2025+ fw drops "MHS 5s"; "HS RT" is its realtime figure.
                result["hashrate_mhs_5s"] = s.get("MHS 5s") if s.get("MHS 5s") is not None else s.get("HS RT")
                result["hashrate_mhs_avg"] = s.get("MHS av")
                result["hashrate_mhs_1m"] = s.get("MHS 1m")
                result["hashrate_mhs_15m"] = s.get("MHS 15m")
                result["target_hashrate_mhs"] = s.get("Target MHS")
                result["frequency"] = s.get("freq_avg")
                result["target_frequency"] = s.get("Target Freq")
                result["power_watts"] = s.get("Power")
                result["power_limit"] = s.get("Power Limit")
                result["power_mode"] = s.get("Power Mode")
                result["efficiency"] = s.get("Power Rate")
                result["board_temp"] = s.get("Temperature")
                result["chip_temp_min"] = s.get("Chip Temp Min")
                result["chip_temp_max"] = s.get("Chip Temp Max")
                result["chip_temp_avg"] = s.get("Chip Temp Avg")
                result["env_temp"] = s.get("Env Temp")
                result["fan_in"] = s.get("Fan Speed In")
                result["fan_out"] = s.get("Fan Speed Out")
                result["accepted"] = s.get("Accepted")
                result["rejected"] = s.get("Rejected")
                result["pool_rejected_pct"] = s.get("Pool Rejected%")
                result["pool_stale_pct"] = s.get("Pool Stale%")
                result["uptime_seconds"] = s.get("Elapsed")
    except Exception as e:
        result["summary_error"] = str(e)

    # --- get_miner_info ---
    try:
        data = whatsminer_command(ip, "get_miner_info")
        if data and data.get("STATUS") == "S":
            msg = data.get("Msg", {})
            result["mac"] = msg.get("mac")
            result["serial"] = msg.get("minersn")
            result["hostname"] = msg.get("hostname")
            # NOTE: get_miner_info does NOT carry the model — `minertype` is
            # absent here (returns None). The real model is resolved below from
            # devdetails / get_version. Do not set result["model"] from this.
    except Exception as e:
        result["miner_info_error"] = str(e)

    # --- get_version ---
    try:
        data = whatsminer_command(ip, "get_version")
        if data and data.get("STATUS") == "S":
            msg = data.get("Msg", {})
            result["firmware"] = msg.get("fw_ver")
            # Newer firmware (api_ver >= 2.2.x) reports the model here as
            # `miner_type` (e.g. "M60S_VK40") and answers even while sleeping.
            # devdetails (below) overrides when available (awake, all firmware).
            if msg.get("miner_type"):
                result["model"] = msg.get("miner_type")
    except Exception as e:
        result["version_error"] = str(e)

    # --- devdetails (authoritative model: per-board "Model" e.g. "M60S_VK40") ---
    # Works on all firmware but only while AWAKE (needs the mining daemon).
    # Preferred over get_version's miner_type, which only exists on newer fw.
    try:
        data = cgminer_command(ip, "devdetails")
        if data:
            models = {b.get("Model", "") for b in data.get("DEVDETAILS", [])
                      if isinstance(b, dict)}
            models.discard("")
            if models:
                result["model"] = "|".join(sorted(models)) if len(models) > 1 else next(iter(models))
    except Exception as e:
        result["devdetails_error"] = str(e)

    # Normalize the variant family (M60 vs M60S) from whatever model we found.
    if result.get("model"):
        result["model_family"] = result["model"].split("|")[0].split("_")[0].upper()

    # --- status ---
    try:
        data = whatsminer_command(ip, "status")
        if data and data.get("STATUS") == "S":
            msg = data.get("Msg", {})
            if isinstance(msg, dict):
                result["is_sleeping"] = msg.get("mineroff", "false").lower() == "true"
                result["sleep_reason"] = msg.get("mineroff_reason")
                if not result.get("firmware"):
                    result["firmware"] = msg.get("FirmwareVersion")
                # 2025+ firmware dropped "Power Limit" from summary; the
                # configured limit lives in status as power_limit_set.
                if result.get("power_limit") is None:
                    try:
                        plim = int(msg.get("power_limit_set"))
                        result["power_limit"] = None if plim == 2147483647 else plim
                    except (TypeError, ValueError):
                        pass
    except Exception as e:
        result["status_error"] = str(e)

    # --- get_error_code ---
    try:
        data = whatsminer_command(ip, "get_error_code")
        if data and data.get("STATUS") == "S":
            msg = data.get("Msg", {})
            if isinstance(msg, dict):
                codes = msg.get("error_code", [])
                if isinstance(codes, list):
                    result["error_codes"] = codes
    except Exception as e:
        result["error_code_error"] = str(e)

    # --- devs ---
    try:
        data = whatsminer_command(ip, "devs")
        if data:
            status = data.get("STATUS")
            if isinstance(status, list) and len(status) > 0:
                status = status[0].get("STATUS")
            if status == "S":
                hashboards = []
                for dev in data.get("DEVS", []):
                    hashboards.append({
                        "index": dev.get("ASC"),
                        "slot": dev.get("Slot"),
                        "enabled": dev.get("Enabled") == "Y",
                        "status": dev.get("Status"),
                        "hashrate_mhs_5s": dev.get("MHS 5s"),
                        "hashrate_mhs_avg": dev.get("MHS av"),
                        "hashrate_mhs_rt": dev.get("HS RT"),
                        "temp": dev.get("Temperature"),
                        "chip_temp_min": dev.get("Chip Temp Min"),
                        "chip_temp_max": dev.get("Chip Temp Max"),
                        "chip_temp_avg": dev.get("Chip Temp Avg"),
                        "frequency": dev.get("Chip Frequency"),
                        "chips": dev.get("Effective Chips"),
                        "serial": dev.get("PCB SN"),
                    })
                result["hashboards"] = hashboards
    except Exception as e:
        result["devs_error"] = str(e)

    # --- get_psu ---
    try:
        data = whatsminer_command(ip, "get_psu")
        if data and data.get("STATUS") == "S":
            msg = data.get("Msg", {})
            if isinstance(msg, dict):
                result["psu_model"] = msg.get("name") or msg.get("model")
                result["psu_power_in"] = msg.get("pin")
                result["psu_voltage_in"] = msg.get("vin")
                result["psu_fan_speed"] = msg.get("fan_speed")
                result["psu_temp"] = msg.get("temp0")
    except Exception as e:
        result["psu_error"] = str(e)

    # --- pools ---
    try:
        data = whatsminer_command(ip, "pools")
        if data:
            status = data.get("STATUS")
            if isinstance(status, list) and len(status) > 0:
                status = status[0].get("STATUS")
            if status == "S":
                pools = []
                for p in data.get("POOLS", []):
                    url = p.get("URL", "")
                    if url and "127.0.0.1" not in url:
                        pools.append({
                            "url": url,
                            "user": p.get("User", ""),
                            "status": p.get("Status", ""),
                            "accepted": p.get("Accepted"),
                            "rejected": p.get("Rejected"),
                            "stale": p.get("Stale"),
                        })
                result["pools"] = pools
    except Exception as e:
        result["pools_error"] = str(e)

    _reconcile_whatsminer_firmware(result)

    return result
