#!/usr/bin/env perl

# see perldoc below for instructions on how to use this script.

use FindBin ();
use lib "$FindBin::RealBin/../lib";

use Cwd qw( abs_path );
use File::Basename qw( dirname );
use File::Spec ();
use File::Spec::Functions qw( catfile file_name_is_absolute );
use HTTP::Daemon ();
use HTTP::Response ();
use Storable qw( store );
use Type::Params qw( signature );
use Types::Standard qw( Str StrMatch ArrayRef Optional );
use YAML::Any qw( Dump LoadFile );

use Google::RestApi::Auth::OAuth2Client ();

my $config_file = $ARGV[0] or die "A config file name must be provided. See 'perldoc $0'.\n";
my $config = eval { LoadFile($config_file); };
die "Unable to load YAML config file '$config_file': $@\n" if $@;

# Support an optional 'google_restapi' top-level key so the config file can
# be shared with other apps. Use that section if present, else use the root.
$config = $config->{google_restapi} // $config;

print "Validating config:\n", Dump($config);
my $check = signature(
  bless => !!0,
  named => [
    class         => StrMatch[qr/^OAuth2Client$/],
    client_id     => Str,
    client_secret => Str,
    scope         => Optional[ArrayRef[Str]],
    token_file    => Str,
  ],
);
$check->(%{ $config->{auth} });

# Start a loopback web server on an ephemeral port to catch Google's
# redirect. Google blocked the old out-of-band (OOB) copy-the-code flow in
# 2023; the loopback address is its sanctioned replacement for desktop apps.
# Desktop-app OAuth clients automatically permit http://127.0.0.1:<any port>
# as a redirect URI, so no extra Google Cloud Console configuration is needed.
my $daemon = HTTP::Daemon->new(LocalAddr => '127.0.0.1', LocalPort => 0)
  or die "Unable to start local callback server: $!\n";
my $redirect_uri = sprintf 'http://127.0.0.1:%d/', $daemon->sockport;

my $oauth2 = Google::RestApi::Auth::OAuth2Client->new(
  client_id     => $config->{auth}->{client_id},
  client_secret => $config->{auth}->{client_secret},
  redirect_uri  => $redirect_uri,
  defined $config->{auth}->{scope} ? (scope => $config->{auth}->{scope}) : (),
);

# access_type=offline + approval_prompt=force ensure we get not only an access
# token but also a refresh token that can be used to update it as needed.
my $url = $oauth2->authorize_url(
  access_type     => 'offline',
  approval_prompt => 'force',
);

# Give the user instructions on what to do:
print <<END

To authorise access to your Google account:

1. A browser window should open automatically. If it does not, copy the
URL printed below and paste it into a browser yourself.

2. Log in to the Google account you want to grant access to, if you are
not already logged in.

3. When asked to grant access, click "Continue" / "Allow".

4. Google will redirect back to this script automatically and the browser
will show a "you can close this tab" message. Then return here.

Authorisation URL:

$url

END
    ;

open_browser($url);

print "Waiting for the Google authorisation redirect on $redirect_uri ...\n";
my $code = wait_for_code($daemon);
$daemon->close();
die "No authorisation code was received; aborting.\n" unless defined $code;

# Exchange the code for an access token:
my $token = $oauth2->access_token($code)
  or die "Unable to exchange the code for an access token";

# If we get to here, it worked!  Report success: 
print "\nToken obtained successfully!\n";
print "Here are the token contents:\n\n";
print $token->to_string(), "\n\n";

# Save the token for future use. token_file may be an absolute path or a
# bare filename relative to the config file's directory.
my $token_file = $config->{auth}->{token_file};
$token_file = catfile(dirname($config_file), $token_file)
  unless file_name_is_absolute($token_file);
store($token->session_freeze(), $token_file);

print <<END2

Token successfully stored in file $token_file.

END2
    ;

