"""
workflow_serial.py — Serial-based ES24N IOM network configuration workflow.
Connects via USB serial cable, logs in to the IOM console, and configures
network settings by running curl commands through the shell session.

The IOM's Redfish API is only accessible locally at https://127.0.0.1 from
within the IOM's own shell — not directly from the host over the serial cable.
All Redfish operations are therefore performed by issuing curl commands over
the serial connection and parsing the JSON responses.
"""

import glob
import json
import os
import re
import subprocess
import time
from typing import Optional

from models import IOMConfig, ShelfConfig
from serial_port import SerialPort
from ui import (
    _c, C,
    banner, rule, draw_table, draw_box,
    info, ok, warn, error,
    prompt, prompt_ip, prompt_yn, prompt_password,
)

# Strip ANSI escape sequences from serial terminal output.
# Catches CSI sequences (ESC[...X) and other two-character ESC sequences.
_ANSI_RE = re.compile(r'\x1b(?:\[[0-9;]*[A-Za-z]|[^[])')


def _at_shell_prompt(text: str) -> bool:
    """
    Return True if any line in text looks like a root shell prompt.
    Checks whether a non-empty line ends with '#' or '# ' — the pattern
    produced by prompts like 'root@hostname:~#' — without being tripped
    up by the word 'login' appearing elsewhere in the output (e.g. in
    'Last login: ...' messages shown after a session is resumed).
    """
    for line in text.splitlines():
        stripped = line.rstrip()
        if stripped.endswith("#") or stripped.endswith("# "):
            return True
    return False


def _extract_serial_from_hostname(hostname: str) -> Optional[str]:
    """
    Extract the BMC serial number from an IOM hostname.
    The serial number (which doubles as the root password) is a
    dash-separated segment matching MXE followed by alphanumerics.
    e.g. 'ves-ves-vds2249r-MXE3000048LHA03C-mgr1' → 'MXE3000048LHA03C'
    Returns None if no matching segment is found.
    """
    for segment in hostname.split("-"):
        if re.match(r"^MXE[A-Z0-9]+$", segment, re.IGNORECASE):
            return segment
    return None


def _detect_password_from_shell(ser: SerialPort) -> Optional[str]:
    """
    When already logged in, run 'hostname' on the IOM shell and extract
    the BMC serial number from the output.
    """
    ser.send_line("hostname", delay=0.3)
    out = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))
    for line in out.splitlines():
        pw = _extract_serial_from_hostname(line.strip())
        if pw:
            return pw
    return None


# ── Serial Redfish transport ───────────────────────────────────────────────────
def _login_serial_console(ser: SerialPort) -> tuple:
    """
    Perform the root login sequence on the IOM serial console.

    Auto-detects the root password from the IOM hostname — the BMC serial
    number (e.g. MXE3000048LHA03C) is embedded in both the login prompt
    hostname and the output of 'hostname', so no manual entry is needed.

    - Login prompt present: extracts serial from 'hostname login:' line.
    - Already logged in: runs 'hostname' on the shell to extract the serial.
    - Falls back to prompting the user if auto-detection fails either way.

    Returns (success: bool, password: str).
    """
    info("Logging in to IOM serial console...")

    # Send a newline to get the current state of the console
    ser.send_line("", delay=0.5)
    response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))

    # Already at a shell prompt — extract password via 'hostname' command
    if _at_shell_prompt(response):
        ok("Already at shell prompt.")
        password = _detect_password_from_shell(ser)
        if password:
            ok(f"Password auto-detected: {_c(C.BOLD, password)}")
            return True, password
        warn("Could not auto-detect password from hostname.")
        return True, prompt_password()

    low = response.lower()

    # Try to extract the password from the login prompt hostname before
    # sending credentials — format: 'ves-ves-model-SERIAL-mgr1 login:'
    password = None
    login_match = re.search(r"(\S+)\s+login:", response, re.IGNORECASE)
    if login_match:
        password = _extract_serial_from_hostname(login_match.group(1))
        if password:
            ok(f"Password auto-detected from login prompt: {_c(C.BOLD, password)}")

    # Send username if we see a login prompt (or an empty/unknown response)
    if "login" in low or not response.strip():
        ser.send_line("root", delay=0.5)
        response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))

    # Fall back to asking the user if auto-detection failed
    if not password:
        warn("Could not auto-detect password from login prompt.")
        password = prompt_password()

    # Send password
    if "password" in response.lower():
        ser.send_line(password, delay=0.5)
        response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=2.0))

    if _at_shell_prompt(response):
        ok("Logged in to IOM console.")
        return True, password

    # "Last login:" in the response confirms the password was accepted —
    # the shell prompt just hasn't arrived yet (slow shell init or motd).
    # Give it up to 10 more seconds to appear.
    if "last login" in response.lower():
        response += _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=1.5, timeout=10.0))
        if _at_shell_prompt(response):
            ok("Logged in to IOM console.")
            return True, password

    error(f"Login failed. Console response: {response.strip()[:120]}")
    return False, ""


