# ------------------------------------------------------------------------------
# Copyright (C) 2006-2021 British Crown (Met Office) & Contributors.
#
# This file is part of FCM, tools for managing and building source code.
#
# FCM is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# FCM is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with FCM. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
# NAME
#   FCM1::Util
#
# DESCRIPTION
#   This is a package of misc utilities used by the FCM command.
#
# ------------------------------------------------------------------------------

use warnings;
use strict;

package FCM1::Util;
require Exporter;
our @ISA = qw{Exporter};

sub expand_tilde;
sub e_report;
sub find_file_in_path;
sub get_command_string;
sub get_rev_of_wc;
sub get_url_of_wc;
sub get_url_peg_of_wc;
sub get_wct;
sub is_url;
sub is_wc;
sub print_command;
sub run_command;
sub svn_date;
sub tidy_url;
sub touch_file;
sub w_report;

our @EXPORT = qw{
    expand_tilde
    e_report
    find_file_in_path
    get_command_string
    get_rev_of_wc
    get_url_of_wc
    get_url_peg_of_wc
    get_wct
    is_url
    is_wc
    print_command
    run_command
    svn_date
    tidy_url
    touch_file
    w_report
};

# Standard modules
use Carp;
use Cwd;
use File::Basename;
use File::Find;
use File::Path;
use File::Spec;
use POSIX qw{strftime SIGINT SIGKILL SIGTERM WEXITSTATUS WIFSIGNALED WTERMSIG};

# FCM component modules
use FCM1::Timer;

# ------------------------------------------------------------------------------

# Module level variables
my %svn_info       = (); # "svn info" log, (key1 = path,
                         # key2 = URL, Revision, Last Changed Rev)

# ------------------------------------------------------------------------------
# SYNOPSIS
#   %srcdir = &FCM1::Util::find_file_in_path ($file, \@path);
#
# DESCRIPTION
#   Search $file in @path. Returns the full path of the $file if it is found
#   in @path. Returns "undef" if $file is not found in @path.
# ------------------------------------------------------------------------------

