from __future__ import annotations

import datetime as dt
import fcntl
import getpass
import os
import socket
import tempfile
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple

import yaml

from schedint.logging_config import get_logger

logger = get_logger("core.storage")

ISO_Z = "%Y-%m-%dT%H:%M:%SZ"


def _format_utc(time: Optional[dt.datetime]) -> Optional[str]:
    """
    TODO: Write tests
    """
    if time is None:
        return None
    if time.tzinfo is None:
        time = time.replace(tzinfo=dt.timezone.utc)
    return time.astimezone(dt.timezone.utc).strftime(ISO_Z)


def _parse_utc(time: Optional[str]) -> Optional[dt.datetime]:
    if time is None:
        return None

    if isinstance(time, dt.datetime):
        if time.tzinfo is None:
            return time.replace(tzinfo=dt.timezone.utc)
        return time.astimezone(dt.timezone.utc)

    if not isinstance(time, str):
        raise ValueError(
            f"Timestamp must be a string or null, got {type(time)}"
        )

    return dt.datetime.strptime(time, ISO_Z).replace(tzinfo=dt.timezone.utc)


def _now_utc() -> dt.datetime:
    return dt.datetime.now(dt.timezone.utc)


@dataclass(frozen=True)
class OverrideWindow:
    value: Any
    start: Optional[dt.datetime]
    end: Optional[dt.datetime]


@dataclass(frozen=True)
class Request:
    request_id: str
    source: str
    submitted_at: Optional[dt.datetime]
    submitted_by: Optional[str]
    submitted_from_host: Optional[str]
    reason: Optional[str]
    overrides: Dict[str, OverrideWindow]


@dataclass(frozen=True)
class OverridesFile:
    schema_version: int
    file_metadata: Dict[str, Any]
    requests: List[Request]
    cadence_multiplier: float = 1.0


class OverridesFormatError(Exception):
    pass


class RequestNotFoundError(Exception):
    pass


class AmbiguousRequestIdError(Exception):
    pass


def load_state(path: str) -> OverridesFile:
    """ """
    logger.debug("Loading overrides from %s", path)

    with open(path, "r", encoding="utf-8") as this_file:
        data = yaml.safe_load(this_file)

    if not isinstance(data, dict):
        raise OverridesFormatError("Top level YAML must be a mapping/dict")

    schema_version = data.get("schema_version")
    if schema_version != 1:
        raise OverridesFormatError(
            f"Unsupported schema version: {schema_version!r}"
        )

    file_metadata = data.get("file_metadata") or {}
    if not isinstance(file_metadata, dict):
        raise OverridesFormatError("file_metadata must be a mapping/dict")

    cadence_multiplier_raw = data.get("cadence_multiplier", None)
    if cadence_multiplier_raw is None:
        cadence_multiplier = 1.0
    else:
        if not isinstance(cadence_multiplier_raw, (int, float)):
            raise OverridesFormatError("cadence_multiplier must be a number")
        cadence_multiplier = float(cadence_multiplier_raw)

    if cadence_multiplier <= 0:
        raise OverridesFormatError("cadence_multiplier must be > 0")

    reqs = data.get("requests")
    if reqs is None:
        reqs = []
    if not isinstance(reqs, list):
        raise OverridesFormatError("requests must be a list")

    parsed: List[Request] = []
    for request_index, this_request in enumerate(reqs):
        if not isinstance(this_request, dict):
            raise OverridesFormatError(
                f"requests[{request_index}] must be a mapping/dict"
            )

        rid = this_request.get("request_id")
        src = this_request.get("source")
        if not isinstance(rid, str) or not rid:
            raise OverridesFormatError(
                f"requests[{request_index}].request_id must be a non-empty string"
            )
        if not isinstance(src, str) or not src:
            raise OverridesFormatError(
                f"requests[{request_index}].source must be a non-empty string"
            )

        # Validate UUID format
        try:
            uuid.UUID(rid)
        except Exception:
            raise OverridesFormatError(
                f"requests[{request_index}].request_id is not a valid UUID: {rid!r}"
            )

        # Get request metadata
        submitted_at = _parse_utc(this_request.get("submitted_at"))
        submitted_by = this_request.get("submitted_by")
        submitted_from_host = this_request.get("submitted_from_host")
        reason = this_request.get("reason")

        # Get override parameters for request
        overrides_raw = this_request.get("overrides")
        if not isinstance(overrides_raw, dict):
            raise OverridesFormatError(
                f"requests[{request_index}].overrides must be a mapping/dict"
            )

        overrides: Dict[str, OverrideWindow] = {}
        for key, block in overrides_raw.items():
            if not isinstance(block, dict):
                raise OverridesFormatError(
                    f"requests[{request_index}].overrides.{key} must be a mapping/dict"
                )

            val = block.get("value", None)
            start = _parse_utc(block.get("start_time"))
            end = _parse_utc(block.get("end_time"))

            if (start is None) ^ (end is None):
                raise OverridesFormatError(
                    f"requests[{request_index}].overrides.{key} must set both start and end times"
                )

            if start is not None and end is not None and end <= start:
                raise OverridesFormatError(
                    f"requests[{request_index}].overrides.{key} end_time must be after start_time"
                )

            overrides[key] = OverrideWindow(value=val, start=start, end=end)

        parsed.append(
            Request(
                request_id=rid,
                source=src,
                submitted_at=submitted_at,
                submitted_by=(
                    submitted_by if isinstance(submitted_by, str) else None
                ),
                submitted_from_host=(
                    submitted_from_host
                    if isinstance(submitted_from_host, str)
                    else None
                ),
                reason=reason if isinstance(reason, str) else None,
                overrides=overrides,
            )
        )

    return OverridesFile(
        schema_version=1,
        file_metadata=file_metadata,
        requests=parsed,
        cadence_multiplier=cadence_multiplier,
    )