# Run the scope check to verify the token scopes and API enablement.
my $test_runner = "$FindBin::RealBin/../t/run_unit_tests.t";
if (!-f $test_runner) {
  print <<END_MISSING;

NOTE: The scope check test could not be found at:
  $test_runner

This check is only available when running from a cloned copy of the
repository. To verify that your token scopes and APIs are correctly
configured, clone the repo and run the check manually:

  git clone https://github.com/mvsjes2/p5-google-restapi.git
  cd p5-google-restapi
  GOOGLE_RESTAPI_CONFIG=$config_file \\
    TEST_CLASS='Test::Google::RestApi::ScopeCheck' \\
    prove -v t/run_unit_tests.t

A failing test will print the Google Cloud Console URL needed to
enable each missing API directly in the output.

END_MISSING
} else {
  print "Running scope check to verify token scopes and API enablement...\n\n";

  my $abs_config  = abs_path($config_file);
  local $ENV{GOOGLE_RESTAPI_CONFIG} = $abs_config;
  local $ENV{TEST_CLASS}            = 'Test::Google::RestApi::ScopeCheck';

  open(my $fh, '-|', $^X,
    "-I$FindBin::RealBin/../lib",
    "-I$FindBin::RealBin/../t/lib",
    "-I$FindBin::RealBin/../t/unit",
    $test_runner,
  ) or die "Cannot run scope check: $!";

  my (@failures, $last_failure);
  while (my $line = <$fh>) {
    print $line;
    if ($line =~ /^not ok \d+ - (.+)/) {
      $last_failure = { message => $1, url => undef };
      push @failures, $last_failure;
    } elsif ($last_failure && $line =~ /^#\s+(https?:\S+)/) {
      $last_failure->{url} = $1;
      $last_failure = undef;
    } elsif ($line !~ /^#/) {
      $last_failure = undef;
    }
  }
  close($fh);

  print "\n", "=" x 60, "\n";
  if (@failures) {
    printf "SCOPE CHECK: %d issue(s) found:\n\n", scalar @failures;
    for my $f (@failures) {
      print "  * $f->{message}\n";
      print "    $f->{url}\n" if $f->{url};
      print "\n";
    }
    print "Once resolved, re-run the scope check with:\n\n";
    print "  GOOGLE_RESTAPI_CONFIG=$abs_config \\\n";
    print "    TEST_CLASS='Test::Google::RestApi::ScopeCheck' \\\n";
    print "    prove -v t/run_unit_tests.t\n";
  } else {
    print "SCOPE CHECK: all APIs reachable. Your token is ready to use.\n";
  }
  print "=" x 60, "\n";
}

# Best-effort attempt to open the auth URL in the user's browser. Non-fatal:
# if no opener is found the user can still paste the URL printed above.
sub open_browser {
  my ($url) = @_;
  for my $cmd (qw( xdg-open open )) {
    next unless which($cmd);
    my $pid = fork();
    return unless defined $pid;   # fork failed; user can open it manually
    if (!$pid) {
      open STDOUT, '>', File::Spec->devnull();
      open STDERR, '>', File::Spec->devnull();
      exec($cmd, $url);
      exit 1;                     # exec failed
    }
    return;
  }
  return;
}

