=pod

=encoding UTF-8

=head1 NAME

Time::Str::Util - Binary search and time zone database utilities

=head1 SYNOPSIS

    use Time::Str::Util qw( find_tzdb_directory
                            valid_tzdb_timezone valid_posix_timezone
                            binary_search lower_bound upper_bound range_bounds );

    # Locate system zoneinfo directory
    my $tzdir = find_tzdb_directory();  # /usr/share/zoneinfo or undef

    # Validate timezone names
    valid_tzdb_timezone('America/New_York');          # true
    valid_posix_timezone('EST5EDT,M3.2.0,M11.1.0');   # true

    my @sorted = (10, 20, 30, 40, 50);

    # Membership test
    my $found = binary_search(\@sorted, 30);  # true
    my $miss  = binary_search(\@sorted, 25);  # false

    my $i = lower_bound(\@sorted, 25);  # 2 (first element >= 25)
    my $j = upper_bound(\@sorted, 30);  # 3 (first element >  30)

    # With optional bounds
    my $k = lower_bound(\@sorted, 25, 1, 4);  # search within [1, 4)

    # Range query: indices of elements in [15, 35]
    my ($lo, $hi) = range_bounds(\@sorted, 15, 35);  # (1, 3)

=head1 DESCRIPTION

This module provides binary search functions for sorted integer arrays
and utilities for locating the system's IANA Time Zone Database.
All functions are exportable on request. Use C<:all> to import
everything.

=head1 FUNCTIONS

=head2 Binary Search

=head3 binary_search

    my $bool = binary_search($arrayref, $value);
    my $bool = binary_search($arrayref, $value, $lo, $hi);

Returns true if C<$value> is present in the sorted array, false
otherwise.

Optional C<$lo> and C<$hi> parameters restrict the search to the
half-open range C<[$lo, $hi)>. Defaults to C<[0, length)>.


=head3 lower_bound

    my $index = lower_bound($arrayref, $value);
    my $index = lower_bound($arrayref, $value, $lo, $hi);

Returns the index of the first element in the sorted array that is
greater than or equal to C<$value>. If all elements are less than
C<$value>, returns the length of the array (one past the last index).

Optional C<$lo> and C<$hi> parameters restrict the search to the
half-open range C<[$lo, $hi)>. Defaults to C<[0, length)>.

=head3 upper_bound

    my $index = upper_bound($arrayref, $value);
    my $index = upper_bound($arrayref, $value, $lo, $hi);

Returns the index of the first element in the sorted array that is
strictly greater than C<$value>. If all elements are less than or
equal to C<$value>, returns the length of the array.

Optional C<$lo> and C<$hi> parameters restrict the search to the
half-open range C<[$lo, $hi)>. Defaults to C<[0, length)>.

=head3 range_bounds

    my ($lo, $hi) = range_bounds($arrayref, $min_value, $max_value);

Returns a pair of indices C<($lo, $hi)> defining the half-open range
C<[$lo, $hi)> of elements within C<[$min_value, $max_value]>.
C<$lo> is the index of the first element E<gt>= C<$min_value> (via
binary search), and C<$hi> is the index of the first element
E<gt> C<$max_value> (via linear scan from C<$lo>).

This is optimized for cases where few elements fall within the range,
as the linear scan avoids a second binary search.

Croaks if C<$min_value> E<gt> C<$max_value>.

=head2 Time Zone Database

=head3 find_tzdb_directory

    my $dir = find_tzdb_directory();

Returns the path to the system's IANA Time Zone Database directory
(zoneinfo), or C<undef> if no valid directory is found.

The search order is:

=over 4

=item 1. C<$ENV{TZDIR}> if set and the directory exists.

=item 2. Well-known paths: F</usr/share/zoneinfo>, F</usr/lib/zoneinfo>,
F</usr/share/lib/zoneinfo>, F</etc/zoneinfo>, F</usr/share/zoneinfo.default>.

=item 3. macOS: F</var/db/timezone/zoneinfo> (symlink to the active version).

=back

A candidate directory is accepted only if it exists and contains a
F<UTC> file.

=head3 find_local_timezone

    my $tz = find_local_timezone();
    my $tz = find_local_timezone($tzdb_directory);

