from __future__ import annotations

from collections.abc import Mapping
import json
import logging
from pathlib import Path
from types import TracebackType
from typing import TypeAlias

from influxdb_client.client.influxdb_client import InfluxDBClient
from influxdb_client.client.write.point import Point
from influxdb_client.client.write_api import SYNCHRONOUS
from influxdb_client.rest import ApiException

from ..settings import InfluxSettings, load_settings
from ..types import TUID

logger = logging.getLogger(__name__)

FieldValue: TypeAlias = str | int | float | bool
CLIENT_TIMEOUT_MS = 10_000

QOI_KEY = "qoi"
QOI_TAG_KEYS = (
    "element",
    "label",
    "unit",
    "element_label",
    "device_ID",
    "run_ID",
    "cycle_ID",
    "condition",
)
QOI_FIELD_KEYS = (
    "nominal_value",
    "uncertainty",
    "tuid",
)
RESERVED_KEYS = frozenset((QOI_KEY, *QOI_TAG_KEYS, *QOI_FIELD_KEYS))


class InfluxWriteError(RuntimeError):
    """User-facing write error with actionable context."""


def _api_error_detail(error: ApiException) -> str | None:
    """Extract a compact detail message from an InfluxDB API error."""
    body = error.body
    if isinstance(body, bytes):
        body = body.decode("utf-8", errors="replace")

    if isinstance(body, str) and body.strip():
        raw = body.strip()
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError:
            return raw

        if isinstance(payload, dict):
            code = payload.get("code")
            message = payload.get("message")
            if isinstance(code, str) and code.strip():
                if isinstance(message, str) and message.strip():
                    return f"{code}: {message}"
                return code
            if isinstance(message, str) and message.strip():
                return message
        return raw

    if isinstance(error.reason, str) and error.reason.strip():
        return error.reason
    return None


def _friendly_write_error_message(error: Exception, settings: InfluxSettings) -> str:
    """Translate low-level client errors into user-friendly write guidance."""
    if isinstance(error, ApiException):
        detail = _api_error_detail(error)
        status = error.status

        if status == 401:
            return (
                "Write rejected (401 unauthorized). "
                "Check settings.toml values (`url`, `org`, `bucket`, `token`) and "
                "use a write-capable token for this org/bucket."
            )
        if status == 403:
            return (
                "Write rejected (403 forbidden). "
                "Token is valid but lacks permission for this org/bucket."
            )
        if status == 404:
            return (
                "Write rejected (404 not found). "
                f"Bucket/org mismatch for bucket='{settings.bucket}', org='{settings.org}'."
            )
        if status == 422:
            suffix = f" Detail: {detail}" if detail else ""
            return f"Write rejected (422 invalid point format).{suffix}"

        suffix = f" Detail: {detail}" if detail else ""
        return f"Write failed with InfluxDB API error (HTTP {status}).{suffix}"

    return f"Write failed before server confirmation: {error}"


def _as_clean_string(value: object, field_name: str) -> str:
    """Validate and normalize a required non-empty string.

    Parameters
    ----------
    value : object
        Candidate value.
    field_name : str
        Field name for error messages.

    Returns
    -------
    str
        Stripped string value.

    Raises
    ------
    ValueError
        If value is not a non-empty string.
    """
    if not isinstance(value, str) or not value.strip():
        logger.error("Invalid string value for '%s'.", field_name)
        raise ValueError(f"'{field_name}' must be a non-empty string.")
    return value.strip()


def _validate_field_value(key: str, value: object) -> FieldValue:
    """Validate a field value type.

    Parameters
    ----------
    key : str
        Field name.
    value : object
        Field value.

    Returns
    -------
    FieldValue
        Validated value.

    Raises
    ------
    ValueError
        If value type is not supported.
    """
    if not isinstance(value, (str, int, float, bool)):
        logger.error("Unsupported field type for '%s': %s", key, type(value))
        raise ValueError(f"Field '{key}' has unsupported value type: {type(value)}")
    return value


