package Weather::WeatherKit;

use 5.008;
use strict;
use warnings;

use Carp;
use Crypt::JWT qw(encode_jwt);

use parent 'Weather::API::Base';
use Weather::API::Base qw(:all);

=head1 NAME

Weather::WeatherKit - Apple WeatherKit REST API client

=cut

our $VERSION = '0.12';

=head1 SYNOPSIS

    use Weather::WeatherKit;

    my $wk = Weather::WeatherKit->new(
        team_id    => $apple_team_id,          # Apple Developer Team Id
        service_id => $weatherkit_service_id,  # WeatherKit Service Id
        key_id     => $key_id,                 # WeatherKit developer key ID
        key        => $private_key             # Encrypted private key (PEM)
    );
    
    # Request current weather:
    my $report = $wk->get(
        lat      => $lat,      # Latitude
        lon      => $lon,      # Longitude
        dataSets => 'currentWeather'
    );

    # Request forecast for 8 days, use Weather::API::Base helper functions
    # for ISO dates, and get full HTTP::Response object to check for success
    use Weather::API::Base qw(:all);

    my $response = $wk->get_response(
        lat         => $lat,
        lon         => $lon,
        dataSets    => 'forecastHourly',
        hourlyStart => ts_to_iso_date(time()),
        hourlyEnd   => ts_to_iso_date(time()+8*24*3600)
    );

    if ($response->is_success) {
        my $json = $response->decoded_content;
    } else {
        die $response->status_line;
    }

=head1 DESCRIPTION

Weather::WeatherKit provides basic access to the Apple WeatherKit REST API (v1).
WeatherKit replaces the Dark Sky API and requires an Apple developer subscription.

Pease see the L<official API documentation|https://developer.apple.com/documentation/weatherkitrestapi>
for datasets and usage options as well as the L<required attribution|https://developer.apple.com/weatherkit/get-started/#attribution-requirements>.

It was made to serve the apps L<Xasteria|https://astro.ecuadors.net/xasteria/> and
L<Polar Scope Align|https://astro.ecuadors.net/polar-scope-align/>, but if your service
requires some extra functionality, feel free to contact the author about it.

=head1 CONSTRUCTOR

=head2 C<new>

    my $wk = Weather::WeatherKit->new(
        team_id    => "MLU84X58U4",
        service_id => "com.domain.myweatherapp",
        key_id     => $key_id,
        key        => $private_key?,
        key_file   => $private_key_pem?,
        language   => $lang_code?,
        timeout    => $timeout_sec?,
        expiration => $expire_secs?,
        ua         => $lwp_ua?,
        curl       => $use_curl?
    );
  
Required parameters:

=over 4

=item * C<team_id> : Your 10-character Apple developer Team Id - it can be located
on the Apple developer portal.

=item * C<service_id> : The WeatherKit Service Identifier created on the Apple
developer portal. Usually a reverse-domain type string is used for this.

=item * C<key_id> : The ID of the WeatherKit key created on the Apple developer portal.

=item * C<key_file> : The encrypted WeatherKit private key file that you created on
the Apple developer portal. On the portal you download a PKCS8 format file (.p8),
which you first need to convert to the PEM format. On a Mac you can convert it simply:

   openssl pkcs8 -nocrypt -in AuthKey_<key_id>.p8 -out AuthKey_<key_id>.pem

=item * C<key> : Instead of the C<.pem> file, you can pass its contents directly
as a string. If both are provided C<key> takes precedence over C<key_file>.

=back

Optional parameters:

=over 4

=item * C<language> : Language code. Default: C<en_US>.

=item * C<timeout> : Timeout for requests in secs. Default: C<30>.

=item * C<ua> : Pass your own L<LWP::UserAgent> to customise the agent string etc.

=item * C<curl> : If true, fall back to using the C<curl> command line program.
This is useful if you have issues adding http support to L<LWP::UserAgent>, which
is the default method for the WeatherKit requests. It assumes the C<curl> program
is installed in C<$PATH>.

=item * C<expiration> : Token expiration time in seconds. Tokens are cached until
there are less than 10 minutes left to expiration. Default: C<7200>.

=back

=head1 METHODS

=head2 C<get>

    my $report = $wk->get(
        lat      => $lat,
        lon      => $lon,
        dataSets => $datasets
        %args?
    );

    my %report = $wk->get( ... );

Fetches datasets (weather report, forecast, alert...) for the requested location.
Returns a string containing the JSON data, except in array context, in which case,
as a convenience, it will use L<JSON> to decode it directly to a Perl hash.

Requires L<LWP::UserAgent>, unless the C<curl> option was set.

If the request is not successful, it will C<die> throwing the C<< HTTP::Response->status_line >>.

=over 4
 
=item * C<lat> : Latitude (-90 to 90).

=item * C<lon> : Longitude (-18 to 180).

=item * C<dataSets> : A comma-separated string of the dataset(s) you request. Example
supported data sets: C<currentWeather, forecastDaily, forecastHourly, forecastNextHour, weatherAlerts>.
Some data sets might not be available for all locations. Will return empty results
if parameter is missing.

=item * C<%args> : See the official API documentation for the supported weather API
query parameters which you can pass as key/value pairs.

=back

=head2 C<get_response>

    my $response = $wk->get_response(
        lat      => $lat,
        lon      => $lon,
        dataSets => $datasets
        %args?
    );

Same as C<get> except it returns the full L<HTTP::Response> from the API (so you
can handle bad requests yourself).

=head1 CONVENIENCE METHODS

