import os
import sys
import logging
import subprocess
import json
import tempfile
from datetime import datetime, timezone

from shutil import copy
from os.path import join, normcase, normpath
from winreg import (
    OpenKeyEx, QueryValueEx, SetValueEx,
    HKEY_LOCAL_MACHINE, KEY_ALL_ACCESS, KEY_READ
)

logger = logging.getLogger(__name__)

def is_frozen_exe() -> bool:
    return bool(getattr(sys, "frozen", False))

def update_service_binpath(service_name: str, exe_path: str) -> str:
    """
    Update service ImagePath in registry to point to exe_path.
    Returns the previous ImagePath string.
    """

    with OpenKeyEx(HKEY_LOCAL_MACHINE,
                       fr"SYSTEM\CurrentControlSet\services\{service_name}",
                       0,
                       KEY_ALL_ACCESS) as key:
        (old_executable, reg_type) = QueryValueEx(key, "ImagePath")
        SetValueEx(key, "ImagePath", None, 2, f'"{exe_path}"')

    return old_executable


def copy_running_exe(dest: str) -> bool:
    """
    Startup helper:
      - If the running executable image is not the dest
        then copy it to dest (overwriting old dest if present),
      - Otherwise do nothing.

    Returns True if it performed changes, otherwise False.
    """
    # Where are we actually running from?
    current_exe = sys.executable

    # Windows paths are case-insensitive -> compare with normcase
    if normcase(current_exe) == normcase(dest):
        return False

    copy(current_exe, dest)
    return True

def servicePostUpdate(service_name, exe_path):
        logger.debug("Running exe %s", sys.executable)
        if is_frozen_exe() and copy_running_exe(exe_path):
            logger.debug("The running agent copyed to %s", exe_path)
            old_exe = update_service_binpath(service_name, exe_path)
            logger.debug("%s service binpath updated  %s -> %s", service_name, old_exe, exe_path)
            return True
        return False

def getRegistryVal(reg_path: str, name: str, default=None):
    """
    Read HKLM\\<reg_path>\\<name> and return its value.
    If key or value does not exist, return default.
    Example:
        getRegistryVal(
            r"SYSTEM\\CurrentControlSet\\Services\\circle-agent",
            "LogLevel",
            "INFO"
        )
    """
    value=default
    try:
        with OpenKeyEx(HKEY_LOCAL_MACHINE, reg_path, 0, KEY_READ) as key:
            value, _ = QueryValueEx(key, name)
    except Exception as e: 
        logger.debug("Registry read failed %s\\%s: %s",
        reg_path, name, e
    )
    return value

def start_process_async(path, args=None, workdir=None, delay_seconds=0):
    """
    Starts a process asynchronously (fire-and-forget) using cmd.exe.
    Parameters:
        path (str): 
              Supported types:
              - .exe, .bat, .cmd  → started directly by cmd.exe
              - .ps1              → started via powershell.exe (-File)
        args Command-line arguments passed to the target process.
            Arguments are appended as-is; quoting is the caller's responsibility.
        workdir Working directory for the started process.
        delay_seconds
    Notes:
        - The function does not wait for the process to finish.
        - No stdout/stderr is captured; all output is discarded.
    """

    if args is None:
        args = []

    ext = os.path.splitext(path)[1].lower()

    if ext == ".ps1":
        cmdline = f'powershell -NoProfile -ExecutionPolicy Bypass -File "{path}"'
        if args:
            cmdline += " " + " ".join(args)
    else:
        cmdline = f'"{path}"'
        if args:
            cmdline += " " + " ".join(args)

    parts = []

    if workdir:
        parts.append(f'cd /d "{workdir}"')

    parts.append(f'timeout /t {int(delay_seconds)} /nobreak >nul')
    parts.append(cmdline)

    cmd = " & ".join(parts)

    try: 
        subprocess.Popen(
            ["cmd.exe", "/c", cmd],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL
        )
    except Exception:
        logger.exception(f"Execution failed for: {path} (workdir={workdir})")

def file_is_newer(file_a, file_b):
    """
    Returns True if file_a is newer than file_b.
    Raises FileNotFoundError if any file does not exist.
    """

    if not os.path.exists(file_a):
        raise FileNotFoundError(file_a)
    if not os.path.exists(file_b):
        raise FileNotFoundError(file_b)

    return os.path.getmtime(file_a) > os.path.getmtime(file_b)

def start_delayed_process(command, delay_seconds, workdir=None):
    """
    Starts a delayed process asynchronously using cmd.exe (no threads).
    """

    if isinstance(command, list):
        exe = command[0]
        args = " ".join(command[1:])
    else:
        exe = command
        args = ""

    cmd = (
        f'timeout /t {int(delay_seconds)} /nobreak >nul '
        f'& "{exe}" {args}'
    )

    subprocess.Popen(
        ["cmd.exe", "/c", cmd],
        cwd=workdir,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )
    

def load_json(path, default=None):
    """
    Loads a JSON file and returns a dict.
    If file does not exist, returns default (or empty dict).
    """

    if not os.path.exists(path):
        return default if default is not None else {}

    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

def save_json(path, data):
    """
    Saves dict to JSON file atomically.
    """

    directory = os.path.dirname(path)
    if directory:
        os.makedirs(directory, exist_ok=True)

    fd, temp_path = tempfile.mkstemp(
        dir=directory,
        prefix=".tmp_",
        suffix=".json"
    )

    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
            f.flush()
            os.fsync(f.fileno())

        os.replace(temp_path, path)

    finally:
        if os.path.exists(temp_path):
            try:
                os.remove(temp_path)
            except OSError:
                pass

import os
import sys
import shutil

def update_component(state_file: str, base_dir: str) -> str:
    """
    Component self-update state handler.
    Uses a per-component JSON state file to coordinate a two-phase update:
    1) If a newer pending image is detected, transitions from IDLE → PENDING
       and schedules the pending image to start (or updates the service binpath).
    2) When running from the pending image, copies it over the standard image,
       updates the state to COPIED, and schedules the standard image to restart.

    The function is restart-safe and supports both service-managed components
    and user-mode processes. The running image is identified by comparing
    sys.executable with the configured image names.
    """
    state_file = os.path.join(base_dir, state_file)
    state = load_json(state_file, default={})
    state["last_checked"] = datetime.now(timezone.utc).isoformat() + "Z"
    save_json(state_file, state)
    img = state.get("img")
    img_pending = state.get("img_pending")
    status = (state.get("status") or "idle").lower()
    service = state.get("service")

    if not img or not img_pending:
        raise ValueError("status json must contain 'img' and 'img_pending'")

    img_path = os.path.join(base_dir, img)
    img_pending_path = os.path.join(base_dir, img_pending)

    # Determine what THIS running process is (by basename)
    self_image = os.path.basename(sys.executable)
    is_self_img = self_image.lower() == os.path.basename(img).lower()
    is_self_img_pending = self_image.lower() == os.path.basename(img_pending).lower()
    logger.debug("executable: %s is_self: %s is_self_pending: %s", self_image, is_self_img, is_self_img_pending)

    # Compare mtimes (img_pending newer than img?)
    try:
        newer = file_is_newer(img_pending_path, img_path)
    except FileNotFoundError:
        newer = False
        logger.debug("One of teh img is not found: %s %s", img_path, img_pending_path)
    
    if newer:
        logger.debug("newer: %s", status);
        if status == "idle" and is_self_img_pending:
            status = "pending"        # img_pending started first 
            
        if status == "idle":
            state["status"] = "pending"
            save_json(state_file, state)

            if service:
                # set pending image as service image
                update_service_binpath(service, img_pending_path)
                return "exit"
            else: 
                # start pending image after 60 sec, then the caller should exit
                logger.debug("Start porcess: %s", imp_pending)
                start_delayed_process(img_pending, workdir=base_dir, delay_seconds=60)
            return "set_pending_and_started_img_pending"

        if status == "pending" and is_self_img_pending:
            # we are running as img_pending -> safe to overwrite img (img is not running)
            shutil.copy2(img_pending_path, img_path)
            logger.debug("Copy: %s ---> %s", img_pending_path, img_path)

            state["status"] = "copied"
            save_json(state_file, state)
            
            if service: 
                # set standard image as service image
                update_service_binpath(service, img_path)
                return "exit"
            else: 
                # start standard image after 60 sec
                logger.debug("Start porcess: %s", imp)
                start_delayed_process(img, workdir=base_dir, delay_seconds=60)
                return "copied_img_pending_to_img_and_started_img"

        return "newer_no_action"
    else:
        if status != "idle":
            state["status"] = "idle"
            save_json(state_file, state)
            return "reset_to_idle"

        return "idle_no_change"


def get_windows_version():
    if sys.platform != "win32":
        return None

    ver = sys.getwindowsversion()
    major = ver.major
    minor = ver.minor
    build = ver.build

    # Windows 7
    if major == 6 and minor == 1:
        return "Windows_7"

    # Windows 8 / 8.1
    if major == 6 and minor in (2, 3):
        return "Windows_8"

    # Windows 10 / 11
    if major == 10:
        # Windows 11 starts at build 22000
        if build >= 22000:
            return "Windows_11"
        else:
            return "Windows_10"

    return f"Windows_{major}_{minor}_{build})"

if __name__ == '__main__':
    logging.basicConfig(
        level=logging.DEBUG,
        format="%(asctime)s %(name)s %(levelname)s %(message)s"
    )
    print(update_component("circle-watchdog.state", r"c:\circle"))
    