fix: preserve ambiguous local times in chrono conversions (#6202)

* fix: preserve ambiguous local times in chrono conversions

* fix: use GraalPy-compatible time zones in chrono tests

* fix: satisfy clang-tidy in chrono conversions

* test: isolate timezone changes on older GraalPy

* test: skip timezone changes on older GraalPy
diff --git a/docs/advanced/cast/chrono.rst b/docs/advanced/cast/chrono.rst
index fbd4605..7a924dc 100644
--- a/docs/advanced/cast/chrono.rst
+++ b/docs/advanced/cast/chrono.rst
@@ -47,7 +47,9 @@
 - ``std::chrono::system_clock::time_point`` → ``datetime.datetime``
     System clock times are converted to python datetime instances. They are
     in the local timezone, but do not have any timezone information attached
-    to them (they are naive datetime objects).
+    to them (they are naive datetime objects). The ``fold`` attribute
+    distinguishes the two occurrences of an ambiguous local time when
+    clocks move backward.
 
 - ``std::chrono::duration`` → ``datetime.timedelta``
     Durations are converted to timedeltas, any precision in the duration
@@ -62,7 +64,10 @@
 - ``datetime.datetime`` or ``datetime.date`` or ``datetime.time`` → ``std::chrono::system_clock::time_point``
     Date/time objects are converted into system clock timepoints. Any
     timezone information is ignored and the type is treated as a naive
-    object.
+    object in the local timezone. The ``fold`` attribute selects the earlier
+    (``0``) or later (``1``) occurrence of an ambiguous local time.
+    Nonexistent local times when clocks move forward follow Python's
+    ``datetime.timestamp()`` behavior and cannot round-trip unchanged.
 
 - ``datetime.timedelta`` → ``std::chrono::duration``
     Time delta are converted into durations with microsecond precision.
diff --git a/include/pybind11/chrono.h b/include/pybind11/chrono.h
index 668e458..84c9aef 100644
--- a/include/pybind11/chrono.h
+++ b/include/pybind11/chrono.h
@@ -16,7 +16,6 @@
 #include <cmath>
 #include <ctime>
 #include <datetime.h>
-#include <mutex>
 
 PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)
 PYBIND11_NAMESPACE_BEGIN(detail)
@@ -101,22 +100,6 @@
     PYBIND11_TYPE_CASTER(type, const_name("datetime.timedelta"));
 };
 
-inline std::tm *localtime_thread_safe(const std::time_t *time, std::tm *buf) {
-#if (defined(__STDC_LIB_EXT1__) && defined(__STDC_WANT_LIB_EXT1__)) || defined(_MSC_VER)
-    if (localtime_s(buf, time))
-        return nullptr;
-    return buf;
-#else
-    static std::mutex mtx;
-    std::lock_guard<std::mutex> lock(mtx);
-    std::tm *tm_ptr = std::localtime(time);
-    if (tm_ptr != nullptr) {
-        *buf = *tm_ptr;
-    }
-    return tm_ptr;
-#endif
-}
-
 // This is for casting times on the system clock into datetime.datetime instances
 template <typename Duration>
 class type_caster<std::chrono::time_point<std::chrono::system_clock, Duration>> {
@@ -134,8 +117,9 @@
             return false;
         }
 
-        std::tm cal;
+        std::tm cal{};
         microseconds msecs;
+        int fold = 0;
 
         if (PyDateTime_Check(src.ptr())) {
             cal.tm_sec = PyDateTime_DATE_GET_SECOND(src.ptr());
@@ -146,6 +130,7 @@
             cal.tm_year = PyDateTime_GET_YEAR(src.ptr()) - 1900;
             cal.tm_isdst = -1;
             msecs = microseconds(PyDateTime_DATE_GET_MICROSECOND(src.ptr()));
+            fold = PyDateTime_DATE_GET_FOLD(src.ptr());
         } else if (PyDate_Check(src.ptr())) {
             cal.tm_sec = 0;
             cal.tm_min = 0;
@@ -164,11 +149,41 @@
             cal.tm_year = 70; // earliest available date for Python's datetime
             cal.tm_isdst = -1;
             msecs = microseconds(PyDateTime_TIME_GET_MICROSECOND(src.ptr()));
+            fold = PyDateTime_TIME_GET_FOLD(src.ptr());
         } else {
             return false;
         }
 
