from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path
from typing import Awaitable, Callable

import aiofiles
from aiofiles.threadpool.text import AsyncTextIOWrapper

from inventory_handler.InventoryTypes import PropertyName, GeneratorInterface, Property
from inventory_handler.InventoryUtils import (
    CPU_SERIAL_PATH,
    UBOOT_VERSION_PATH,
    ECP_VERSION_PATH,
    RESCUE_CENTRAL_RAUCS,
    IMAGE_VERSION_PATH,
    logger,
    LABEL_NOT_INSTALLED,
    LABEL_UNKNOWN_VERSION,
)


# -----------------------------------------------------------------------------#
# Helpers
# -----------------------------------------------------------------------------#

async def runCta(cta: Callable[..., Awaitable[None]]) -> None:
    """
    Execute a single call-to-action coroutine and log success or failure.

    :param cta: A coroutine (usually a side-effect like notifying a service).
    """
    try:
        logger.debug(f"Running call-to-action: {cta.__name__}")
        await cta()
        logger.debug(f"Call-to-action completed: {cta.__name__}")
    except Exception as e:
        logger.error(f"Call-to-action ({cta.__name__}) failed: {e}", exc_info=True)


# -----------------------------------------------------------------------------#
# Base Generator
# -----------------------------------------------------------------------------#

async def readAndParseFile(
    path: Path,
    what: str,
    parseFn: Callable[[AsyncTextIOWrapper], Awaitable[str | None]],
) -> str:
    """
    Shared helper that opens a text file, let a parser extract a value,
    and converts all error/missing cases to standardized labels.

    :param path: Location of the source file.
    :param what: Friendly label (for logging).
    :param parseFn: Async function that consumes the opened file handle
                    and returns either a string value or None.
    :return: Parsed string value, or one of the LABEL_* fall-back strings.
    """
    logger.debug(f"Reading {what} from {path}")

    if not path.exists():
        logger.warning(f"{what} metadata not found at {path}.")
        return LABEL_NOT_INSTALLED

    try:
        async with aiofiles.open(path, mode="r", encoding="utf-8") as file:
            value = await parseFn(file)

        if not value:
            logger.warning(f"{what} not found in {path}.")
            return LABEL_UNKNOWN_VERSION

        return value

    except Exception as e:
        logger.error("Failed to read %s from %s: %s", what, path, e, exc_info=True)
        return LABEL_UNKNOWN_VERSION


class Generator(GeneratorInterface):
    """
    Abstract base class for all inventory property generators.

    - Provides common update logic (change detection + CTA execution).
    - Sub-classes only implement `readValue()`.
    """

    def __init__(self, callToActions=None):
        """
        :param callToActions: Optional list of coroutines to run whenever a value changes.
        """
        self.callToActions = callToActions or []

    async def update(self, base: Property, value: str) -> Property:
        """
        Compare a newly read value with the previous property and produce
        an updated Property dict (or unchanged if same).

        :param base: Existing property dict or empty/None.
        :param value: Newly read string value.
        :return: New or updated property dict.
        """
        def makeProp(newVal: str, oldVal: str, nowIso: str, oldReg: str) -> Property:
            return {
                "value": newVal,
                "oldValue": oldVal,
                "registerDate": nowIso,
                "oldRegisterDate": oldReg,
            }

        now = datetime.now(timezone.utc).isoformat(timespec="seconds")

        if value == "":
            logger.info("Received empty value; resetting property.")
            return makeProp("", "", now, "")

        if base == {} or base is None:
            logger.info(f"Creating new property with value: {value}")
            await self.runCallToActions()
            return makeProp(value, "", now, "")

        if base["value"] != value:
            logger.info(
                f"Property value changed from '{base['value']}' to '{value}', updating property."
            )
            await self.runCallToActions()
            return makeProp(value, base["value"], now, base["registerDate"])

        logger.debug(f"Property value unchanged ('{value}'); no update needed.")
        return base

    async def runCallToActions(self) -> None:
        """
        Run all configured call-to-actions sequentially, logging each result.
        """
        if not self.callToActions:
            logger.debug("No call-to-actions to run.")
            return

        logger.info(f"Running {len(self.callToActions)} call-to-action(s)...")
        for cta in self.callToActions:
            await runCta(cta)
        logger.info("All call-to-actions completed.")

    async def readValue(self) -> str:
        """
        Sub-classes must implement to return the string value to store in the property.
        """
        raise NotImplementedError

    async def generate(self, base: Property):
        """
        High-level workflow: read current value, then call update() to produce
        the next property dict.

        :param base: Previous property dict or None.
        :return: Final property dict after applying new value.
        """
        value = await self.readValue()
        return await self.update(base=base, value=value)


# -----------------------------------------------------------------------------#
# Concrete Generators
# -----------------------------------------------------------------------------#