def iter_override_windows(
    req: Request,
) -> Iterable[Tuple[str, OverrideWindow]]:

    for key, override_window in req.overrides.items():
        yield key, override_window


def request_is_active(
    req: Request, when: Optional[dt.datetime] = None
) -> bool:

    when = when or _now_utc()
    for _, override_window in iter_override_windows(req):

        if (
            override_window.start
            and override_window.end
            and (override_window.start <= when < override_window.end)
            and override_window.value is not None
        ):
            return True

    return False


def request_is_expired(
    req: Request, when: Optional[dt.datetime] = None
) -> bool:

    when = when or _now_utc()

    if request_is_active(req, when=when):
        return False

    for _, override_window in iter_override_windows(req):

        if (
            override_window.end
            and override_window.end <= when
            and override_window.value is not None
        ):
            return True

    return False


def filter_requests(
    state: OverridesFile,
    *,
    request_id: Optional[str] = None,
    source: Optional[str] = None,
    submitted_by: Optional[str] = None,
    active: Optional[bool] = None,
    expired: Optional[bool] = None,
    between: Optional[Tuple[dt.datetime, dt.datetime]] = None,
    when: Optional[dt.datetime] = None,
) -> List[Request]:

    when = when or _now_utc()

    out: List[Request] = []

    for this_request in state.requests:

        if request_id and this_request.request_id != request_id:
            continue

        if source and this_request.source != source:
            continue

        if submitted_by and this_request.submitted_by != submitted_by:
            continue

        if active is not None:
            is_active = request_is_active(this_request, when=when)
            if is_active != active:
                continue

        if expired is not None:
            is_expired = request_is_expired(this_request, when=when)
            if is_expired != expired:
                continue

        if between is not None:
            start, end = between
            if this_request.submitted_at is None:
                continue
            if not (start <= this_request.submitted_at <= end):
                continue

        out.append(this_request)

    return out


def dump_state(state: OverridesFile) -> str:
    """
    Serialise OverridesFile back to YAML matching the schema that load_state expects
    """

    def override_window_to_dict(
        override_window: OverrideWindow,
    ) -> Dict[str, Any]:

        out: Dict[str, Any] = {"value": override_window.value}

        if (
            override_window.start is not None
            and override_window.end is not None
        ):
            out["start_time"] = _format_utc(override_window.start)
            out["end_time"] = _format_utc(override_window.end)
        else:
            out["start_time"] = None
            out["end_time"] = None
        return out

    def request_to_dict(req: Request) -> Dict[str, Any]:
        return {
            "request_id": req.request_id,
            "source": req.source,
            "submitted_at": _format_utc(req.submitted_at),
            "submitted_by": req.submitted_by,
            "submitted_from_host": req.submitted_from_host,
            "reason": req.reason,
            "overrides": {
                key: override_window_to_dict(override_window)
                for key, override_window in req.overrides.items()
            },
        }

    payload: Dict[str, Any] = {
        "schema_version": state.schema_version,
        "file_metadata": state.file_metadata or {},
        "cadence_multiplier": float(state.cadence_multiplier),
        "requests": [request_to_dict(r) for r in state.requests],
    }

    return yaml.safe_dump(payload, sort_keys=False, default_flow_style=False)