=head2 C<jwt>

    my $jwt = $wk->jwt(
        iat => $iat?,
        exp => $exp?
    );

Returns the JSON Web Token string in case you need it. Will return a cached one
if it has more than 10 minutes until expiration and you don't explicitly pass an
C<exp> argument.

=over 4
 
=item * C<iat> : Specify the token creation timestamp. Default is C<time()>.

=item * C<exp> : Specify the token expiration timestamp. Passing this parameter
will force the creation of a new token. Default is C<time()+7200> (or what you
specified in the constructor).

=back

=head1 HELPER FUNCTIONS (from Weather::API::Base)

The parent class L<Weather::API::Base> contains some useful functions e.g.:

  use Weather::API::Base qw(:all);

  # Get time in ISO (YYYY-MM-DDTHH:mm:ss) format
  my $datetime = ts_to_iso_date(time());

  # Convert 30 degrees Celsius to Fahrenheit
  my $result = convert_units('C', 'F', 30);

See the doc for that module for more details.

=head1 KNOWN ISSUES

=head2 400 errors on 10 day forecast

Although WeatherKit is supposed to provide 10 days of forecast, at some point users
started getting C<400> errors when requesting (e.g. with C<hourlyEnd>) more than 8 or 9
days of forecast. If you encounter this issue, limit your forecast request to 9 or
8 days in the future.

=head1 OTHER PERL WEATHER MODULES

Some Perl modules for current weather and forecasts from other sources:

=head2 L<Weather::OWM>

OpenWeatherMap uses various weather sources combined with their own ML and offers
a couple of free endpoints (the v2.5 current weather and 5d/3h forecast) with generous
request limits. Their newer One Call 3.0 API also offers some free usage (1000 calls/day)
and the cost is per call above that. If you want access to history APIs, extended
hourly forecasts etc, there are monthly subscriptions. L<Weather::OWM> is from the
same author as this module and similar in use.

=head2 L<Weather::Astro7Timer>

The 7Timer! weather forecast is completely free and would be of extra interest if
you are interested in astronomy/stargazing. It uses the standard NOAA forecast,
but also calculates astronomical seeing and transparency. It can be accessed with
L<Weather::Astro7Timer>, which is another module similar to this (same author).

=cut

sub new {
    my ($class, %args) = @_;
    croak("10 digit team_id expected.")
        unless $args{team_id} && length($args{team_id}) == 10;

    my $self = $class->SUPER::new(
        language => 'en_US',
        error    => 'die',
        %args
    );

    ($self->{$_} = $args{$_} || croak("$_ required."))
        for qw/service_id key_id/;

    unless ($args{key}) {
        croak("key or key_file required.") unless $args{key_file};
        open my $fh, '<', $args{key_file} or die "Can't open file $args{key_file}: $!";
        $args{key} = do {local $/; <$fh>};
    }

    $self->{key}        = \$args{key};
    $self->{team_id}    = $args{team_id};
    $self->{expiration} = $args{expiration} || 7200;

    return $self;
}

sub get {
    my $self = shift;
    my %args = @_;
    my $resp = $self->get_response(%args);

    return $self->_get_output($resp, wantarray);
}

sub get_response {
    my $self = shift;
    my %args = @_;
    $args{language} ||= $self->{language};

    Weather::API::Base::_verify_lat_lon(\%args);

    $self->_ua unless $self->{ua} || $self->{curl};

    return _fetch($self->{ua}, _weather_url(%args), $self->jwt);
}

sub jwt {
    my $self = shift;
    my %args = @_;

    # Return cached one
    return $self->{jwt}
        if !$args{exp} && $self->{jwt_exp} && $self->{jwt_exp} >= time() + 600;

    $args{iat} ||= time();
    $self->{jwt_exp} = $args{exp} || (time() + $self->{expiration});

    my $data = {
        iss => $self->{team_id},
        sub => $self->{service_id},
        exp => $self->{jwt_exp},
        iat => $args{iat}
    };

    $self->{jwt} = encode_jwt(
        payload       => $data,
        alg           => 'ES256',
        key           => $self->{key},
        extra_headers => {
            kid => $self->{key_id},
            id  => "$self->{team_id}.$self->{service_id}",
            typ => "JWT"
        }
    );

    return $self->{jwt};
}

sub _fetch {
    my ($ua, $url, $jwt) = @_;

    return
        `curl "$url" -A "Curl Weather::WeatherKit/$VERSION" -s -H 'Authorization: Bearer $jwt'`
        unless $ua;

    return $ua->get($url, Authorization => "Bearer $jwt");
}

sub _weather_url {
    my %args = @_;
    my $url =
        "https://weatherkit.apple.com/api/v1/weather/{language}/{lat}/{lon}";

    $url =~ s/{$_}/delete $args{$_}/e foreach qw/language lat lon/;

    my $params = join("&", map {"$_=$args{$_}"} keys %args);

    $url .= "?$params" if $params;

    return $url;
}

=head1 AUTHOR

Dimitrios Kechagias, C<< <dkechag at cpan.org> >>

=head1 BUGS

Please report any bugs or feature requests either on L<GitHub|https://github.com/dkechag/Weather-WeatherKit> (preferred), or on RT (via the email
C<bug-weather-weatherkit at rt.cpan.org> or L<web interface|https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Weather-WeatherKit>).

I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.

=head1 GIT

L<https://github.com/dkechag/Weather-WeatherKit>

=head1 LICENSE AND COPYRIGHT

This software is copyright (c) 2023 by Dimitrios Kechagias.

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

=cut

1;
