import asyncio
import json
import logging
from http.server import HTTPServer
from pathlib import Path
from threading import Event
import sys

from dbus_next import DBusError, InvalidObjectPathError
from .screen import provideFirmwareUpdateScreen
from .utils import (
    OTA_ECP4_LOG,
    EVTEC_ECP_DATA_FOLDER,
    CONFIG_FOLDER,
    OTA_MAIN_BOARD_API_PORT,
    OtaState,
    DeviceRole,
    buildIpAddressFromConfig,
    setupLogging,
    OTA_DATA_FOLDER,
    addStatusEntry,
    runSubprocess,
    BROWSER_RELOAD_KEYWORD,
    OTA_DOWNLOAD_FOLDER,
    sendGetRequest,
    DISPLAY_API_PORT,
    logHeader,
    RESCUE_RAUCB_ARCHIVE_PATH,
)
from .restoration import (
    backupInventory,
    removeDir,
    exposeOtaLogs,
    checkExpectedServices,
    getConfiguredServices,
    exposeStatistics,
)
from .displayBoard import (
    getMainBoardAddress,
    sendOtaNotification,
    createDisplayBoardHttpServer,
)
from .mainBoard import (
    startServer,
    parseInventoryForConnectedHosts,
)
from .raucInterface import Rauc

ECP_CONFIG: Path = EVTEC_ECP_DATA_FOLDER / CONFIG_FOLDER / "config.json"

logger = logging.getLogger(OTA_ECP4_LOG)

SECONDS_PER_MINUTE = 60

# On a single core SOM, extracting the ubifs takes about 10 min, plus extracting twice the rootfs takes 2x 10 min
# Wait for long enough here before raising a timeout error
MINUTES_TO_WAIT_FOR_OTA_UPDATE = 60


def readConfig() -> dict:
    """
    Read the config file and return it as a plain dictionary.
    """
    logger.info(f"Reading configuration from {ECP_CONFIG}")

    if not ECP_CONFIG.is_file():
        logger.warning("Config file not found.")
        exit(1)

    try:
        with open(ECP_CONFIG) as f:
            config = json.load(f)
    except json.JSONDecodeError as err:
        logger.warning(f"Failed to load {ECP_CONFIG.as_posix()}. Error: {err}")
        exit(1)
    except Exception as err:
        logger.warning(f"Failed to load {ECP_CONFIG.as_posix()}. Error: {err}")
        exit(1)

    return config


def printTaskHead(name: str) -> None:
    logger.info(f"\n{name} ".ljust(99, "-"))


async def restartDisplayServiceRequest(ip):
    url = f"http://{ip}:{DISPLAY_API_PORT}/restartDisplayService"
    if await sendGetRequest(url):
        logger.info(f"Successfully restarted display service on {ip}")
    else:
        logger.warning(f"Failed to restart display service on {ip}!!")


async def readyRequest(ip):
    url = f"http://{ip}:{DISPLAY_API_PORT}/ready"
    if await sendGetRequest(url):
        logger.info(f"Successfully restarted display service on {ip}")
    else:
        logger.warning(f"Failed to restart display service on {ip}!!")


async def updateRequest(ip):
    """Send an update request to a remote board."""
    logger.info(f"Sending update command to remote board at {ip}.")
    url = f"http://{ip}:{DISPLAY_API_PORT}/update"
    if await sendGetRequest(url):
        logger.info(f"Update started on {ip}.")
    else:
        logger.warning(f"Failed to start update on {ip}!!")


async def hijackScreen(
    address: str, remoteClients: list, isPowerSinkControl: bool
) -> HTTPServer:
    logger.info(f"Stop ServiceSupervisor")
    await runSubprocess(["systemctl", "stop", "ServiceSupervisor"])

    if isPowerSinkControl:
        logger.info(f"Stop PowerSinkControl")
        await runSubprocess(["systemctl", "stop", "PowerSinkControl"])
    else:
        logger.info(f"Stop ECP")
        await runSubprocess(["systemctl", "stop", "ECP"])

    logger.info(f"Start FeedWatchdog")
    await runSubprocess(["systemctl", "restart", "FeedWatchdog"])

    # make sure ECP has stopped and we can hijack port 8090
    await asyncio.sleep(10)

    logger.info("Start Firmware Update Screen")
    screen = provideFirmwareUpdateScreen(OTA_DATA_FOLDER, address)

    return screen