def _atomic_write_text(path: str, text: str) -> None:
    """
    Atomic write: write temp file in same dir, fsync and then os.replace
    """

    # Create temp file in same dir as overrides file
    directory = os.path.dirname(path) or "."
    os.makedirs(directory, exist_ok=True)

    fd: Optional[int] = None
    tmp_name: Optional[str] = None

    try:
        # Create a temp file in that directory. This will create something like
        # overrides.yaml.tmp.abcd1234
        fd, tmp_name = tempfile.mkstemp(
            prefix=os.path.basename(path) + ".tmp", dir=directory, text=True
        )

        # Write and fsync the temp file
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            # Write to python's buffer
            f.write(text)
            # Push python's buffer to the OS
            f.flush()
            # Force the OS to push the data to disk
            os.fsync(f.fileno())

        # Atomic replacement
        os.replace(tmp_name, path)

        # Fsync the directory
        try:
            dfd = os.open(directory, os.O_DIRECTORY)
            try:
                os.fsync(dfd)
            finally:
                os.close(dfd)
        except OSError:
            pass

    # Cleanup safely
    finally:
        if tmp_name and os.path.exists(tmp_name):
            try:
                os.unlink(tmp_name)
            except OSError:
                pass


@contextmanager
def _file_lock(lock_path: str):
    """
    Exclusive lock using separate lock file
    """
    lock_dir = os.path.dirname(lock_path) or "."
    os.makedirs(lock_dir, exist_ok=True)

    fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o640)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX)
        yield
    finally:
        try:
            fcntl.flock(fd, fcntl.LOCK_UN)
        finally:
            os.close(fd)


def build_request(
    *,
    source: str,
    overrides: Dict[str, OverrideWindow],
    submitted_by: Optional[str] = None,
    submitted_from_host: Optional[str] = None,
    reason: Optional[str] = None,
    request_id: Optional[str] = None,
    submitted_at: Optional[dt.datetime] = None,
) -> Request:
    """
    Construct a request object for submission
    """
    rid = request_id or str(uuid.uuid4())
    sat = submitted_at or _now_utc()
    host = submitted_from_host or socket.gethostname()
    user = submitted_by or getpass.getuser()

    for key, ow in overrides.items():
        if (ow.start is None) ^ (ow.end is None):
            raise ValueError(
                f"Override {key!r} must set both start and end times"
            )
        if ow.start is not None and ow.end is not None and ow.end <= ow.start:
            raise ValueError(
                f"Override {key!r} end time must be after start time"
            )

    return Request(
        request_id=rid,
        source=source,
        submitted_by=user,
        submitted_at=sat,
        submitted_from_host=host,
        reason=reason,
        overrides=overrides,
    )


def append_request_atomic(
    path: str,
    new_request: Request,
    *,
    overlap_check,
) -> OverridesFile:
    """
    Load current overrides and assert no overlap for new request's
    effective window, append request and atomically write back
    """

    # Local import to avoid circular import
    from schedint.core.validate import request_effective_window

    lock_path = path + ".lock"

    # Compute effective window for new request
    win = request_effective_window(new_request)

    with _file_lock(lock_path):
        state = load_state(path)

        if win is not None:
            start, end = win
            overlap_check(
                state, source=new_request.source, start=start, end=end
            )

        updated = OverridesFile(
            schema_version=state.schema_version,
            file_metadata=state.file_metadata,
            requests=[*state.requests, new_request],
            cadence_multiplier=state.cadence_multiplier,
        )

        _atomic_write_text(path, dump_state(updated))
        return updated


def set_cadence_multiplier_atomic(path: str, value: float) -> OverridesFile:
    if not isinstance(value, (int, float)):
        raise ValueError("cadence multiplier must be a number")
    value = float(value)

    if value <= 0:
        raise ValueError("cadence multipler must be > 0")

    lock_path = path + ".lock"
    with _file_lock(lock_path):
        state = load_state(path)
        updated = OverridesFile(
            schema_version=state.schema_version,
            file_metadata=state.file_metadata,
            requests=state.requests,
            cadence_multiplier=value,
        )
    _atomic_write_text(path, dump_state(updated))
    return updated


def delete_request_by_prefix_atomic(path: str, prefix: str) -> Request:

    if not prefix or not isinstance(prefix, str):
        raise ValueError("Prefix must be a non-empty string")

    if len(prefix) < 6:
        raise ValueError(
            "Please provide at least 6 characters of the request id"
        )

    lock_path = path + ".lock"

    with _file_lock(lock_path):
        state = load_state(path)

        matches = [
            r for r in state.requests if r.request_id.startswith(prefix)
        ]

        if not matches:
            raise RequestNotFoundError(f"No request id starts with {prefix!r}")

        if len(matches) > 1:
            ids = ", ".join(r.request_id for r in matches[:10])
            extra = (
                "" if len(matches) <= 10 else f" (and {len(matches)-10} more)"
            )
            raise AmbiguousRequestIdError(
                f"Prefix {prefix!r} matches {len(matches)} requests: {ids}{extra}"
            )

        victim = matches[0]

        new_requests = [
            r for r in state.requests if r.request_id != victim.request_id
        ]
        updated = OverridesFile(
            schema_version=state.schema_version,
            file_metadata=state.file_metadata,
            requests=new_requests,
            cadence_multiplier=state.cadence_multiplier,
        )

        _atomic_write_text(path, dump_state(updated))
        return victim