def _serial_redfish_request(ser: SerialPort, password: str, method: str,
                             path: str, payload: Optional[dict] = None) -> tuple:
    """
    Issue a Redfish request by running curl on the IOM's serial console.
    The Redfish API is available at https://127.0.0.1 from within the IOM shell.

    Appends -w '\\nHTTP_CODE:%{http_code}' to every curl call so the HTTP
    status can be parsed independently of the response body.

    Returns (success: bool, data: dict | str).
    """
    url = f"https://127.0.0.1{path}"
    cmd = f"curl -sk -u root:{password} -X {method}"
    if payload:
        body = json.dumps(payload, separators=(",", ":"))
        cmd += f" -H 'Content-Type: application/json' -d '{body}'"
    cmd += f" -w '\\nHTTP_CODE:%{{http_code}}' '{url}'"

    ser.send_line(cmd, delay=0.3)
    raw = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=1.5, timeout=30.0))

    # ── Parse HTTP status code ─────────────────────────────────────────────────
    # Use the LAST occurrence of HTTP_CODE: so the echoed curl -w argument
    # (which also contains the literal text "HTTP_CODE:") doesn't interfere.
    http_code = 0
    if "HTTP_CODE:" in raw:
        try:
            http_code = int(raw.split("HTTP_CODE:")[-1].strip()[:3])
        except ValueError:
            pass

    # ── Extract JSON body ──────────────────────────────────────────────────────
    # The terminal echoes the curl command before executing it, and the
    # -w format string contains '%{http_code}' which includes a literal '{'.
    # When the terminal wraps long command lines, that '{' can land at a
    # line boundary, causing find("{") to return the wrong position.
    #
    # Strategy: narrow the search to the text before HTTP_CODE: (the actual
    # curl response body appears there), then find the LAST newline-prefixed
    # '{' — because the JSON response always starts on its own line, while
    # the echoed '{http_code}' is embedded mid-line in the curl command echo.
    # Use rfind (not find) — the -w argument in the echoed curl command also
    # contains the literal text "HTTP_CODE:", so find() would land there instead
    # of at the actual HTTP_CODE:200 output that curl appends at the end.
    http_code_pos = raw.rfind("HTTP_CODE:")
    search_area   = raw[:http_code_pos].rstrip() if http_code_pos >= 0 else raw

    data: dict = {}
    json_end = search_area.rfind("}") + 1
    if json_end > 0:
        json_start = -1
        for nl in ("\r\n", "\n"):
            pos = search_area.rfind(nl + "{")
            if pos >= 0:
                json_start = pos + len(nl)
                break
        if json_start < 0:
            json_start = search_area.find("{")   # fallback if no newline found

        if json_start >= 0:
            try:
                data = json.loads(search_area[json_start:json_end])
            except json.JSONDecodeError:
                pass

    # ── Determine success ──────────────────────────────────────────────────────
    if http_code >= 400:
        msg = (data.get("error", {}).get("message", "")
               if isinstance(data, dict) else "")
        snippet = search_area[json_start:json_start + 120] if json_start >= 0 else search_area[-120:]
        return False, f"HTTP {http_code}: {msg or snippet}"

    if http_code >= 200 or data:
        return True, data

    # No HTTP code and no JSON — check for curl-level error
    if "curl:" in raw.lower():
        return False, f"curl error: {raw.strip()[:120]}"

    return True, data