async def shutdownScreen(screen, remoteClients, isPowerSinkControl):
    # Reload the browser, in case we have an "Eichrecht Display"
    logger.info("Send Browser reload message")
    await addStatusEntry(BROWSER_RELOAD_KEYWORD)

    logger.info("Stop firmware update screen.")
    screen.shutdown()

    if isPowerSinkControl:
        logger.info("Start PowerSinkControl")
        await runSubprocess(["systemctl", "start", "PowerSinkControl"])
    else:
        logger.info("Start ECP")
        await runSubprocess(["systemctl", "start", "ECP"])

    logger.info("Stop FeedWatchdog")
    await runSubprocess(["systemctl", "stop", "FeedWatchdog"])

    if len(remoteClients) > 0:
        logger.info("Restart display service")
        tasks = [
            asyncio.gather(restartDisplayServiceRequest(ip)) for ip in remoteClients
        ]
        await asyncio.gather(*tasks)

        await asyncio.sleep(10)

        logger.info("Send ready notification")
        tasks = [asyncio.gather(readyRequest(ip)) for ip in remoteClients]
        await asyncio.gather(*tasks)


async def startRaucBundleInstallation(rauc, bundleFile: Path) -> None:
    """
    Coroutine for the installation of Rauc Bundle via d-bus API.

    :param bundleFile: Path to file
    """
    try:
        await rauc.connect()

        await rauc.installBundle(bundleFile.as_posix())

    except (
        AttributeError,
        DBusError,
        InvalidObjectPathError,
        TypeError,
        IndexError,
        EOFError,
    ) as e:
        logger.warning(f"Failed to install rauc bundle {e}!!")