sub which {
  my ($cmd) = @_;
  for my $dir (split /:/, ($ENV{PATH} // '')) {
    return 1 if length $dir && -x catfile($dir, $cmd);
  }
  return 0;
}

# Block until the browser hits our loopback server with ?code=... (success)
# or ?error=... (user denied). Returns the code, or undef on error/denial.
# Other requests (e.g. favicon.ico) are answered 404 and ignored.
sub wait_for_code {
  my ($daemon) = @_;
  while (my $conn = $daemon->accept()) {
    while (my $req = $conn->get_request()) {
      my %q = $req->uri->query_form();
      if ($q{code}) {
        $conn->send_response(html_response(
          'Authorisation complete',
          'You can close this tab and return to the terminal.',
        ));
        return $q{code};
      }
      if ($q{error}) {
        $conn->send_response(html_response(
          'Authorisation failed',
          "Google returned an error: $q{error}. You can close this tab.",
        ));
        return;
      }
      $conn->send_error(404);
    }
  }
  return;
}

sub html_response {
  my ($title, $body) = @_;
  return HTTP::Response->new(
    200, 'OK',
    [ 'Content-Type' => 'text/html; charset=utf-8' ],
    "<!doctype html><html><head><meta charset='utf-8'><title>$title</title></head>"
      . "<body style='font-family:sans-serif;max-width:40em;margin:3em auto'>"
      . "<h2>$title</h2><p>$body</p></body></html>\n",
  );
}

__END__

=head1 SYNOPSIS

Script to create an OAuth2 token that can be stored and used later to authorize
REST API access to your Google account.

Based on code from https://gist.github.com/hexaddikt/6738162

=head1 INITIAL GOOGLE CLOUD SETUP

Before running this script you need a Google Cloud project with OAuth credentials
and the relevant APIs enabled. This is a one-time setup per project.

=head2 1. Create a Google Cloud Project

=over

=item * Go to L<https://console.cloud.google.com> and sign in.

=item * Click the project dropdown at the top and select B<New Project>.

=item * Give it a name and click B<Create>.

=back

=head2 2. Enable the APIs You Need

=over

=item * In the left menu go to B<APIs & Services E<gt> Library>.

=item * Search for and enable each API you intend to use. For this package the
relevant APIs are:

=over

=item * Google Drive API

=item * Google Sheets API

=item * Google Calendar API

=item * Google Docs API

=item * Gmail API

=item * Tasks API

=back

You only need to enable the APIs you actually plan to use. Each failing scope
check test will print the exact Cloud Console URL to enable that specific API.

=back

=head2 3. Configure the OAuth Consent Screen

=over

=item * Go to B<APIs & Services E<gt> OAuth consent screen>.

=item * Choose B<External> and click B<Create>.

=item * Fill in the required fields (app name, support email) and click B<Save and Continue>
through the remaining steps.

=item * On the B<Test users> page, add the Google account(s) you will use for testing.
Only listed users can authorise your app while it remains in Testing mode.

=back

=head2 4. Create OAuth Credentials

=over

=item * Go to B<APIs & Services E<gt> Credentials>.

=item * Click B<+ Create Credentials> and select B<OAuth client ID>.

=item * Choose B<Desktop app>, give it a name, and click B<Create>.

=item * Copy the B<Client ID> and B<Client Secret> shown in the confirmation dialog.

=back

=head2 5. Create a Config File

Create a YAML file with your credentials. If the config file is shared with
other applications, nest the Google::RestApi configuration under an optional
C<google_restapi> top-level key; otherwise place it at the root:

    ---
    # shared config — google_restapi section is used by this package:
    google_restapi:
        auth:
            class: OAuth2Client
            client_id: <client-id-from-google>
            client_secret: <client-secret-from-google>
            token_file: <filename-for-the-stored-token>  # bare filename (resolved against the config file's directory) or an absolute path
            scope:
                - https://www.googleapis.com/auth/drive
                - https://www.googleapis.com/auth/spreadsheets
                # add further scopes as needed:
                # - https://www.googleapis.com/auth/calendar
                # - https://www.googleapis.com/auth/documents
                # - https://www.googleapis.com/auth/gmail.modify
                # - https://www.googleapis.com/auth/tasks
    other_app:
        some_key: some_value

    # or, without the google_restapi wrapper (root-level config):
    ---
    auth:
        class: OAuth2Client
        client_id: <client-id-from-google>
        client_secret: <client-secret-from-google>
        token_file: <filename-for-the-stored-token>
        scope:
            - https://www.googleapis.com/auth/drive
            - https://www.googleapis.com/auth/spreadsheets

The C<scope> list controls which Google APIs the token is authorised to access.
Only include the scopes for APIs you actually use — requesting unnecessary scopes
may cause Google to show a warning during authorisation. If omitted, a minimal
default scope is used (userinfo only).

If C<token_file> is a bare filename it is written to the same directory as the
config file; if it is an absolute path it is written there directly. The same
config file is used by this package at runtime to access the Google APIs.

=head2 6. Run This Script

    perl bin/google_restapi_oauth_token_creator /path/to/your/config.yaml

The script starts a temporary web server on a random port on the loopback
address (C<http://127.0.0.1:E<lt>portE<gt>>) to receive Google's redirect,
then:

=over

=item * A browser opens automatically on the authorisation URL. If it does
not, copy the URL the script prints and paste it into a browser yourself.

=item * Log in to your Google account if prompted, then click B<Allow>.

=item * Google redirects back to the local server, which captures the
authorisation code automatically. The browser shows a "you can close this
tab" message and the script continues on its own -- no copy-and-paste.

=back

This uses the OAuth2 loopback flow, the replacement for the out-of-band (OOB)
flow that Google discontinued in 2023. Desktop-app OAuth clients (the type
created in step 4) automatically permit loopback redirect URIs on any port,
so no additional Google Cloud Console configuration is required.

The script will store the token and then run a scope check to confirm that all
enabled APIs are reachable. Any failing check will print the Cloud Console URL
needed to enable that API.

=head2 7. Use the Token

    use Google::RestApi;
    my $rest_api = Google::RestApi->new(config_file => '/path/to/your/config.yaml');

See L<Google::RestApi> for further details.

=head1 RE-RUNNING THE SCOPE CHECK

If you enable additional APIs later and want to verify without re-creating the
token, run:

    GOOGLE_RESTAPI_CONFIG=/path/to/your/config.yaml \
      TEST_CLASS='Test::Google::RestApi::ScopeCheck' \
      prove -v t/run_unit_tests.t

This requires a cloned copy of the repository. See the NOTE printed by this
script if the test file is not found.
