from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
    CONF_DIR_PIN,
    CONF_DIRECTION,
    CONF_HYSTERESIS,
    CONF_ID,
    CONF_POWER_MODE,
    CONF_RANGE,
    CONF_WATCHDOG,
)

CODEOWNERS = ["@ammmze"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True

as5600_ns = cg.esphome_ns.namespace("as5600")
AS5600Component = as5600_ns.class_("AS5600Component", cg.Component, i2c.I2CDevice)

DIRECTION = {
    "CLOCKWISE": 0,
    "COUNTERCLOCKWISE": 1,
}

POWER_MODE = {
    "NOMINAL": 0,
    "LOW1": 1,
    "LOW2": 2,
    "LOW3": 3,
}

HYSTERESIS = {
    "NONE": 0,
    "LSB1": 1,
    "LSB2": 2,
    "LSB3": 3,
}

SLOW_FILTER = {
    "16X": 0,
    "8X": 1,
    "4X": 2,
    "2X": 3,
}

FAST_FILTER = {
    "NONE": 0,
    "LSB6": 1,
    "LSB7": 2,
    "LSB9": 3,
    "LSB18": 4,
    "LSB21": 5,
    "LSB24": 6,
    "LSB10": 7,
}

CONF_RAW_ANGLE = "raw_angle"
CONF_RAW_POSITION = "raw_position"
CONF_SLOW_FILTER = "slow_filter"
CONF_FAST_FILTER = "fast_filter"
CONF_START_POSITION = "start_position"
CONF_END_POSITION = "end_position"


RESOLUTION = 4096
MAX_POSITION = RESOLUTION - 1
ANGLE_TO_POSITION = RESOLUTION / 360
POSITION_TO_ANGLE = 360 / RESOLUTION
# validate min range of 18deg (per datasheet) ... though i seem to get valid values down to a range of 192steps (16.875deg)
MIN_RANGE = round(18 * ANGLE_TO_POSITION)


def angle(min=-360, max=360):
    return cv.All(
        cv.float_with_unit("angle", "(°|deg)"), cv.float_range(min=min, max=max)
    )


def angle_to_position(value, min=-360, max=360):
    try:
        value = angle(min=min, max=max)(value)
        return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION
    except cv.Invalid as e:
        raise cv.Invalid(f"When using angle, {e.error_message}") from e


def percent_to_position(value):
    value = cv.possibly_negative_percentage(value)
    return (RESOLUTION + round(value * RESOLUTION)) % RESOLUTION


def position(min=-MAX_POSITION, max=MAX_POSITION):
    """Validate that the config option is a position.
    Accepts integers, degrees, or percentage (of 360 degrees).
    """

    def validator(value):
        if isinstance(value, str) and value.endswith("%"):
            value = percent_to_position(value)

        if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")):
            return angle_to_position(
                value,
                min=round(min * POSITION_TO_ANGLE),
                max=round(max * POSITION_TO_ANGLE),
            )

        return cv.int_range(min=min, max=max)(value)

    return validator


def position_range():
    """Validate that value given is a valid range for the device.
    A valid range is one of the following:
    - a value of 0 (meaning full range)
    - 18 thru 360 degrees
    - negative 360 thru negative 18 degrees (notes: these are normalized to their positive values, accepting negatives is for convenience)
    """
    zero_validator = position(min=0, max=0)
    negative_validator = cv.Any(
        position(min=-MAX_POSITION, max=-MIN_RANGE),
        zero_validator,
    )
    positive_validator = cv.Any(
        position(min=MIN_RANGE, max=MAX_POSITION),
        zero_validator,
    )

    def validator(value):
        is_negative_str = isinstance(value, str) and value.startswith("-")
        is_negative_num = isinstance(value, (float, int)) and value < 0
        if is_negative_str or is_negative_num:
            return negative_validator(value)
        return positive_validator(value)

    return validator


def has_valid_range_config():
    """Validate that that the config start + end position results in a valid
    positional range, which must be >= 18degrees
    """
    range_validator = position_range()

    def validator(config):
        # if we don't have an end position, then there is nothing to do
        if CONF_END_POSITION not in config:
            return config

        # determine the range by taking the difference from the end and start
        range = config[CONF_END_POSITION] - config[CONF_START_POSITION]

        # but need to account for start position being greater than end position
        # where the range rolls back around the 0 position
        if config[CONF_END_POSITION] < config[CONF_START_POSITION]:
            range = RESOLUTION + config[CONF_END_POSITION] - config[CONF_START_POSITION]

        try:
            range_validator(range)
            return config
        except cv.Invalid as e:
            raise cv.Invalid(
                f"The range between start and end position is invalid. It was was {range} but {e.error_message}"
            ) from e

    return validator


CONFIG_SCHEMA = cv.All(
    cv.Schema(
        {
            cv.GenerateID(): cv.declare_id(AS5600Component),
            cv.Optional(CONF_DIR_PIN): pins.gpio_input_pin_schema,
            cv.Optional(CONF_DIRECTION, default="CLOCKWISE"): cv.enum(
                DIRECTION, upper=True
            ),
            cv.Optional(CONF_WATCHDOG, default=False): cv.boolean,
            cv.Optional(CONF_POWER_MODE, default="NOMINAL"): cv.enum(
                POWER_MODE, upper=True, space=""
            ),
            cv.Optional(CONF_HYSTERESIS, default="NONE"): cv.enum(
                HYSTERESIS, upper=True, space=""
            ),
            cv.Optional(CONF_SLOW_FILTER, default="16X"): cv.enum(
                SLOW_FILTER, upper=True, space=""
            ),
            cv.Optional(CONF_FAST_FILTER, default="NONE"): cv.enum(
                FAST_FILTER, upper=True, space=""
            ),
            cv.Optional(CONF_START_POSITION, default=0): position(),
            cv.Optional(CONF_END_POSITION): position(),
            cv.Optional(CONF_RANGE): position_range(),
        }
    )
    .extend(cv.COMPONENT_SCHEMA)
    .extend(i2c.i2c_device_schema(0x36)),
    # ensure end_position and range are mutually exclusive
    cv.has_at_most_one_key(CONF_END_POSITION, CONF_RANGE),
    has_valid_range_config(),
)


async def to_code(config):
    var = cg.new_Pvariable(config[CONF_ID])
    await cg.register_component(var, config)
    await i2c.register_i2c_device(var, config)

    cg.add(var.set_direction(config[CONF_DIRECTION]))
    cg.add(var.set_watchdog(config[CONF_WATCHDOG]))
    cg.add(var.set_power_mode(config[CONF_POWER_MODE]))
    cg.add(var.set_hysteresis(config[CONF_HYSTERESIS]))
    cg.add(var.set_slow_filter(config[CONF_SLOW_FILTER]))
    cg.add(var.set_fast_filter(config[CONF_FAST_FILTER]))
    cg.add(var.set_start_position(config[CONF_START_POSITION]))

    if dir_pin_config := config.get(CONF_DIR_PIN):
        pin = await cg.gpio_pin_expression(dir_pin_config)
        cg.add(var.set_dir_pin(pin))

    if (end_position_config := config.get(CONF_END_POSITION, None)) is not None:
        cg.add(var.set_end_position(end_position_config))

    if (range_config := config.get(CONF_RANGE, None)) is not None:
        cg.add(var.set_range(range_config))