# ── Step 1: Detect serial device ──────────────────────────────────────────────
def detect_serial_device() -> Optional[str]:
    rule("Step 1 of 5 -- Serial Cable & Device Detection")

    print("  Connect the serial cable from the ES24N IOM port")
    print("  to the active F-Series controller USB port.")
    print()
    prompt("Press Enter when the cable is connected")

    for attempt in range(1, 4):
        info(f"Scanning for USB serial devices (attempt {attempt}/3)...")
        time.sleep(1)

        # FreeBSD: /dev/ttyU*   Linux: /dev/ttyUSB*, /dev/ttyACM*
        patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyU*"]
        ports    = sorted({p for pat in patterns for p in glob.glob(pat)})

        if ports:
            break

        if attempt < 3:
            warn("No device found yet — retrying in 2 seconds...")
            time.sleep(2)

    if not ports:
        error("No USB serial device detected after 3 attempts.")
        print()
        print("  Troubleshooting:")
        print("    - Ensure the serial cable is fully seated at both ends.")
        print("    - Try a different USB port on the controller.")
        print("    - Confirm the ES24N is powered on.")
        return None

    if len(ports) == 1:
        ok(f"Device found: {_c(C.BOLD, ports[0])}")
        _fix_permissions(ports[0])
        return ports[0]

    # Multiple devices — let the user choose
    print()
    draw_table(
        ["#", "Device"],
        [[str(i), p] for i, p in enumerate(ports, 1)],
        [4, 24],
    )
    print()

    while True:
        val = prompt(f"Select device number [1-{len(ports)}]")
        if val.isdigit() and 1 <= int(val) <= len(ports):
            selected = ports[int(val) - 1]
            ok(f"Selected: {_c(C.BOLD, selected)}")
            _fix_permissions(selected)
            return selected
        warn(f"Please enter a number between 1 and {len(ports)}.")


def _fix_permissions(device: str):
    try:
        result = subprocess.run(
            ["sudo", "chown", ":wheel", device],
            capture_output=True, timeout=5,
        )
        if result.returncode == 0:
            ok(f"Permissions updated on {device}")
            return
    except Exception:
        pass
    try:
        os.chmod(device, 0o666)
        ok(f"Permissions updated on {device}")
    except PermissionError:
        warn("Could not update device permissions automatically.")
        warn("If the connection fails, re-run this script with sudo.")


# ── Step 2: Open serial connection & wake IOM console ─────────────────────────
def open_serial_connection(device: str) -> Optional[SerialPort]:
    rule("Step 2 of 5 -- Opening Serial Connection")

    info(f"Opening {device} at 115200 baud (8N1)...")
    ser = SerialPort(device, baudrate=115200, timeout=5.0)

    try:
        ser.open()
    except OSError as e:
        error(str(e))
        return None

    ok(f"Port opened: {device}")
    info("Sending wake signal to IOM console...")

    ser.send_line("", delay=0.5)
    ser.send_line("", delay=0.5)
    response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5))

    print()
    if response.strip():
        print(f"  {_c(C.DIM, '+-- IOM Console Response ' + '-' * 31)}")
        for line in response.strip().splitlines():
            print(f"  {_c(C.DIM, '|')}  {line}")
        print(f"  {_c(C.DIM, '+' + '-' * 56)}")
        print()

        low = response.lower()
        if any(kw in low for kw in ("login", "$", "#", "password")):
            ok("IOM console is responsive.")
        else:
            warn("Unexpected response — the IOM may still be booting.")
            warn("You can continue; login will be attempted next.")
    else:
        warn("No response received from IOM console.")
        warn("Login will be attempted after the password is entered.")

    print()
    return ser


# ── Step 3: Fetch & display current IOM network settings ─────────────────────
def fetch_current_config(cfg: ShelfConfig, ser: SerialPort, iom: str) -> bool:
    """
    Query Redfish for the current network config of the connected IOM via curl
    over the serial console session.  Only the IOM whose console port the serial
    cable is plugged into is reachable.
    Populates cfg.iom1 with live data (regardless of whether IOM1 or IOM2).
    Returns True if the IOM responded successfully.
    """
    rule(f"Step 3 of 5 -- Current {iom} Network Settings")
    info(f"Querying {iom} Redfish API via serial console...")
    print()

    path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1"
    ok_flag, data = _serial_redfish_request(ser, cfg.password, "GET", path)

    if ok_flag and isinstance(data, dict):
        dhcp_enabled = (
            data.get("DHCPv4", {}).get("DHCPEnabled", False) or
            data.get("DHCPv6", {}).get("DHCPEnabled", False)
        )

        # Prefer IPv4StaticAddresses; fall back to IPv4Addresses
        addrs = data.get("IPv4StaticAddresses") or data.get("IPv4Addresses", [])
        if addrs:
            addr_rec = addrs[0]
            ip      = addr_rec.get("Address",    "--")
            gateway = addr_rec.get("Gateway",    "--")
            netmask = addr_rec.get("SubnetMask", "--")
        else:
            ip = gateway = netmask = "--"

        cfg.iom1 = IOMConfig(
            iom     = iom,
            dhcp    = dhcp_enabled,
            ip      = ip      if ip      != "--" else "",
            gateway = gateway if gateway != "--" else "",
            netmask = netmask if netmask != "--" else "",
        )

        mode_str = _c(C.CYN, "DHCP") if dhcp_enabled else _c(C.GRN, "Static")
        draw_table(
            ["IOM", "Mode", "IP Address", "Gateway", "Subnet Mask"],
            [[iom, mode_str, ip, gateway, netmask]],
            [5, 8, 15, 15, 15],
        )
        print()
        return True
    else:
        draw_table(
            ["IOM", "Mode", "IP Address", "Gateway", "Subnet Mask"],
            [[iom, _c(C.RED, "No response"), "--", "--", "--"]],
            [5, 8, 15, 15, 15],
        )
        print()
        error(f"{iom} query failed: {data}")
        error(f"Check that the serial cable is connected and {iom} is booted.")
        print()
        return False