-        value = time_point_cast<Duration>(system_clock::from_time_t(std::mktime(&cal)) + msecs);
+#if defined(_WIN32)
+        // Python's fold detection probes up to a day before the timestamp. Windows
+        // cannot represent those probes during the first day after the epoch, and
+        // datetime.fromtimestamp() also skips fold detection there.
+        if (cal.tm_year == 70 && cal.tm_mon == 0 && cal.tm_mday <= 2) {
+            std::tm cal_copy = cal;
+            const std::time_t tt = std::mktime(&cal_copy);
+            if (tt >= 0 && tt <= 86400) {
+                value = time_point_cast<Duration>(system_clock::from_time_t(tt) + msecs);
+                return true;
+            }
+        }
+#endif
+
+        // Use Python's local-time disambiguation, preserving fold while ignoring
+        // tzinfo and datetime subclass overrides. Keep microseconds separate to
+        // avoid losing precision through timestamp()'s floating-point result.
+        auto dt = reinterpret_steal<object>(PyDateTime_FromDateAndTimeAndFold(cal.tm_year + 1900,
+                                                                              cal.tm_mon + 1,
+                                                                              cal.tm_mday,
+                                                                              cal.tm_hour,
+                                                                              cal.tm_min,
+                                                                              cal.tm_sec,
+                                                                              0,
+                                                                              fold));
+        if (!dt) {
+            throw error_already_set();
+        }
+        const auto tt = int_(dt.attr("timestamp")()).cast<std::time_t>();
+        value = time_point_cast<Duration>(system_clock::from_time_t(tt) + msecs);
         return true;
     }
 
@@ -196,18 +211,20 @@
         std::time_t tt
             = system_clock::to_time_t(time_point_cast<system_clock::duration>(src - us));
 
-        std::tm localtime;
-        std::tm *localtime_ptr = localtime_thread_safe(&tt, &localtime);
-        if (!localtime_ptr) {
-            throw cast_error("Unable to represent system_clock in local time");
+        // Python determines the local calendar fields and fold from whole seconds.
+        auto args = make_tuple(tt);
+        auto dt = reinterpret_steal<object>(PyDateTime_FromTimestamp(args.ptr()));
+        if (!dt) {
+            throw error_already_set();
         }
-        return PyDateTime_FromDateAndTime(localtime.tm_year + 1900,
-                                          localtime.tm_mon + 1,
-                                          localtime.tm_mday,
-                                          localtime.tm_hour,
-                                          localtime.tm_min,
-                                          localtime.tm_sec,
-                                          us.count());
+        return PyDateTime_FromDateAndTimeAndFold(PyDateTime_GET_YEAR(dt.ptr()),
+                                                 PyDateTime_GET_MONTH(dt.ptr()),
+                                                 PyDateTime_GET_DAY(dt.ptr()),
+                                                 PyDateTime_DATE_GET_HOUR(dt.ptr()),
+                                                 PyDateTime_DATE_GET_MINUTE(dt.ptr()),
+                                                 PyDateTime_DATE_GET_SECOND(dt.ptr()),
+                                                 us.count(),
+                                                 PyDateTime_DATE_GET_FOLD(dt.ptr()));
     }
     PYBIND11_TYPE_CASTER(type, const_name("datetime.datetime"));
 };
diff --git a/tests/test_chrono.cpp b/tests/test_chrono.cpp
index 8be0ffd..efaa975 100644
--- a/tests/test_chrono.cpp
+++ b/tests/test_chrono.cpp
@@ -44,6 +44,15 @@
     // Round trip the passed in system clock time
     m.def("test_chrono2", [](system_time t) { return t; });
 
+    // Check each direction independently, without an ambiguous local datetime
+    // hiding which instant the system-clock caster selected.
+    using timestamp_us
+        = std::chrono::time_point<std::chrono::system_clock, std::chrono::microseconds>;
+    m.def("test_chrono_system_clock_as_us",
+          [](timestamp_us t) { return t.time_since_epoch().count(); });
+    m.def("test_chrono_system_clock_from_us",
+          [](int64_t us) { return timestamp_us(std::chrono::microseconds(us)); });
+
     // test_chrono_duration_roundtrip
     // Round trip the passed in duration
     m.def("test_chrono3", [](std::chrono::system_clock::duration d) { return d; });
diff --git a/tests/test_chrono.py b/tests/test_chrono.py
index ed889fb..db6416f 100644
--- a/tests/test_chrono.py
+++ b/tests/test_chrono.py
@@ -1,10 +1,12 @@
 from __future__ import annotations
 
 import datetime