def _normalize_extra_tags(extra_tags: Mapping[str, str] | None) -> dict[str, str]:
    """Normalize and validate extra tag mappings.

    Parameters
    ----------
    extra_tags : Mapping[str, str] | None
        Extra tag mapping.

    Returns
    -------
    dict[str, str]
        Normalized tag mapping.

    Raises
    ------
    ValueError
        If keys collide with reserved keys or contain invalid strings.
    """
    if extra_tags is None:
        return {}

    normalized: dict[str, str] = {}
    for key, value in extra_tags.items():
        clean_key = _as_clean_string(key, "extra tag key")
        if clean_key in RESERVED_KEYS:
            logger.error("Extra tag '%s' conflicts with reserved keys.", clean_key)
            raise ValueError(
                f"Extra tag '{clean_key}' conflicts with built-in keys. "
                "Use built-in arguments/record keys for standard fields."
            )
        normalized[clean_key] = _as_clean_string(value, f"extra tag '{clean_key}'")
    logger.debug("Normalized %d extra tags.", len(normalized))
    return normalized


def _normalize_extra_fields(
    extra_fields: Mapping[str, FieldValue] | None,
) -> dict[str, FieldValue]:
    """Normalize and validate extra field mappings.

    Parameters
    ----------
    extra_fields : Mapping[str, FieldValue] | None
        Extra field mapping.

    Returns
    -------
    dict[str, FieldValue]
        Normalized field mapping.

    Raises
    ------
    ValueError
        If keys collide with reserved keys or values are invalid.
    """
    if extra_fields is None:
        return {}

    normalized: dict[str, FieldValue] = {}
    for key, value in extra_fields.items():
        clean_key = _as_clean_string(key, "extra field key")
        if clean_key in RESERVED_KEYS:
            logger.error("Extra field '%s' conflicts with reserved keys.", clean_key)
            raise ValueError(
                f"Extra field '{clean_key}' conflicts with built-in keys. "
                "Use built-in arguments/record keys for standard fields."
            )
        normalized[clean_key] = _validate_field_value(clean_key, value)
    logger.debug("Normalized %d extra fields.", len(normalized))
    return normalized


def build_point(
    *,
    record: Mapping[str, object],
    default_tags: Mapping[str, str],
    extra_tags: Mapping[str, str] | None,
    extra_fields: Mapping[str, FieldValue] | None,
) -> Point:
    """Build an InfluxDB ``Point`` from one standard record.

    Parameters
    ----------
    record : Mapping[str, object]
        Standard record containing reserved QOI keys only.
    default_tags : Mapping[str, str]
        Tags applied to every point.
    extra_tags : Mapping[str, str] | None
        Optional user-defined tag mapping.
    extra_fields : Mapping[str, FieldValue] | None
        Optional user-defined field mapping.

    Returns
    -------
    Point
        InfluxDB point ready to be written.

    Raises
    ------
    ValueError
        If record contains unknown keys, invalid values, or no field values.
    """
    logger.debug("Building point from record with %d keys.", len(record))

    unknown_keys = [key for key in record if key not in RESERVED_KEYS]
    if unknown_keys:
        unknown = ", ".join(sorted(unknown_keys))
        logger.error("Unknown record keys: %s", unknown)
        raise ValueError(
            f"Unknown record keys: {unknown}. "
            "Use extra_tags / extra_fields for custom keys."
        )

    measurement = _as_clean_string(record.get(QOI_KEY), QOI_KEY)
    point = Point(measurement)

    merged_tags: dict[str, str] = dict(default_tags)
    for key in QOI_TAG_KEYS:
        value = record.get(key)
        if value is None:
            continue
        merged_tags[key] = _as_clean_string(value, key)
    merged_tags.update(_normalize_extra_tags(extra_tags))

    for key, value in merged_tags.items():
        point.tag(key, value)

    has_field = False
    for key in QOI_FIELD_KEYS:
        value = record.get(key)
        if value is None:
            continue
        point.field(key, _validate_field_value(key, value))
        has_field = True

    for key, value in _normalize_extra_fields(extra_fields).items():
        point.field(key, value)
        has_field = True

    if not has_field:
        required = ", ".join(QOI_FIELD_KEYS)
        logger.error("Write rejected: no fields were provided.")
        raise ValueError(
            f"Record must include at least one field value ({required}) "
            "or provide extra_fields."
        )

    return point


class InfluxWriter:
    """Thin wrapper around the InfluxDB client for synchronous writes.

    Parameters
    ----------
    settings : InfluxSettings
        Validated InfluxDB settings.
    """

    def __init__(self, settings: InfluxSettings) -> None:
        """Initialize writer state and client handles.

        Parameters
        ----------
        settings : InfluxSettings
            Validated InfluxDB settings.
        """
        logger.debug(
            "Initializing InfluxWriter for org='%s', bucket='%s'.",
            settings.org,
            settings.bucket,
        )
        self.settings = settings
        self._client = InfluxDBClient(
            url=settings.url,
            token=settings.token,
            org=settings.org,
            timeout=CLIENT_TIMEOUT_MS,
        )
        self._write_api = self._client.write_api(write_options=SYNCHRONOUS)

    @classmethod
    def from_toml(cls, path: str | Path | None = None) -> InfluxWriter:
        """Create a writer from a TOML settings file.

        Parameters
        ----------
        path : str | Path | None, default=None
            Path to settings file. If ``None``, uses ``settings.toml``.

        Returns
        -------
        InfluxWriter
            Initialized writer instance.
        """
        return cls(load_settings(path))

    def write_record(
        self,
        record: Mapping[str, object],
        *,
        extra_tags: Mapping[str, str] | None = None,
        extra_fields: Mapping[str, FieldValue] | None = None,
    ) -> None:
        """Write one standard record.

        Parameters
        ----------
        record : Mapping[str, object]
            Standard record to write.
        extra_tags : Mapping[str, str] | None, default=None
            Extra tags to merge into the point.
        extra_fields : Mapping[str, FieldValue] | None, default=None
            Extra fields to merge into the point.

        Raises
        ------
        ValueError
            If record or extras fail validation.
        """
        point = build_point(
            record=record,
            default_tags=self.settings.default_tags,
            extra_tags=extra_tags,
            extra_fields=extra_fields,
        )
        try:
            self._write_api.write(
                bucket=self.settings.bucket,
                org=self.settings.org,
                record=point,
            )
        except Exception as error:
            message = _friendly_write_error_message(error, self.settings)
            logger.error(message)
            raise InfluxWriteError(message) from None

    def write_point(
        self,
        *,
        qoi: str,
        nominal_value: FieldValue,
        uncertainty: FieldValue | None = None,
        tuid: TUID | None = None,
        element: str | None = None,
        label: str | None = None,
        unit: str | None = None,
        element_label: str | None = None,
        device_ID: str | None = None,
        run_ID: str | None = None,
        cycle_ID: str | None = None,
        condition: str | None = None,
        extra_tags: Mapping[str, str] | None = None,
        extra_fields: Mapping[str, FieldValue] | None = None,
    ) -> None:
        """Write one point using explicit keyword arguments.

        Parameters
        ----------
        qoi : str
            Measurement name.
        nominal_value : FieldValue
            Primary field value.
        uncertainty : FieldValue | None, default=None
            Optional uncertainty field value.
        tuid : TUID | None, default=None
            Optional TUID field value.
        element : str | None, default=None
            Optional ``element`` tag.
        label : str | None, default=None
            Optional ``label`` tag.
        unit : str | None, default=None
            Optional ``unit`` tag.
        element_label : str | None, default=None
            Optional ``element_label`` tag.
        device_ID : str | None, default=None
            Optional ``device_ID`` tag.
        run_ID : str | None, default=None
            Optional ``run_ID`` tag.
        cycle_ID : str | None, default=None
            Optional ``cycle_ID`` tag.
        condition : str | None, default=None
            Optional ``condition`` tag.
        extra_tags : Mapping[str, str] | None, default=None
            Additional user-defined tags.
        extra_fields : Mapping[str, FieldValue] | None, default=None
            Additional user-defined fields.

        Raises
        ------
        ValueError
            If validation fails while building the point.
        """
        record: dict[str, object] = {
            QOI_KEY: qoi,
            "nominal_value": nominal_value,
        }

        optional_tags = {
            "element": element,
            "label": label,
            "unit": unit,
            "element_label": element_label,
            "device_ID": device_ID,
            "run_ID": run_ID,
            "cycle_ID": cycle_ID,
            "condition": condition,
        }
        for key, current_value in optional_tags.items():
            if current_value is not None:
                record[key] = current_value

        optional_fields = {
            "uncertainty": uncertainty,
            "tuid": tuid,
        }
        for key, current_value in optional_fields.items():
            if current_value is not None:
                record[key] = current_value

        self.write_record(
            record,
            extra_tags=extra_tags,
            extra_fields=extra_fields,
        )

    def close(self) -> None:
        """Close the underlying InfluxDB client."""
        self._client.close()

    def __enter__(self) -> InfluxWriter:
        """Return self for context-manager usage.

        Returns
        -------
        InfluxWriter
            Current writer instance.
        """
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Close resources at context-manager exit.

        Parameters
        ----------
        exc_type : type[BaseException] | None
            Exception type if one occurred.
        exc : BaseException | None
            Exception instance if one occurred.
        tb : TracebackType | None
            Exception traceback if one occurred.
        """
        self.close()