# ── Step 4: Prompt user — change config or exit ───────────────────────────────
def collect_network_config(cfg: ShelfConfig, iom: str) -> bool:
    """
    Show current settings, ask user what to do.
    Returns True to proceed with applying changes, False to skip.
    """
    rule("Step 4 of 5 -- Change Configuration?")

    print(f"    {_c(C.BOLD, '1')}  Change network configuration")
    print(f"    {_c(C.BOLD, '2')}  Leave settings as-is and disconnect")
    print()

    while True:
        choice = prompt("Select option [1/2]")
        if choice in ("1", "2"):
            break
        warn("Please enter 1 or 2.")

    if choice == "2":
        info("No changes requested.")
        return False

    # ── User wants to change settings ─────────────────────────────────────────
    print()
    print(f"  How should {iom} be configured?")
    print(f"    {_c(C.BOLD, '1')}  Static IP address")
    print(f"    {_c(C.BOLD, '2')}  DHCP")
    print()

    while True:
        mode = prompt("Select mode [1/2]")
        if mode in ("1", "2"):
            break
        warn("Please enter 1 or 2.")

    use_dhcp = (mode == "2")
    print()

    if use_dhcp:
        cfg.iom1 = IOMConfig(iom, dhcp=True)
        ok(f"{iom} will be set to DHCP.")
        return True

    # Static
    info(f"Static network details for {_c(C.BOLD, iom)}:")
    ip = prompt_ip(f"  {iom} IP address ")
    gw = prompt_ip(f"  {iom} Gateway    ")
    nm = prompt_ip(f"  {iom} Subnet Mask")
    cfg.iom1 = IOMConfig(iom, dhcp=False, ip=ip, gateway=gw, netmask=nm)
    return True


# ── Step 5a: Apply configuration via Redfish ──────────────────────────────────
def apply_configuration(cfg: ShelfConfig, ser: SerialPort) -> bool:
    rule("Step 5 of 5 -- Applying Configuration via Redfish API")

    info("Sending Redfish PATCH request to IOM1 via serial console curl...")
    print()

    success, detail = _apply_iom(cfg.password, cfg.iom1, ser)
    status = _c(C.GRN, "OK") if success else _c(C.RED, "FAIL")
    draw_table(["IOM", "Result", "Detail"], [["IOM1", status, detail]], [6, 8, 50])
    print()
    return success


def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple:
    """
    Apply network config to a single IOM via curl over the serial console.

    DHCP: single PATCH enabling DHCPv4.

    Static: two sequential PATCHes to work around a firmware bug in the
    current ES24N release that prevents disabling DHCP and setting a static
    address in the same request.
      Pass 1 -- set the static IP/gateway/netmask (DHCP still on)
      Pass 2 -- disable DHCP (address is already committed)
    """
    path = f"/redfish/v1/Managers/{iom_cfg.iom}/EthernetInterfaces/1"

    if iom_cfg.dhcp:
        ok_flag, data = _serial_redfish_request(
            ser, password, "PATCH", path,
            {"DHCPv4": {"DHCPEnabled": True}},
        )
        if ok_flag:
            return True, "Configured: DHCP"
        return False, str(data)[:80]

    # Static -- Pass 1: set address while DHCP is still enabled
    info(f"  {iom_cfg.iom} pass 1/2 -- setting static address {iom_cfg.ip}...")
    ok_flag, data = _serial_redfish_request(
        ser, password, "PATCH", path,
        {
            "IPv4StaticAddresses": [{
                "Address":    iom_cfg.ip,
                "Gateway":    iom_cfg.gateway,
                "SubnetMask": iom_cfg.netmask,
            }]
        },
    )
    if not ok_flag:
        return False, f"Pass 1 failed: {str(data)[:70]}"

    # Brief pause to allow the IOM to commit the address before the next call
    time.sleep(1)

    # Static -- Pass 2: disable DHCP now that the static address is committed
    info(f"  {iom_cfg.iom} pass 2/2 -- disabling DHCP...")
    ok_flag, data = _serial_redfish_request(
        ser, password, "PATCH", path,
        {"DHCPv4": {"DHCPEnabled": False}},
    )
    if not ok_flag:
        return False, f"Pass 2 failed: {str(data)[:70]}"

    return True, f"Configured: Static {iom_cfg.ip}"