sub find_file_in_path {
  my ($file, $path) = @_;

  for my $dir (@$path) {
    my $full_file = File::Spec->catfile ($dir, $file);
    return $full_file if -e $full_file;
  }

  return undef;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $expanded_path = &FCM1::Util::expand_tilde ($path);
#
# DESCRIPTION
#   Returns an expanded path if $path is a path that begins with a tilde (~).
# ------------------------------------------------------------------------------

sub expand_tilde {
  my $file = $_[0];

  $file =~ s#^~([^/]*)#$1 ? (getpwnam $1)[7] : ($ENV{HOME} || $ENV{LOGDIR})#ex;

  # Expand . and ..
  while ($file =~ s#/+\.(?:/+|$)#/#g) {next}
  while ($file =~ s#/+[^/]+/+\.\.(?:/+|$)#/#g) {next}

  # Remove trailing /
  $file =~ s#/*$##;

  return $file;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $rc = &FCM1::Util::touch_file ($file);
#
# DESCRIPTION
#   Touch $file if it exists. Create $file if it does not exist. Return 1 for
#   success or 0 otherwise.
# ------------------------------------------------------------------------------

sub touch_file {
  my $file = $_[0];
  my $rc   = 1;

  if (-e $file) {
    my $now = time;
    $rc = utime $now, $now, $file;

  } else {
    mkpath dirname ($file) unless -d dirname ($file);

    $rc = open FILE, '>', $file;
    $rc = close FILE if $rc;
  }

  return $rc;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $flag = &is_wc ([$path]);
#
# DESCRIPTION
#   Returns true if current working directory (or $path) is a Subversion
#   working copy.
# ------------------------------------------------------------------------------

sub is_wc {
  my $path = shift() || cwd();
  my $path_of_dir = -f $path ? dirname($path) : $path;
  if (-e File::Spec->catfile($path_of_dir, qw{.svn entries})) {
      return 1;
  }
  my $inforc = &run_command (
    [qw/svn info/, $path_of_dir],
    METHOD => 'qx', DEVNULL => 1, ERROR => 'ignore'
  );
  return $inforc != 0;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $flag = &is_url ($url);
#
# DESCRIPTION
#   Returns true if $url is a URL.
# ------------------------------------------------------------------------------

sub is_url {
  # This should handle URL beginning with svn://, http:// and svn+ssh://
  return ($_[0] =~ m#^[\+\w]+://#);
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $url = tidy_url($url);
#
# DESCRIPTION
#   Returns a tidied version of $url by removing . and .. in the path.
# ------------------------------------------------------------------------------

sub tidy_url {
    my ($url) = @_;
    if (!is_url($url)) {
        return $url;
    }
    my $DOT_PATTERN     = qr{/+ \. (?:/+|(@|\z))}xms;
    my $DOT_DOT_PATTERN = qr{/+ [^/]+ /+ \.\. (?:/+|(@|\z))}xms;
    my $TRAILING_SLASH_PATTERN = qr{([^/]+) /* (@|\z)}xms;
    my $RIGHT_EVAL = q{'/' . ($1 ? $1 : '')};
    DOT:
    while ($url =~ s{$DOT_PATTERN}{$RIGHT_EVAL}eegxms) {
        next DOT;
    }
    DOT_DOT:
    while ($url =~ s{$DOT_DOT_PATTERN}{$RIGHT_EVAL}eegxms) {
        next DOT_DOT;
    }
    $url =~ s{$TRAILING_SLASH_PATTERN}{$1$2}xms;
    return $url;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $string = &get_wct ([$dir]);
#
# DESCRIPTION
#   If current working directory (or $dir) is a Subversion working copy,
#   returns the top directory of this working copy; otherwise returns an empty
#   string.
# ------------------------------------------------------------------------------

sub get_wct {
  my $dir = @_ ? $_[0] : cwd ();

  return '' if not &is_wc ($dir);

  my $updir = dirname $dir;
  while (&is_wc ($updir)) {
    $dir   = $updir;
    $updir = dirname $dir;
    last if $updir eq $dir;
  }

  return $dir;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $string = &get_url_of_wc ([$path[, $refresh]]);
#
# DESCRIPTION
#   If current working directory (or $path) is a Subversion working copy,
#   returns the URL of the associated Subversion repository; otherwise returns
#   an empty string. If $refresh is specified, do not use the cached
#   information.
# ------------------------------------------------------------------------------

sub get_url_of_wc {
  my $path    = @_ ? $_[0] : cwd ();
  my $refresh = exists $_[1] ? $_[1] : 0;
  my $url  = '';

  if (&is_wc ($path)) {
    delete $svn_info{$path} if $refresh;
    &_invoke_svn_info (PATH => $path) unless exists $svn_info{$path};
    $url = $svn_info{$path}{URL};
  }

  return $url;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $string = &get_url_peg_of_wc ([$path[, $refresh]]);
#
# DESCRIPTION
#   If current working directory (or $path) is a Subversion working copy,
#   returns the URL@REV of the associated Subversion repository; otherwise
#   returns an empty string. If $refresh is specified, do not use the cached
#   information.
# ------------------------------------------------------------------------------

sub get_url_peg_of_wc {
  my $path    = @_ ? $_[0] : cwd ();
  my $refresh = exists $_[1] ? $_[1] : 0;
  my $url  = '';

  if (&is_wc ($path)) {
    delete $svn_info{$path} if $refresh;
    &_invoke_svn_info (PATH => $path) unless exists $svn_info{$path};
    $url = $svn_info{$path}{URL} . '@' . $svn_info{$path}{Revision};
  }

  return $url;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   &_invoke_svn_info (PATH => $path);
#
# DESCRIPTION
#   The function is internal to this module. It invokes "svn info" on $path to
#   gather information on URL, Revision and Last Changed Rev. The information
#   is stored in a hash table at the module level, so that the information can
#   be re-used.
# ------------------------------------------------------------------------------

sub _invoke_svn_info {
  my %args = @_;
  my $path = $args{PATH};
  my $cfg  = FCM1::Config->instance();

  return if exists $svn_info{$path};

  # Invoke "svn info" command
  my @info = &run_command (
    [qw/svn info/, $path],
    PRINT => $cfg->verbose > 2, METHOD => 'qx', DEVNULL => 1, ERROR => 'ignore',
  );
  for (@info) {
    chomp;

    if (/^(URL|Revision|Last Changed Rev):\s*(.+)$/) {
      $svn_info{$path}{$1} = $2;
    }
  }

  return;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $string = &get_command_string ($cmd);
#   $string = &get_command_string (\@cmd);
#
# DESCRIPTION
#   The function returns a string by converting the list in @cmd or the scalar
#   $cmd to a form, where it can be executed as a shell command.
# ------------------------------------------------------------------------------

sub get_command_string {
  my $cmd    = $_[0];
  my $return = '';

  if (ref ($cmd) and ref ($cmd) eq 'ARRAY') {
    # $cmd is a reference to an array

    # Print each argument
    for my $i (0 .. @{ $cmd } - 1) {
      my $arg = $cmd->[$i];

      $arg =~ s/./*/g if $i > 0 and $cmd->[$i - 1] eq '--password';

      if ($arg =~ /[\s'"*?]/) {
        # Argument contains a space, quote it
        if (index ($arg, "'") >= 0) {
          # Argument contains an apostrophe, quote it with double quotes
          $return .= ($i > 0 ? ' ' : '') . '"' . $arg . '"';

        } else {
          # Otherwise, quote argument with apostrophes
          $return .= ($i > 0 ? ' ' : '') . "'" . $arg . "'";
        }

      } else {
        # Argument does not contain a space, just print it
        $return .= ($i > 0 ? ' ' : '') . ($arg eq '' ? "''" : $arg);
      }
    }

  } else {
    # $cmd is a scalar, just print it "as is"
    $return = $cmd;
  }

  return $return;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   &print_command ($cmd);
#   &print_command (\@cmd);
#
# DESCRIPTION
#   The function prints the list in @cmd or the scalar $cmd, as it would be
#   executed by the shell.
# ------------------------------------------------------------------------------

sub print_command {
  my $cmd = $_[0];

  print '=> ', &get_command_string ($cmd) , "\n";
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   @return = &run_command (\@cmd, <OPTIONS>);
#   @return = &run_command ($cmd , <OPTIONS>);
#
# DESCRIPTION
#   This function executes the command in the list @cmd or in the scalar $cmd.
#   The remaining are optional arguments in a hash table. Valid options are
#   listed below. If the command is run using "qx", the function returns the
#   standard output from the command. If the command is run using "system", the
#   function returns true on success. By default, the function dies on failure.
#
# OPTIONS
#   METHOD  => $method - this can be "system", "exec" or "qx". This determines
#                        how the command will be executed. If not set, the
#                        default is to run the command with "system".
#   PRINT   => 1       - if set, print the command before executing it.
#   ERROR   => $flag   - this should only be set if METHOD is set to "system"
#                        or "qx". The $flag can be "die" (default), "warn" or
#                        "ignore". If set to "die", the function dies on error.
#                        If set to "warn", the function issues a warning on
#                        error, and the function returns false. If set to
#                        "ignore", the function returns false on error.
#   RC      => 1       - if set, must be a reference to a scalar, which will be
#                        set to the return code of the command.
#   DEVNULL => 1       - if set, re-direct STDERR to /dev/null before running
#                        the command.
#   TIME    => 1       - if set, print the command with a timestamp before
#                        executing it, and print the time taken when it
#                        completes. This option supersedes the PRINT option.
# ------------------------------------------------------------------------------

sub run_command {
  my ($cmd, %input_opt_of) = @_;
  my %opt_of = (
    DEVNULL => undef,
    ERROR   => 'die',
    METHOD  => 'system',
    PRINT   => undef,
    RC      => undef,
    TIME    => undef,
    %input_opt_of,
  );
  local($|) = 1; # Make sure STDOUT is flushed before running command

  # Print the command before execution, if necessary
  if ($opt_of{TIME}) {
    print(timestamp_command(get_command_string($cmd)));
  }
  elsif ($opt_of{PRINT}) {
    print_command($cmd);
  }

  # Re-direct STDERR to /dev/null if necessary
  if ($opt_of{DEVNULL}) {
    no warnings;
    open(OLDERR, ">&STDERR") || croak("Cannot dup STDERR ($!), abort");
    use warnings;
    open(STDERR, '>', File::Spec->devnull())
      || croak("Cannot redirect STDERR ($!), abort");
    # Make sure the channels are unbuffered
    my $select = select();
    select(STDERR); local($|) = 1;
    select($select);
  }

  my @return = ();
  if (ref($cmd) && ref($cmd) eq 'ARRAY') {
    # $cmd is an array
    my @command = @{$cmd};
    if ($opt_of{METHOD} eq 'qx') {
      @return = qx(@command);
    }
    elsif ($opt_of{METHOD} eq 'exec') {
      exec(@command);
    }
    else {
      system(@command);
      @return = $? ? () : (1);
    }
  }
  else {
    # $cmd is an scalar
    if ($opt_of{METHOD} eq 'qx') {
      @return = qx($cmd);
    }
    elsif ($opt_of{METHOD} eq 'exec') {
      exec($cmd);
    }
    else {
      system($cmd);
      @return = $? ? () : (1);
    }
  }
  my $rc = $?;

  # Put STDERR back to normal, if redirected previously
  if ($opt_of{DEVNULL}) {
    close(STDERR);
    open(STDERR, ">&OLDERR") || croak("Cannot dup STDERR ($!), abort");
  }

  # Print the time taken for command after execution, if necessary
  if ($opt_of{TIME}) {
    print(timestamp_command(get_command_string($cmd), 'end'));
  }

  # Signal and return code
  my ($signal, $status) = (WTERMSIG($rc), WEXITSTATUS($rc));
  if (exists($opt_of{RC})) {
    ${$opt_of{RC}} = $status;
  }
  if (WIFSIGNALED($rc) && grep {$signal == $_} (SIGINT, SIGKILL, SIGTERM)) {
    croak(sprintf('%s terminated (%d)', get_command_string($cmd), $signal));
  }
  if ($status && $opt_of{ERROR} ne 'ignore') {
    my $func_ref = $opt_of{ERROR} eq 'warn' ? \&carp : \&croak;
    $func_ref->(sprintf('%s failed (%d)', get_command_string($cmd), $status));
  }
  return @return;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   &e_report (@message);
#
# DESCRIPTION
#   The function prints @message to STDERR and aborts with a error.
# ------------------------------------------------------------------------------

sub e_report {
  print STDERR @_, "\n" if @_;

  exit 1;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   &w_report (@message);
#
# DESCRIPTION
#   The function prints @message to STDERR and returns.
# ------------------------------------------------------------------------------

sub w_report {
  print STDERR @_, "\n" if @_;

  return;
}

# ------------------------------------------------------------------------------
# SYNOPSIS
#   $date = &svn_date ($time);
#
# DESCRIPTION
#   The function returns a date, formatted as by Subversion. The argument $time
#   is the number of seconds since epoch.
# ------------------------------------------------------------------------------

sub svn_date {
  my $time = shift;

  return strftime ('%Y-%m-%d %H:%M:%S %z (%a, %d %b %Y)', localtime ($time));
}

# ------------------------------------------------------------------------------

1;

__END__