Determines the system's local timezone and returns it as a timezone
object: a L<Time::TZif> object for a zoneinfo file, or a
L<Time::TZif::POSIX> object for a POSIX C<TZ> rule. Returns C<undef> if
the local timezone cannot be determined.

The optional C<$tzdb_directory> is the root of the zoneinfo database used
to resolve zone names; it defaults to the result of
L<< find_tzdb_directory|/find_tzdb_directory >>. If no directory is
available (neither given nor found), only forms that do not require the
database can be resolved.

If the C<TZ> environment variable is set, it is interpreted as follows
(an empty C<TZ> selects UTC, per the BSD/GNU convention):

=over 4

=item *

As a zoneinfo name relative to C<$tzdb_directory> (e.g.
C<Europe/Stockholm>). This is tried before the POSIX form, matching the C
library, so a name that exists as a zoneinfo file (e.g. C<EST5EDT>)
resolves to that file rather than to a bare POSIX rule.

=item *

As a POSIX C<TZ> rule (e.g. C<CET-1CEST,M3.5.0/2,M10.5.0/3> or the
quoted form C<< <+05>-5 >>), yielding a L<Time::TZif::POSIX> object.

=item *

As a path containing C<zoneinfo/> (e.g.
C<:/usr/share/zoneinfo/Europe/Stockholm>); the portion after C<zoneinfo/>
is taken as the zone name and resolved under C<$tzdb_directory>.

=item *

As a zoneinfo name with an optional leading colon (e.g.
C<:Europe/Stockholm>).

=item *

As a literal path to a TZif file.

=back

If C<TZ> is not set, the symlink target of F</etc/localtime> is resolved
to a zone name under C<$tzdb_directory> when possible; otherwise
F</etc/localtime> is read directly as a TZif file.

Zone names taken from C<TZ> or from a resolved path are validated with
L<< valid_tzdb_timezone|/valid_tzdb_timezone >> before being joined to
C<$tzdb_directory>, so a malicious C<TZ> cannot escape the database
directory.

Note that resolving a path that exists but is not a valid TZif file
propagates the error from L<Time::TZif>; this function only returns
C<undef> when no candidate matches at all.

Examples:

    # TZ=Europe/Stockholm
    my $tz = find_local_timezone();        # Time::TZif (zoneinfo file)

    # TZ=CET-1CEST,M3.5.0/2,M10.5.0/3
    my $tz = find_local_timezone();        # Time::TZif::POSIX (rule)

    # TZ unset, /etc/localtime -> .../zoneinfo/Europe/Stockholm
    my $tz = find_local_timezone();        # Time::TZif (zoneinfo file)

=head2 Validation

=head3 valid_tzdb_timezone

    my $bool = valid_tzdb_timezone($string);

Returns true if C<$string> is structurally valid as an IANA Time Zone
Database timezone name (e.g., C<America/New_York>, C<Europe/Stockholm>,
C<Etc/GMT+5>, C<UTC>).

A valid name consists of one or more path components separated by C</>.
Each component starts with a letter, followed by letters or digits,
with optional C<_>, C<+>, or C<-> separators introducing additional
alphanumeric segments.

This is a structural check only; it does not verify that the timezone
exists in the database.

=head3 valid_posix_timezone

    my $bool = valid_posix_timezone($string);

Returns true if C<$string> is structurally valid as a POSIX TZ string
(IEEE Std 1003.1).

A POSIX TZ string has the form:

    std offset [dst [offset] , start [/time] , end [/time]]

Examples:

    EST5EDT,M3.2.0,M11.1.0            US Eastern
    CET-1CEST,M3.5.0/2,M10.5.0/3      Central European
    <+05>-5                           Fixed UTC+5 (no DST)
    NZST-12NZDT,M9.5.0,M4.1.0/3       New Zealand

Transition rules may use C<Mm.w.d> (month/week/weekday), C<Jn>
(Julian day excluding Feb 29), or C<n> (zero-based Julian day
including Feb 29) forms.

This validates the syntactic structure of the string only, not the
semantic validity of field values (e.g., month ranges, day ranges).

=head1 SEE ALSO

L<Time::Str>

=head1 AUTHOR

Christian Hansen

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2026 by Christian Hansen

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut
