# Copyright Mercurial Contributors
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

from __future__ import annotations

import functools
import os
import stat
import time
from typing import Optional, Tuple

from .. import error


rangemask = 0x7FFFFFFF


@functools.total_ordering
class timestamp(tuple):
    """
    A Unix timestamp with optional nanoseconds precision,
    modulo 2**31 seconds.

    A 3-tuple containing:

    `truncated_seconds`: seconds since the Unix epoch,
    truncated to its lower 31 bits

    `subsecond_nanoseconds`: number of nanoseconds since `truncated_seconds`.
    When this is zero, the sub-second precision is considered unknown.

    `second_ambiguous`: whether this timestamp is still "reliable"
    (see `reliable_mtime_of`) if we drop its sub-second component.
    """

    def __new__(cls, value):
        truncated_seconds, subsec_nanos, second_ambiguous = value
        value = (truncated_seconds & rangemask, subsec_nanos, second_ambiguous)
        return super(timestamp, cls).__new__(cls, value)

    def __eq__(self, other):
        raise error.ProgrammingError(
            'timestamp should never be compared directly'
        )

    def __gt__(self, other):
        raise error.ProgrammingError(
            'timestamp should never be compared directly'
        )


def get_fs_now(vfs) -> Optional[timestamp]:
    """return a timestamp for "now" in the current vfs

    This will raise an exception if no temporary files could be created.
    """
    tmpfd, tmpname = vfs.mkstemp()
    try:
        return mtime_of(os.fstat(tmpfd))
    finally:
        os.close(tmpfd)
        vfs.unlink(tmpname)


def zero() -> timestamp:
    """
    Returns the `timestamp` at the Unix epoch.
    """
    return tuple.__new__(timestamp, (0, 0))


def mtime_of(stat_result: os.stat_result) -> timestamp:
    """
    Takes an `os.stat_result`-like object and returns a `timestamp` object
    for its modification time.
    """
    try:
        # TODO: add this attribute to `osutil.stat` objects,
        # see `mercurial/cext/osutil.c`.
        #
        # This attribute is also not available on Python 2.
        nanos = stat_result.st_mtime_ns
    except AttributeError:
        # https://docs.python.org/2/library/os.html#os.stat_float_times
        # "For compatibility with older Python versions,
        #  accessing stat_result as a tuple always returns integers."
        secs = stat_result[stat.ST_MTIME]

        subsec_nanos = 0
    else:
        billion = int(1e9)
        secs = nanos // billion
        subsec_nanos = nanos % billion

    return timestamp((secs, subsec_nanos, False))


def reliable_mtime_of(
    stat_result: os.stat_result, present_mtime: timestamp
) -> Optional[timestamp]:
    """Wrapper for `make_mtime_reliable` for stat objects"""
    file_mtime = mtime_of(stat_result)
    return make_mtime_reliable(file_mtime, present_mtime)


def make_mtime_reliable(
    file_timestamp: timestamp, present_mtime: timestamp
) -> Optional[timestamp]:
    """Same as `mtime_of`, but return `None` or a `Timestamp` with
    `second_ambiguous` set if the date might be ambiguous.

    A modification time is reliable if it is older than "present_time" (or
    sufficiently in the future).

    Otherwise a concurrent modification might happens with the same mtime.
    """
    file_second = file_timestamp[0]
    file_ns = file_timestamp[1]
    boundary_second = present_mtime[0]
    boundary_ns = present_mtime[1]
    # If the mtime of the ambiguous file is younger (or equal) to the starting
    # point of the `status` walk, we cannot garantee that another, racy, write
    # will not happen right after with the same mtime and we cannot cache the
    # information.
    #
    # However if the mtime is far away in the future, this is likely some
    # mismatch between the current clock and previous file system operation. So
    # mtime more than one days in the future are considered fine.
    if boundary_second == file_second:
        if file_ns and boundary_ns:
            if file_ns < boundary_ns:
                return timestamp((file_second, file_ns, True))
        return None
    elif boundary_second < file_second < (3600 * 24 + boundary_second):
        return None
    else:
        return file_timestamp


FS_TICK_WAIT_TIMEOUT = 0.1  # 100 milliseconds


def wait_until_fs_tick(vfs) -> Optional[Tuple[timestamp, bool]]:
    """Wait until the next update from the filesystem time by writing in a loop
    a new temporary file inside the working directory and checking if its time
    differs from the first one observed.

    Returns `None` if we are unable to get the filesystem time,
    `(timestamp, True)` if we've timed out waiting for the filesystem clock
    to tick, and `(timestamp, False)` if we've waited successfully.

    On Linux, your average tick is going to be a "jiffy", or 1/HZ.
    HZ is your kernel's tick rate (if it has one configured) and the value
    is the one returned by `grep 'CONFIG_HZ=' /boot/config-$(uname -r)`,
    again assuming a normal setup.

    In my case (Alphare) at the time of writing, I get `CONFIG_HZ=250`,
    which equates to 4ms.
    This might change with a series that could make it to Linux 6.12:
    https://lore.kernel.org/all/20241002-mgtime-v10-8-d1c4717f5284@kernel.org
    """
    start = time.monotonic()

    try:
        old_fs_time = get_fs_now(vfs)
        new_fs_time = get_fs_now(vfs)

        while (
            new_fs_time[0] == old_fs_time[0]
            and new_fs_time[1] == old_fs_time[1]
        ):
            if time.monotonic() - start > FS_TICK_WAIT_TIMEOUT:
                return (old_fs_time, True)
            new_fs_time = get_fs_now(vfs)
    except OSError:
        return None
    else:
        return (new_fs_time, False)
