#!/usr/bin/perl -w

use strict;
use warnings;
use LWP::UserAgent;
use HTTP::Request;
use JSON qw(from_json to_json);
use Getopt::Long;
use Config::Simple;
use File::HomeDir;
use File::MimeInfo;
use File::Basename;
use File::Spec;
use URI::Escape;
use Term::ReadKey;
use Hash::Merge::Simple qw(merge);
use Scalar::Util qw(looks_like_number);

our $opt;

GetOptions(
  "user=s"                             => \$opt->{user},
  "password=s"                         => \$opt->{password},
  "access_token|access-token|token=s"  => \$opt->{access_token},
  "server=s"                           => \$opt->{server},
  "proxy=s"                            => \$opt->{proxy},
  "room=s"                             => \$opt->{room},
  "message|msg=s"                      => \$opt->{message},
  "files=s@"                           => \$opt->{file},
  "debug"                              => \$opt->{debug},
  "action=s"                           => \$opt->{action},
  "send-msg|send-message"              => \$opt->{'send-msg'},
  "send-notice"                        => \$opt->{'send-notice'},
  "send-code"                          => \$opt->{'send-code'},
  "send-file"                          => \$opt->{'send-file'},
  "create-room"                        => \$opt->{'create-room'},
  "modify-room"                        => \$opt->{'modify-room'},
  "delete-room-alias"                  => \$opt->{'delete-room-alias'},
  "get-access-token"                   => \$opt->{'get-access-token'},
  "get-room-list"                      => \$opt->{'get-room-list'},
  "get-room-id"                        => \$opt->{'get-room-id'},
  "setup"                              => \$opt->{setup},
  "config=s"                           => \$opt->{conf},
  "invite=s@"                          => \$opt->{invite},
  "name=s"                             => \$opt->{name},
  "alias=s"                            => \$opt->{alias},
  "topic=s"                            => \$opt->{topic},
  "join_rules|join-rules=s"            => \$opt->{join_rules},
  "permision=s@"                       => \$opt->{perm},
  "perm_user|user-permission=s@"       => \$opt->{perm_user},
  "perm_event|event-permission=s@"     => \$opt->{perm_user},
  "perm_reset|reset-permission"        => \$opt->{perm_reset}
);

if (-e File::HomeDir->my_home . "/.patrixrc" && !$opt->{conf}){
  $opt->{conf} = File::HomeDir->my_home . "/.patrixrc";
  debug("Using default config file $opt->{conf}");
}
if ($opt->{conf} && -e $opt->{conf}){
  read_conf();
}

# alias for --action=foo is --foo
my @actions = qw(
  send-msg
  send-notice
  send-code
  send-file
  create-room
  modify-room
  delete-room-alias
  get-access-token
  get-room-list
  get-room-id
  setup
);
foreach my $action (@actions){
  if ($opt->{$action}){
    $opt->{action} = $action;
    last;
  }
}

my $lwp = LWP::UserAgent->new;

if (not $opt->{proxy} and defined $ENV{https_proxy}){
  $opt->{proxy} = $ENV{https_proxy};
}
# If a proxy is specified then use it. Else, try to get global one
if ($opt->{proxy}){
  if (eval { require LWP::Protocol::connect; }){
    $opt->{proxy} =~ s|^http://(.*)|connect://$1|;
  }
  $lwp->proxy(['https'], $opt->{proxy});
}

my $stdin = 0;
if (!-t STDIN){
  debug("Reading data from stdin");
  $stdin = 1;
}

foreach (@{$opt->{file}}){
  # Handle ~
  $_ =~ s/^~(\w*)/(getpwnam( $1 || $ENV{USER}))[7]/e;
  # Convert to absolute path
  $_ = File::Spec->rel2abs($_);
}

