import logging

from esphome import pins
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
    CONF_ANALOG,
    CONF_ID,
    CONF_INPUT,
    CONF_INVERTED,
    CONF_MODE,
    CONF_NUMBER,
    CONF_OPEN_DRAIN,
    CONF_OUTPUT,
    CONF_PULLDOWN,
    CONF_PULLUP,
)
from esphome.core import CORE

from .const import (
    KEY_BOARD,
    KEY_COMPONENT_DATA,
    KEY_LIBRETINY,
    LibreTinyComponent,
    libretiny_ns,
)

_LOGGER = logging.getLogger(__name__)

ArduinoInternalGPIOPin = libretiny_ns.class_(
    "ArduinoInternalGPIOPin", cg.InternalGPIOPin
)


def _is_name_deprecated(value):
    return value[0] in "DA" and value[1:].isnumeric()


def _lookup_board_pins(board):
    component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
    board_pins = component.board_pins.get(board, {})
    # Resolve aliased board pins (shorthand when two boards have the same pin configuration)
    while isinstance(board_pins, str):
        board_pins = board_pins[board_pins]
    return board_pins


def _lookup_pin(value):
    board: str = CORE.data[KEY_LIBRETINY][KEY_BOARD]
    board_pins = _lookup_board_pins(board)

    # check numeric pin values
    if isinstance(value, int):
        if value in board_pins.values() or not board_pins:
            # accept if pin number present in board pins
            # if board is not found, just accept all numeric values
            return value
        raise cv.Invalid(f"Pin number '{value}' is not usable for board {board}.")

    # check textual pin names
    if isinstance(value, str):
        if not board_pins:
            # can't remap without known pin name
            raise cv.Invalid(
                f"Board {board} wasn't found. "
                f"Use 'GPIO#' (numeric value) instead of '{value}'."
            )

        if value in board_pins:
            # pin name found, remap to numeric value
            if _is_name_deprecated(value):
                number = board_pins[value]
                # find all alternative pin names (except the deprecated)
                names = (
                    k
                    for k, v in board_pins.items()
                    if v == number and not _is_name_deprecated(k)
                )
                # sort by shortest
                # favor P# or PA# names
                names = sorted(
                    names,
                    key=lambda x: len(x) - 99 if x[0] == "P" else len(x),
                )
                _LOGGER.warning(
                    "Using D# and A# pin numbering is deprecated. "
                    "Please replace '%s' with one of: %s",
                    value,
                    ", ".join(names),
                )
            return board_pins[value]

        # pin name not found and not numeric
        raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.")

    # unknown type of the value
    raise cv.Invalid(f"Unrecognized pin value '{value}'.")


def _translate_pin(value):
    if isinstance(value, dict) or value is None:
        raise cv.Invalid(
            "This variable only supports pin numbers, not full pin schemas "
            "(with inverted and mode)."
        )
    if isinstance(value, int) and not isinstance(value, bool):
        return value
    if not isinstance(value, str):
        raise cv.Invalid(f"Invalid pin number: {value}")
    try:
        return int(value)
    except ValueError:
        pass
    # translate GPIO* and P* to a number, if possible
    # otherwise return unchanged value (i.e. pin PA05)
    try:
        if value.startswith("GPIO"):
            value = int(value[4:])
        elif value.startswith("P"):
            value = int(value[1:])
    except ValueError:
        pass
    return value


def validate_gpio_pin(value):
    value = _translate_pin(value)
    value = _lookup_pin(value)

    component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
    if component.pin_validation:
        value = component.pin_validation(value)

    return value


def validate_gpio_usage(value):
    mode = value[CONF_MODE]
    is_analog = mode[CONF_ANALOG]
    is_input = mode[CONF_INPUT]
    is_output = mode[CONF_OUTPUT]
    is_open_drain = mode[CONF_OPEN_DRAIN]
    is_pullup = mode[CONF_PULLUP]
    is_pulldown = mode[CONF_PULLDOWN]

    if is_open_drain and not is_output:
        raise cv.Invalid(
            "Open-drain only works with output mode", [CONF_MODE, CONF_OPEN_DRAIN]
        )
    if is_analog and not is_input:
        raise cv.Invalid("Analog pins must be an input", [CONF_MODE, CONF_ANALOG])
    if is_analog:
        # expect analog pin numbers to be available as either ADC# or A#
        number = value[CONF_NUMBER]
        board: str = CORE.data[KEY_LIBRETINY][KEY_BOARD]
        board_pins = _lookup_board_pins(board)
        analog_pins = [
            v
            for k, v in board_pins.items()
            if k[0] == "A" and k[1:].isnumeric() or k[0:3] == "ADC"
        ]
        if number not in analog_pins:
            raise cv.Invalid(
                f"Pin '{number}' is not an analog pin", [CONF_MODE, CONF_ANALOG]
            )

    # (input, output, open_drain, pullup, pulldown)
    supported_modes = {
        # INPUT
        (True, False, False, False, False),
        # OUTPUT
        (False, True, False, False, False),
        # INPUT_PULLUP
        (True, False, False, True, False),
        # INPUT_PULLDOWN
        (True, False, False, False, True),
        # OUTPUT_OPEN_DRAIN
        (False, True, True, False, False),
    }
    key = (is_input, is_output, is_open_drain, is_pullup, is_pulldown)
    if key not in supported_modes:
        raise cv.Invalid("This pin mode is not supported", [CONF_MODE])

    component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
    if component.usage_validation:
        value = component.usage_validation(value)

    return value


BASE_PIN_SCHEMA = pins.gpio_base_schema(
    ArduinoInternalGPIOPin,
    validate_gpio_pin,
    modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,),
).add_extra(validate_gpio_usage)


async def component_pin_to_code(config):
    var = cg.new_Pvariable(config[CONF_ID])
    num = config[CONF_NUMBER]
    cg.add(var.set_pin(num))
    # Only set if true to avoid bloating setup() function
    # (inverted bit in pin_flags_ bitfield is zero-initialized to false)
    if config[CONF_INVERTED]:
        cg.add(var.set_inverted(True))
    cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE])))
    return var