async def main() -> int:
    setupLogging()

    # add stdout handler
    logHeader("Start OTA ECP4")

    await addStatusEntry("Starting OTA Cleanup")

    # config check
    printTaskHead(f"Start checking device")
    config = readConfig()
    serial = config.get("Device").get("Serial").get("Serial")
    role = config.get("Device").get("Role")
    # check if the current board is the main board of the OTA process
    isOtaMainBoard = role in [
        DeviceRole.CONTROLLER_REMOTE_DISPLAY,
        DeviceRole.CONTROLLER_LOCAL_HMI,
        DeviceRole.POWER_SINK_CONTROL,
    ]
    isPowerSinkControl = role == DeviceRole.POWER_SINK_CONTROL
    ownIpAddress: str = buildIpAddressFromConfig(serial, role)
    remoteClients: list[str] = (
        parseInventoryForConnectedHosts() if isOtaMainBoard else []
    )

    # Worst case scenario: something in the OTA flow did not work correctly (f.e. display no longer reachable)
    # In this case, skip the cleanup to prevent data loss of the OTA inventory. This allows for:
    # - Retrieving inventory (the service technician is able to know how the charger must be configured when updating on site)
    # - Retrying to proceed with the update on reboot
    readyEvent = Event()
    skipCleanup: bool = False
    raucInterface = Rauc()

    # Baseboard
    if isOtaMainBoard:

        # restoration tasks
        printTaskHead("Executing restoration tasks")
        backupInventory()
        exposeOtaLogs()
        exposeStatistics()

        printTaskHead(f"Start hijacking screen")
        firmwareUpdateScreenServer = await hijackScreen(
            ownIpAddress, remoteClients, isPowerSinkControl
        )

        printTaskHead(f"Start clean rescue OS install with rauc")
        await startRaucBundleInstallation(raucInterface, RESCUE_RAUCB_ARCHIVE_PATH)
        for _ in range(20):
            done = await raucInterface.checkInstallationDone(logger)
            if done:
                break
            await asyncio.sleep(5)
        # sleep extra 3 seconds for post install hooks
        await asyncio.sleep(3)
        # disconnect after use
        await raucInterface.disconnect()

        printTaskHead(f"Start updating display board(s)")
        if len(remoteClients) > 0:
            logger.info("Start OTA server to keep track of remote board states.")
            otaDoneEvent = Event()
            otaServer, statusContainer = startServer(
                port=OTA_MAIN_BOARD_API_PORT,
                otaDoneEvent=otaDoneEvent,
                clients=remoteClients,
            )

            # Start update on remote clients
            tasks = [asyncio.create_task(updateRequest(ip)) for ip in remoteClients]

            # Wait for all tasks to finish and handle cancellations
            await asyncio.gather(*tasks, return_exceptions=True)

            # wait for display boards to be updated
            message = "Waiting for display boards to be updated."
            printTaskHead(message)
            await addStatusEntry(message)
            loop = asyncio.get_running_loop()
            try:
                await asyncio.wait_for(
                    loop.run_in_executor(None, otaDoneEvent.wait),
                    timeout=SECONDS_PER_MINUTE * MINUTES_TO_WAIT_FOR_OTA_UPDATE,
                )
                message = "All remotes have completed OTA successfully."
                logger.info(message)
                await addStatusEntry(message)
            except asyncio.TimeoutError:
                message = f"Timeout waiting for OTA to terminate on connected boards. {statusContainer.getAllStates()}"
                logger.warning(message)
                await addStatusEntry(message)
                skipCleanup = True

            if otaServer is not None:
                message = "Stop OTA server"
                printTaskHead(message)
                await addStatusEntry(message)
                otaServer.shutdown()
                logger.info("Stopped serving OTA server for board synchronization")

        printTaskHead(f"Stop hijacking screen")
        await shutdownScreen(
            firmwareUpdateScreenServer, remoteClients, isPowerSinkControl
        )

        # service check tasks
        printTaskHead("Check running ECP services")
        checkExpectedServices(config)

        # OTA cleanup tasks
        if not skipCleanup:
            printTaskHead("Executing OTA cleanup tasks")
            removeDir(OTA_DOWNLOAD_FOLDER)
            removeDir(OTA_DATA_FOLDER)

    # Display Board
    else:
        printTaskHead("Start DisplayBoard Server")
        createDisplayBoardHttpServer(getConfiguredServices(config), readyEvent)

        # display board flow does update and report to main board about status
        logger.info("Send Boot ECP4 message")
        mainBoardAddress = getMainBoardAddress(serial)
        await sendOtaNotification(
            destinationIp=mainBoardAddress,
            sourceIp=ownIpAddress,
            notification=OtaState.NOTIFY_BOOT_ECP4,
        )

        # restoration tasks
        printTaskHead("Executing OTA restoration tasks")
        backupInventory()
        exposeOtaLogs()
        exposeStatistics()

        printTaskHead(f"Start clean rescue OS install with rauc")
        await startRaucBundleInstallation(raucInterface, RESCUE_RAUCB_ARCHIVE_PATH)
        for _ in range(20):
            done = await raucInterface.checkInstallationDone(logger)
            error = await raucInterface.getLastError(logger)
            if done or error:
                break
            await asyncio.sleep(5)
        # sleep extra 3 seconds for post install hooks
        await asyncio.sleep(3)
        # disconnect after use
        await raucInterface.disconnect()

        # OTA cleanup tasks
        printTaskHead("Executing OTA cleanup tasks")
        removeDir(OTA_DOWNLOAD_FOLDER)
        removeDir(OTA_DATA_FOLDER)

        # send status notification to main board that cleanup has been done
        logger.info("Send OTA ECP4 Complete message")
        await sendOtaNotification(
            destinationIp=mainBoardAddress,
            sourceIp=ownIpAddress,
            notification=OtaState.NOTIFY_COMPLETE_ECP4,
        )

        logger.info(f"Waiting for baseboard")
        loop = asyncio.get_running_loop()
        try:
            await asyncio.wait_for(
                loop.run_in_executor(None, readyEvent.wait),
                timeout=SECONDS_PER_MINUTE * MINUTES_TO_WAIT_FOR_OTA_UPDATE,
            )
            logger.info("Wait for baseboard complete")
        except asyncio.TimeoutError:
            logger.warning("Timeout waiting for Baseboard")

        # service check tasks
        printTaskHead("Check running ECP services")
        checkExpectedServices(config)

    logHeader("OTA ECP4 finished")

    return 0


if __name__ == "__main__":
    returnCode = asyncio.run(main())
    sys.exit(returnCode)