# Set defaults
sub set_defaults {
  $opt->{action}     //= 'send-msg';
  $opt->{server}     = 'https://' . $opt->{server} unless ($opt->{server} =~ m|https?://|);
}

# Print debug info if debug is enabled
sub debug {
  my $msg = shift;
  print "$msg\n\n" if $opt->{debug};
}

# Resolve a room alias to a room ID
sub room_alias_to_id {
  my $alias = shift;
  debug("Looking $opt->{room} room ID");
  my $uri = $opt->{server} . '/_matrix/client/r0/directory/room/' . uri_escape($alias);
  my $resp = send_request({
    method => 'GET',
    uri    => $uri,
  });
  die "Error during room ID lookup\n" unless ($resp->is_success);
  return from_json($resp->decoded_content)->{room_id};
}

# Send a request to Matrix server and return the raw response
sub send_request {
  my $param = shift;
  $param->{method}       ||= 'POST';
  $param->{content_type} ||= 'application/json';
  $param->{content}      ||= to_json({});
  die "Missing an URI" unless $param->{uri};
  my $req = HTTP::Request->new( $param->{method}, $param->{uri} );
  $req->header('Content-Type' => $param->{content_type});
  $req->content($param->{content});
  my $resp = $lwp->request( $req );
  debug("Server responded:\n" . to_json(from_json($resp->decoded_content), { pretty => 1 }));
  return $resp;
}

# Read the content of a file
sub slurp {
  my $file = shift;
  open my $f, '<', $file or die;
  local $/ = undef;
  my $bytes = <$f>;
  close $f;
  return $bytes;
}

# Load values from the config file if it exists
sub read_conf {
  my $cfg = Config::Simple->new;
  $cfg->read($opt->{conf});
  foreach my $param(keys %{$opt}){
    if ($cfg->param('default.' . $param) && !$opt->{$param}){
      $opt->{$param} = $cfg->param('default.' . $param)
    }
  }
}

# Submit user and password the the HS and obtain an access_token
sub login {
  debug("Trying to login on $opt->{server} as $opt->{user}");
  my $uri = $opt->{server} . '/_matrix/client/r0/login';
  my $json = {
    type     => 'm.login.password',
    user     => $opt->{user},
    password => $opt->{password}
  };
  my $resp = send_request({
    uri     => $uri,
    content => to_json($json)
  });
  die "Error login in, please check your credentials\n" unless ($resp->is_success);
  # Set the access token
  $opt->{access_token} = from_json($resp->decoded_content)->{access_token};
  die "No access token in server response\n" if !$opt->{access_token};
}

# Invalidate the access_token
sub logout {
  debug("Trying to logout");
  my $uri = $opt->{server} . '/_matrix/client/r0/logout?access_token=' . $opt->{access_token};
  my $resp = send_request({
    uri => $uri
  });
  die "Error login out\n" unless ($resp->is_success);
}

# Join the specified room, before we can send anything
sub join_room {
  debug("Trying to join room $opt->{room}");
  # Room must be escaped. if not and room is an alias, it'll start with # so the access_token won't be sent
  my $uri = $opt->{server} . '/_matrix/client/r0/join/' . uri_escape( $opt->{room} ) . '?access_token=' . $opt->{access_token};
  my $resp = send_request({
    uri => $uri
  });
  die "Error joining room $opt->{room}\n" unless ($resp->is_success);
}

# Retrieve the actual permissions for a room
sub get_room_permissions {
  debug('Getting actual room state');
  my $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.power_levels?access_token=' . $opt->{access_token};
  my $resp = send_request({
    method => 'GET',
    uri    => $uri
  });
  die "Error joining room $opt->{room}\n" unless ($resp->is_success);
  return from_json($resp->decoded_content);
}

# Return the user ID of the operator
sub who_am_i {
  # We could get user_id if we login with user/pass but what if we use an access token ?
  # Lets just build it manually for now
  my $server = $opt->{server};
  $server =~ s|^https?://||;
  $server =~ s|/$||;
  return '@' . $opt->{user} .':' . $server;
}

# Send a text message (either message or notice as both are similar)
sub send_msg {
  my $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/send/m.room.message?access_token=' . $opt->{access_token};
  # Ignore --message if reading from stdin
  if ($stdin){
    $opt->{message} = '';
    $opt->{message} .= $_ while (<STDIN>);
  }
  my $json = {
    msgtype => ($opt->{action} eq 'send-notice') ? 'm.notice' : 'm.text',
    body => $opt->{message}
  };
  # If we send code, we have to format it correctly
  if ($opt->{action} eq 'send-code'){
    $json->{formatted_body} = '<pre><code>' . $opt->{message} . '</code></pre>';
    $json->{format} = 'org.matrix.custom.html';
  }
  my $resp = send_request({
    uri     => $uri,
    content => to_json($json)
  });
  die "Error sending message to $opt->{room}\n" unless ($resp->is_success);
}

# Send a file to the room
sub send_file {
  my $file = shift;
  # Sending a file is a 2 steps operation. First we need to upload the file to the media store
  # And then we post the uri on the room
  debug("Uploading file $file to the media store");
  my $uri = $opt->{server} . '/_matrix/media/v1/upload?access_token=' . $opt->{access_token} . '&filename=' . basename($file);
  my $mime = mimetype($file);
  my $resp = send_request({
    uri          => $uri,
    content_type => $mime,
    content      => slurp($file)
  });
  die "Error uploading file\n" unless ($resp->is_success);
  # If everything went well, the server replied with the URI of our file, which we can
  # now post on the room
  my $file_uri = from_json($resp->decoded_content)->{content_uri};
  die "Server did not sent the file URI\n" unless ($file_uri);
  debug("File uploaded, with the URI $file_uri\nNow Sending the file link to the room $opt->{room}");
  # Now lets post a new message with the URI of the file
  $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/send/m.room.message?access_token=' . $opt->{access_token};
  my $json = {
    msgtype  => 'm.file',
    body     => basename($file),
    filename => basename($file),
    info     => {
      mimetype => mimetype $file,
      size     => (stat $file)[7]
    },
    url      => $file_uri
  };
  # If the file is an image, audio or video, send it as such
  if ($mime =~ m/^image/){
    $json->{msgtype} = 'm.image';
  }
  elsif ($mime =~ /^audio/){
    $json->{msgtype} = 'm.audio';
  }
  elsif ($mime =~ /^video/){
    $json->{msgtype} = 'm.video';
  }
  $resp = send_request({
    uri     => $uri,
    content => to_json($json)
  });
  die "Error posting file link on room $opt->{room}\n" unless ($resp->is_success);
}

# List public rooms
# Note that there's no pagination handling yet, so you might not have all the results
sub list_room {
  debug("Fetching list of public rooms on $opt->{server}");
  my $uri = $opt->{server} . '/_matrix/client/r0/publicRooms?access_token=' . $opt->{access_token};
  my $resp = send_request({
    uri => $uri,
  });
  die "Error joining room $opt->{room}\n" unless ($resp->is_success);
  # TODO: Handle pagination
  debug("List rooms response is\n" . to_json(from_json($resp->decoded_content), { pretty => 1 }));
  print "Existing Rooms:\n";
  foreach (@{from_json($resp->decoded_content)->{chunk}}){
    print "  * " . $_->{room_id};
    print ' (' . $_->{canonical_alias} . ')' if (defined $_->{canonical_alias});
    print "\n";
  }
}

# Create a new room
sub create_room {
  debug("Creating a new room on $opt->{server}");
  my $uri = $opt->{server} . '/_matrix/client/r0/createRoom?access_token=' . $opt->{access_token};
  my $json = {};
  my $resp = send_request({
    uri => $uri,
    content => to_json($json)
  });
  die "Error creating room on $opt->{server}\n" unless ($resp->is_success);
  $opt->{room} = from_json($resp->decoded_content)->{room_id};
  # Now configure the room
  modify_room();
  print "$opt->{room}\n";
}

# Modify an existing room
sub modify_room {
  debug("Modifying room $opt->{room} on $opt->{server}");
  my ($uri,$req,$json,$resp);
  # A new alias should be added
  if ($opt->{alias}){
    debug('Adding ' . $opt->{alias} . ' as a room alias');
    $uri = $opt->{server} . '/_matrix/client/r0/directory/room/' . uri_escape($opt->{alias}) . '?access_token=' . $opt->{access_token};
    $json = {
      room_id => $opt->{room}
    };
    $resp = send_request({
      method  => 'PUT',
      uri     => $uri,
      content => to_json($json)
    });
    die "Error adding new alias $opt->{alias} for room $opt->{room} on server $opt->{server}\n"
      unless ($resp->is_success);
  }
  # The name of the room is being updated
  if ($opt->{name}){
    debug('Changing the room name to ' . $opt->{name});
    $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.name?access_token=' . $opt->{access_token};
    $json = {
      name => $opt->{name}
    };
    $resp = send_request({
      method  => 'PUT',
      uri     => $uri,
      content => to_json($json)
    });
    die "Error changing name of room $opt->{room}\n"
      unless ($resp->is_success);
  }
  # The topic is being updated
  if ($opt->{topic}){
    debug('Changing the room topic to ' . $opt->{topic});
    $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.topic?access_token=' . $opt->{access_token};
    $json = {
      topic => $opt->{topic}
    };
    $resp = send_request({
      method  => 'PUT',
      uri     => $uri,
      content => to_json($json)
    });
    die "Error changing topic of room $opt->{room}\n"
      unless ($resp->is_success);
  }
  # Changing joining rules
  if ($opt->{join_rules}){
    debug('Changing the joining rules to '. $opt->{join_rules});
    $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.join_rules?access_token=' . $opt->{access_token};
    $json = {
      join_rules => $opt->{join_rules}
    };
    $resp = send_request({
      method  => 'PUT',
      uri     => $uri,
      content => to_json($json)
    });
    die "Error changing joining rules of room $opt->{room}\n"
      unless ($resp->is_success);
  }
  # Permissions modification
  if ($opt->{perm} || $opt->{perm_user} || $opt->{perm_event} || $opt->{perm_reset}){
    debug('Changing permissions for the room');
    my $current_perm = get_room_permissions();
    # If we asked to reset the permission
    if ($opt->{perm_reset}){
      my $operator = who_am_i();
      my $reset_perm = {
        events => {
          "m.room.avatar"             => 50,
          "m.room.canonical_alias"    => 50,
          "m.room.name"               => 50,
          "m.room.power_levels"       => 100,
          "m.room.history_visibility" => 100
        },
      };
      # Ensure we keep at least the permission of the operating user
      # Note that we must also keep the permission of anyone who has at least the same level
      # of privilege, or the operation will be forbidden
      foreach my $user (keys %{$current_perm->{users}}){
        if (looks_like_number($current_perm->{users}->{$user}) &&
            $current_perm->{users}->{$user} >= $current_perm->{users}->{$operator}){
          debug("Keeping permission of $user because it has at least the same privileges " .
                "($current_perm->{users}->{$user} vs $current_perm->{users}->{$operator})");
          $reset_perm->{users}->{$user} = $current_perm->{users}->{$user};
        }
      }
      $current_perm = $reset_perm;
    }
    my $new_perm = {};
    if ($opt->{perm}){
      foreach my $perm (@{$opt->{perm}}){
        my ($key,$val) = split (/\s*=\s*/, $perm);
        $new_perm->{$key} = $val;
      }
    }
    if ($opt->{perm_user}){
      foreach my $perm (@{$opt->{perm_user}}){
        my ($key,$val) = split (/\s*=\s*/, $perm);
        # Prevent the operating user to downgrade its own permissions
        next if ($key eq $opt->{user});
        $new_perm->{users}->{$key} = $val;
      }
    }
    if ($opt->{perm_event}){
      foreach my $perm (@{$opt->{perm_event}}){
        my ($key,$val) = split (/\s*=\s*/, $perm);
        $new_perm->{events}->{$key} = $val;
      }
    }
    my $perm = merge($current_perm, $new_perm);
    debug("New permissions for this room will be:\n" . to_json($perm, { pretty => 1 }));
    $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.power_levels?access_token=' . $opt->{access_token};
    $resp = send_request({
      method  => 'PUT',
      uri     => $uri,
      content => to_json($perm)
    });
    die "Error changing permissions for room $opt->{room}\n"
      unless ($resp->is_success);
  }
  # New invitees should be added
  if ($opt->{invite}){
    debug('Inviting ' . join(',', @{$opt->{invite}}) . ' to join the room');
    $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/invite?access_token=' . $opt->{access_token};
    foreach my $invite (@{$opt->{invite}}){
      $json = {
        user_id => $invite
      };
      $resp = send_request({
        uri     => $uri,
        content => to_json($json)
      });
      unless ($resp->is_success){
        my $error = from_json($resp->decoded_content);
        if ($error->{error} eq $invite . ' is already in the room.' && $error->{errcode} eq 'M_FORBIDDEN'){
          debug($invite . ' has already been invited in this room, ignoring');
        }
        else{
          die "Error inviting user $invite in room $opt->{room}\n";
        }
      }
    }
  }
}

sub del_room_alias {
  debug("Removing room alias $opt->{alias}");
  my $uri = $opt->{server} . "/_matrix/client/r0/directory/room/" . uri_escape($opt->{alias}) . '?access_token=' . $opt->{access_token};
  my $resp = send_request({
    method => 'DELETE',
    uri    => $uri
  });
  die "Error removing the alias\n" unless ($resp->is_success);
}

# Write settings in a config file
sub setup {
  $opt->{conf} //= File::HomeDir->my_home . "/.patrixrc";
  my $cfg = Config::Simple->new(syntax => 'ini');
  foreach my $param (qw(access_token server room)){
    $cfg->param($param, $opt->{$param}) if ($opt->{$param});
  }
  debug("Writing config file $opt->{conf}");
  $cfg->save($opt->{conf});
}

# If server is not specified, ask for it
if (!$opt->{server}){
  print "Matrix server: ";
  $opt->{server} = ReadLine(0);
  $opt->{server} =~ s/\R\z//;
}
# If not using an access token
# Prompt for the user ID and password
# if not provided on the command line or the config file
if (!$opt->{access_token}){
  if (!$opt->{user}){
    print "Matrix ID: ";
    $opt->{user} = ReadLine(0);
    $opt->{user} =~ s/\R\z//;
  }
  if (!$opt->{password}){
    ReadMode('noecho');
    print "Password: ";
    $opt->{password} = ReadLine(0);
    $opt->{password} =~ s/\R\z//;
    ReadMode('restore');
    print "\n";
  }
}

# Set defaults values
set_defaults();

# If the given room starts with #, then it's an alias
# Lets resolve this to the room ID
if ($opt->{room} && $opt->{room} =~ m/^#/){
  $opt->{room} = room_alias_to_id($opt->{room});
  debug('Room ID is ' . $opt->{room});
}

# If we ask for a new access token, then we must login, and ignore any
# access_token from the config file
$opt->{access_token} = undef if ($opt->{action} eq 'get-access-token');

# Should we logout at the end ? Only if we used login and pass
# If we used an access_token, we don't want it to be invalidated
my $must_logout = ($opt->{access_token} || ($opt->{action} eq 'get-access-token' ||
                                            $opt->{action} eq 'setup')) ? 0 : 1;

# If we don't have an access token, we must get one now
if (!$opt->{access_token}){
  login();
}
if ($opt->{action} eq 'setup'){
  setup();
}
if ($opt->{action} eq 'get-access-token'){
  print $opt->{access_token} . "\n";
}
elsif ($opt->{action} eq 'get-room-list'){
  list_room();
}
elsif ($opt->{action} eq 'get-room-id'){
  print $opt->{room} . "\n";
}
elsif ($opt->{action} =~ m/^send\-(msg|message|notice|code)$/){
  join_room();
  send_msg();
}
elsif ($opt->{action} eq 'send-file'){
  join_room();
  send_file($_) foreach (@{$opt->{file}});
}
elsif ($opt->{action} eq 'create-room'){
  create_room();
}
elsif ($opt->{action} eq 'modify-room'){
  modify_room();
}
elsif ($opt->{action} =~ m/^(remove|delete|del)\-room\-alias$/){
  del_room_alias();
}

logout() if $must_logout;

exit(0);