+import sys
+import time
 
 import pytest
 
-import env  # noqa: F401
+import env
 from pybind11_tests import chrono as m
 
 
@@ -43,6 +45,14 @@
     assert diff == datetime.timedelta(0)
 
 
+@pytest.mark.parametrize("day", [1, 2])
+def test_chrono_system_clock_roundtrip_epoch(day):
+    # Python's fold-aware naive timestamp implementation probes the previous
+    # day, which Windows cannot represent before the epoch.
+    value = datetime.datetime(1970, 1, day, 12)
+    assert m.test_chrono2(value) == value
+
+
 def test_chrono_system_clock_roundtrip_date():
     date1 = datetime.date.today()
 
@@ -72,6 +82,198 @@
     assert time2.microsecond == 0
 
 
+@pytest.fixture
+def local_timezone(monkeypatch):
+    if not hasattr(time, "tzset"):
+        pytest.skip("Changing the local timezone requires time.tzset")
+    if env.GRAALPY and sys.graalpy_version_info < (25, 1):
+        pytest.skip("GraalPy before 25.1 does not update its timezone after tzset")
+    try:
+        with monkeypatch.context() as patch:
+
+            def set_timezone(tz):
+                patch.setenv("TZ", tz)
+                time.tzset()
+
+            yield set_timezone
+    finally:
+        time.tzset()
+
+
+def epoch_us(utc):
+    delta = utc - datetime.datetime(1970, 1, 1)
+    return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds
+
+
+# POSIX rules avoid a dependency on the host's IANA timezone data. GraalPy's
+# Java timezone parser requires named zones instead of POSIX rule strings.
+PACIFIC_TZ = "America/Los_Angeles" if env.GRAALPY else "PST8PDT,M3.2.0/2,M11.1.0/2"
+HALF_HOUR_TZ = (
+    "Australia/Lord_Howe" if env.GRAALPY else "XST-10:30XDT-11,M10.1.0/2,M4.1.0/2"
+)
+
+
+DST_CASES = [
+    pytest.param(
+        PACIFIC_TZ,
+        datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=0),
+        datetime.timedelta(hours=-7),
+        0,
+        id="fall-first",
+    ),
+    pytest.param(
+        PACIFIC_TZ,
+        datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=1),
+        datetime.timedelta(hours=-8),
+        0,
+        id="fall-second",
+    ),
+    pytest.param(
+        PACIFIC_TZ,
+        datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=0),
+        datetime.timedelta(hours=-8),
+        1,
+        id="spring-gap-first",
+    ),
+    pytest.param(
+        PACIFIC_TZ,
+        datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=1),
+        datetime.timedelta(hours=-7),
+        -1,
+        id="spring-gap-second",
+    ),
+    pytest.param(
+        HALF_HOUR_TZ,
+        datetime.datetime(2026, 4, 5, 1, 45, 42, 123456, fold=0),
+        datetime.timedelta(hours=11),
+        0,
+        id="half-hour-first",
+    ),
+    pytest.param(
+        HALF_HOUR_TZ,
+        datetime.datetime(2026, 4, 5, 1, 45, 42, 123456, fold=1),
+        datetime.timedelta(hours=10, minutes=30),
+        0,
+        id="half-hour-second",
+    ),
+]
+
+
+@pytest.mark.parametrize(("tz", "local", "offset", "_gap_hours"), DST_CASES)
+def test_chrono_system_clock_load_dst(tz, local, offset, _gap_hours, local_timezone):
+    local_timezone(tz)
+    assert m.test_chrono_system_clock_as_us(local) == epoch_us(local - offset)
+
+
+@pytest.mark.parametrize(("tz", "local", "offset", "gap_hours"), DST_CASES)
+def test_chrono_system_clock_cast_dst(tz, local, offset, gap_hours, local_timezone):
+    local_timezone(tz)
+    value = epoch_us(local - offset)
+    # Use known wall times rather than Python's conversion as the oracle, so
+    # an unsupported TZ cannot make both conversions silently agree in UTC.
+    expected = (
+        (local + datetime.timedelta(hours=gap_hours)).replace(fold=0)
+        if gap_hours
+        else local
+    )
+    result = m.test_chrono_system_clock_from_us(value)
+    assert result == expected
+    # datetime equality ignores fold when comparing naive values.
+    assert result.fold == expected.fold
+    assert result.tzinfo is None
+
+
+@pytest.mark.parametrize("fold", [0, 1])
+def test_chrono_system_clock_non_dst_fold(fold, local_timezone):
+    local_timezone("Europe/Kyiv")
+    first = epoch_us(datetime.datetime(1990, 6, 30, 21, 30))
+    second = first + 3600_000_000
+    # Both sides of this political offset change are daylight time. Skip if
+    # the system lacks the corresponding IANA timezone data.
+    for value in (first, second):
+        parts = time.localtime(value // 1_000_000)
+        if parts[:6] != (1990, 7, 1, 1, 30, 0) or parts.tm_isdst != 1:
+            pytest.skip("Europe/Kyiv's 1990 offset change is unavailable")
+    local = datetime.datetime(1990, 7, 1, 1, 30, fold=fold)
+    value = first if fold == 0 else second
+    assert m.test_chrono_system_clock_as_us(local) == value
+    result = m.test_chrono_system_clock_from_us(value)
+    assert result == local
+    assert result.fold == fold
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        -1_000_001,
+        -1,
+        0,
+        1,
+        1_000_001,
+        epoch_us(datetime.datetime(1900, 1, 1, 0, 0, 0, 123456)),
+        epoch_us(datetime.datetime(2200, 1, 1)),
+        epoch_us(datetime.datetime(2250, 1, 1, 0, 0, 0, 123456)),
+    ],
+)
+def test_chrono_system_clock_microseconds(value, local_timezone):
+    local_timezone("UTC0")
+    try:
+        datetime.datetime.fromtimestamp(value // 1_000_000)
+    except (OverflowError, OSError):
+        pytest.skip("Timestamp is outside the platform's supported range")
+    expected = datetime.datetime(1970, 1, 1) + datetime.timedelta(microseconds=value)
+    assert m.test_chrono_system_clock_as_us(expected) == value
+    assert m.test_chrono_system_clock_from_us(value) == expected
+
+
+class DatetimeWithOverriddenTimestamp(datetime.datetime):
+    def timestamp(self):
+        raise AssertionError("The system-clock caster should read datetime fields")
+
+
+@pytest.mark.parametrize(
+    ("source", "expected"),
+    [
+        (datetime.date(2026, 11, 1), datetime.datetime(2026, 11, 1)),
+        (
+            datetime.time(12, 34, 56, 123456),
+            datetime.datetime(1970, 1, 1, 12, 34, 56, 123456),
+        ),
+        (
+            datetime.time(
+                12,
+                34,
+                56,
+                123456,
+                tzinfo=datetime.timezone(datetime.timedelta(hours=12)),
+                fold=1,
+            ),
+            datetime.datetime(1970, 1, 1, 12, 34, 56, 123456),
+        ),
+        (
+            datetime.datetime(
+                2026,
+                11,
+                1,
+                1,
+                30,
+                42,
+                123456,
+                tzinfo=datetime.timezone(datetime.timedelta(hours=12)),
+            ),
+            datetime.datetime(2026, 11, 1, 1, 30, 42, 123456),
+        ),
+        (
+            DatetimeWithOverriddenTimestamp(2026, 11, 1, 1, 30, 42, 123456),
+            datetime.datetime(2026, 11, 1, 1, 30, 42, 123456),
+        ),
+    ],
+)
+def test_chrono_system_clock_input_fields(source, expected, local_timezone):
+    local_timezone("UTC0")
+    assert m.test_chrono_system_clock_as_us(source) == epoch_us(expected)
+
+
 SKIP_TZ_ENV_ON_WIN = pytest.mark.skipif(
     "env.WIN", reason="TZ environment variable only supported on POSIX"
 )
@@ -99,9 +301,10 @@
         pytest.param("America/New_York", marks=SKIP_TZ_ENV_ON_WIN),
     ],
 )
-def test_chrono_system_clock_roundtrip_time(time1, tz, monkeypatch):
+def test_chrono_system_clock_roundtrip_time(time1, tz, request):
     if tz is not None:
-        monkeypatch.setenv("TZ", f"/usr/share/zoneinfo/{tz}")
+        local_tz = tz if env.GRAALPY else f"/usr/share/zoneinfo/{tz}"
+        request.getfixturevalue("local_timezone")(local_tz)
 
     # Roundtrip the time
     datetime2 = m.test_chrono2(time1)