# ── Step 5b: Print applied-settings summary ───────────────────────────────────
def print_summary(cfg: ShelfConfig, changed: bool):
    rule("Summary")

    iom = cfg.iom1
    if iom.dhcp:
        mode, ip, gateway, netmask = "DHCP", "-- (DHCP)", "-- (DHCP)", "-- (DHCP)"
    else:
        mode, ip, gateway, netmask = "Static", iom.ip, iom.gateway, iom.netmask

    draw_table(
        ["Setting",     "IOM1"],
        [
            ["Mode",        mode],
            ["IP Address",  ip],
            ["Gateway",     gateway],
            ["Subnet Mask", netmask],
            ["Serial Port", cfg.device],
            ["Changes",     "Yes" if changed else "None"],
        ],
        [12, 28],
    )

    if changed:
        print()
        draw_box([
            f"{_c(C.YEL, 'IMPORTANT -- Per the ES24N Service Guide:')}",
            "",
            "Remove the serial cable ONLY after verifying the",
            "expander appears in TrueNAS with matching drives.",
            "",
            "TrueNAS > System Settings > Enclosure >",
            "NVMe-oF Expansion Shelves",
        ], colour=C.YEL)
    print()


# ── Disconnect ────────────────────────────────────────────────────────────────
def close_serial_connection(ser: SerialPort, device: str):
    if ser and ser.is_open:
        ser.close()
        ok(f"Serial port {device} closed.")

    print()
    prompt("Disconnect the serial cable, then press Enter to continue")
    ok("Serial cable disconnected. Shelf complete.")


# ── Full shelf configuration cycle ────────────────────────────────────────────
def configure_shelf() -> bool:
    """Run one complete shelf cycle. Returns True if user wants another shelf."""
    banner()
    cfg = ShelfConfig()

    # 1 — Detect device
    device = detect_serial_device()
    if not device:
        error("Could not detect a serial device. Returning to main menu.")
        time.sleep(2)
        return True
    cfg.device = device

    # 2 — Open serial port & wake IOM console
    ser = open_serial_connection(device)
    if not ser:
        error("Could not open serial port. Returning to main menu.")
        time.sleep(2)
        return True

    # Log in to the IOM console (auto-detects password from hostname)
    print()
    logged_in, cfg.password = _login_serial_console(ser)
    if not logged_in:
        error("Could not log in to IOM console. Returning to main menu.")
        close_serial_connection(ser, device)
        time.sleep(2)
        return True

    # Prompt for which IOM the serial cable is connected to
    print()
    print("  Which IOM is the serial cable connected to?")
    print(f"    {_c(C.BOLD, '1')}  IOM1")
    print(f"    {_c(C.BOLD, '2')}  IOM2")
    print()
    while True:
        iom_choice = prompt("Select [1/2]")
        if iom_choice in ("1", "2"):
            break
        warn("Please enter 1 or 2.")
    iom = "IOM1" if iom_choice == "1" else "IOM2"

    # 3 — Fetch & display current settings
    fetch_current_config(cfg, ser, iom)

    # 4 — Ask user: change or leave alone?
    apply_changes = collect_network_config(cfg, iom)

    # 5 — Apply if requested
    changed = False
    if apply_changes:
        print()
        rule("Ready to Apply")
        info("Redfish PATCH requests will be sent via curl on the IOM console.")
        print()
        if prompt_yn("Apply configuration now?", default=True):
            apply_configuration(cfg, ser)
            changed = True
        else:
            warn("Configuration skipped — no changes were made.")

    # Summary
    print_summary(cfg, changed)

    # Disconnect
    close_serial_connection(ser, device)

    print()
    return prompt_yn("Configure another ES24N shelf?", default=False)