class CpuSerialNumberGenerator(Generator):
    """
    Reads the CPU serial number from the configured file.
    """

    def __init__(self, sourcePath: Path = CPU_SERIAL_PATH, callToActions=None):
        super().__init__(callToActions)
        self.sourcePath = sourcePath

    @property
    def name(self) -> PropertyName:
        """Inventory property name handled by this generator."""
        return PropertyName.CPU_SERIAL_NUMBER

    async def readValue(self) -> str:
        """Parse the whole file as a single serial number string."""
        async def parse(file: AsyncTextIOWrapper) -> str | None:
            content = await file.read()
            serial = content.strip()
            if serial:
                logger.info(f"CPU serial number read: '{serial}'")
                return serial
            return None

        return await readAndParseFile(self.sourcePath, what="CPU serial", parseFn=parse)


class UBootVersionGenerator(Generator):
    """
    Extracts the first line beginning with ``U-Boot`` from the file.
    """

    def __init__(self, sourcePath: Path = UBOOT_VERSION_PATH, callToActions=None):
        super().__init__(callToActions)
        self.sourcePath = sourcePath

    @property
    def name(self) -> PropertyName:
        return PropertyName.UBOOT_VERSION

    async def readValue(self) -> str:
        """Scan the file line-by-line for a line starting with ``U-Boot``."""
        async def parse(file: AsyncTextIOWrapper) -> str | None:
            async for line in file:
                stripped = line.strip()
                if stripped.startswith("U-Boot"):
                    logger.info(f"U-Boot version found: '{stripped}'")
                    return stripped
            return None

        return await readAndParseFile(self.sourcePath, what="U-Boot version", parseFn=parse)


class EcpVersionGenerator(Generator):
    """
    Finds the version of the package named ``ecp`` inside a dpkg-status-style file.
    """

    def __init__(self, sourcePath: Path = ECP_VERSION_PATH, callToActions=None):
        super().__init__(callToActions)
        self.sourcePath = sourcePath

    @property
    def name(self) -> PropertyName:
        return PropertyName.ECP_VERSION

    async def readValue(self) -> str:
        """Look for the block ``Package: ecp`` and return the subsequent ``Version:`` value."""
        async def parse(file: AsyncTextIOWrapper) -> str | None:
            insideEcp = False
            async for raw in file:
                line = raw.strip()
                if line == "Package: ecp":
                    insideEcp = True
                    logger.debug("Found [Package: ecp] section")
                elif insideEcp and line.startswith("Version:"):
                    value = line.replace("Version:", "", 1).strip()
                    logger.info(f"ECP version found: '{value}'")
                    return value
                elif insideEcp and line == "":
                    break
            return None

        return await readAndParseFile(self.sourcePath, what="ECP version", parseFn=parse)


class ImageVersionGenerator(Generator):
    """
    Reads the kernel image version from a text file by extracting the first
    line starting with ``Linux version``.
    """

    def __init__(self, sourcePath: Path = IMAGE_VERSION_PATH, callToActions=None):
        super().__init__(callToActions)
        self.sourcePath = sourcePath

    @property
    def name(self) -> PropertyName:
        return PropertyName.IMAGE_VERSION

    async def readValue(self) -> str:
        """Return the first three tokens of a line starting with ``Linux version``."""
        async def parse(file: AsyncTextIOWrapper) -> str | None:
            async for line in file:
                stripped = line.strip()
                if stripped.startswith("Linux version"):
                    parts = stripped.split()
                    if len(parts) >= 3:
                        value = " ".join(parts[:3])
                        logger.info(f"Image version found: '{value}'")
                        return value
            return None

        return await readAndParseFile(self.sourcePath, what="Image version", parseFn=parse)


class RescueOsVersionGenerator(Generator):
    """
    Parses a RAUC central.raucs file and extracts the
    ``bundle.version`` from the ``[slot.recovery.0]`` section.
    """

    def __init__(self, sourcePath: Path = RESCUE_CENTRAL_RAUCS, callToActions=None):
        super().__init__(callToActions)
        self.sourcePath = sourcePath

    @property
    def name(self) -> PropertyName:
        return PropertyName.RESCUE_OS_VERSION

    async def readValue(self) -> str:
        """Scan until `[slot.recovery.0]` and return its `bundle.version` entry if present."""
        async def parse(file: AsyncTextIOWrapper) -> str | None:
            insideRecovery = False
            async for raw in file:
                line = raw.strip()
                if line == "[slot.recovery.0]":
                    insideRecovery = True
                    logger.debug("Found [slot.recovery.0] section")
                    continue

                if insideRecovery:
                    if line.startswith("bundle.version="):
                        version = line.replace("bundle.version=", "", 1).strip()
                        logger.info(f"Rescue OS bundle version found: '{version}'")
                        return version
                    if line == "" or line.startswith("["):
                        break
            return None

        return await readAndParseFile(self.sourcePath, what="Rescue OS bundle version", parseFn=parse)
