require 'securerandom'
require 'spec_helper'
require 'sham_rack'

module Xfrtuc
  User = Struct.new(:name, :password)
  class FakeTransferatu
    attr_reader :groups

    def headers
      { content_type: 'application/json' }
    end

    def initialize(*users)
      @groups = []
      @transfers = {}
      @schedules = {}
      @users = users
    end

    def active_groups
      @groups.reject { |g| g[:deleted] }
    end

    def find_transfer(group_name, &block)
      @transfers[group_name].find(&block)
    end

    def last_transfer(group_name)
      @transfers[group_name].last
    end

    def last_schedule(group_name)
      @schedules[group_name].last
    end

    def add_group(name, log_url=nil)
      existing_group = @groups.find { |g| g[:name] == name }
      if existing_group
        if existing_group[:deleted]
          # undelete
          existing_group.delete(:deleted)
          [201, headers, [ existing_group.to_json ] ]
        else
          [409, headers, [ { id: :conflict, message: "group #{name} already exists" }.to_json ] ]
        end
      else
        group = { name: name, log_input_url: log_url }
        @groups << group
        @transfers[name] = []
        @schedules[name] = []
        [201, headers, [ group.to_json ] ]
      end
    end

    def delete_group(name)
      target = @groups.find { |g| g[:name] == name }
      if target && target[:deleted]
        [410, headers, []]
      elsif target.nil?
        [404, headers, []]
      else
        target[:deleted] = true
        [200, headers, [target.to_json]]
      end
    end

    def list_groups
      [200, headers, [@groups.to_json]]
    end

    def get_group(name)
      [200, headers, [@groups.find { |g| g[:name] == name }.to_json]]
    end

    def list_transfers(group_name)
      group = @groups.find { |g| g[:name] == group_name }
      if group.nil?
        [404, headers, []]
      elsif group[:deleted]
        [410, headers, []]
      else
        transfers = @transfers[group_name]
        [200, headers, [transfers.to_json]]
      end
    end

    def get_transfer(group_name, xfer_id, verbose=false)
      group = @groups.find { |g| g[:name] == group_name }
      if group.nil?
        [404, headers, []]
      elsif group[:deleted]
        [409, headers, []]
      else
        transfer = @transfers[group_name].find { |xfer| xfer[:uuid] == xfer_id }
        if verbose
          result = transfer.dup
          result[:logs] = []
          transfer = result
        end
        [200, headers, [transfer.to_json]]
      end
    end

    def add_transfer(group_name, transfer)
      unless @transfers.has_key? group_name
        return [404, headers, []]
      end
      xfer = { uuid: SecureRandom.uuid }
      %w(from_type from_url from_name to_type to_url to_name log_input_url).each do |key|
        xfer[key.to_sym] = transfer[key]
      end
      if transfer.has_key? 'num_keep'
        xfer[:num_keep] = transfer['num_keep']
      end

      @transfers[group_name] << xfer
      [201, {}, [xfer.to_json]]
    end

    def delete_transfer(group_name, xfer_id)
      unless @transfers.has_key? group_name
        return [404, headers, []]
      end
      xfer = @transfers[group_name].find { |item| item[:uuid] == xfer_id }
      if xfer.nil?
        return [404, headers, []]
      else
        @transfers[group_name].delete xfer
        return [200, headers, [ xfer.to_json ]]
      end
    end

    def take_transfer_action(action, args, group_name, xfer_id)
      unless @transfers.has_key? group_name
        return [404, headers, []]
      end
      xfer = @transfers[group_name].find { |item| item[:uuid] == xfer_id }
      if xfer.nil?
        return [404, headers, []]
      else
        case action
        when 'cancel' then
          now = Time.now
          xfer[:canceled_at] = now
          return [201, headers, [ { canceled_at: now }.to_json ]]
        when 'public-url' then
          expires_at = if args.has_key? 'ttl'
                         Time.now + args['ttl'].to_i
                       else
                         Time.now + (10 * 60)
                       end
          url = "https://example.com/backup/#{xfer[:uuid]}"
          return [201, headers, [ { url: url, expires_at: expires_at }.to_json ]]
        else
          return [404, headers, []]
        end
      end
    end

    def add_schedule(group_name, schedule)
      unless @schedules.has_key? group_name
        return [404, headers, []]
      end
      sched = { uuid: SecureRandom.uuid }
      %w(name callback_url days hour timezone).each do |key|
        sched[key.to_sym] = schedule[key]
      end
      %w(retain_weeks retain_months).each do |key|
        sched[key.to_sym] = schedule[key] if schedule.has_key? key
      end
      @schedules[group_name] << sched
      [201, {}, [sched.to_json]]
    end

    def delete_schedule(group_name, schedule_id)
      unless @schedules.has_key? group_name
        return [404, headers, []]
      end
      schedule = @schedules[group_name].find { |item| item[:uuid] == schedule_id }
      if schedule.nil?
        return [404, headers, []]
      else
        @schedules[group_name].delete schedule
        return [200, headers, [ schedule.to_json ]]
      end
    end

    def list_schedules(group_name)
      group = @groups.find { |g| g[:name] == group_name }
      if group.nil?
        [404, headers, []]
      elsif group[:deleted]
        [410, headers, []]
      else
        schedules = @schedules[group_name]
        [200, headers, [schedules.to_json]]
      end
    end

    def get_schedule(group_name, sched_id)
      group = @groups.find { |g| g[:name] == group_name }
      if group.nil?
        [404, headers, []]
      elsif group[:deleted]
        [410, headers, []]
      else
        sched = @schedules[group_name].find { |s| s[:uuid] == sched_id }
        if sched.nil?
          [404, headers, []]
        else
          [200, headers, [sched.to_json]]
        end
      end
    end

    def call(env)
      unless verify_auth(env)
        return [401, headers, ["Not authorized"]]
      end
      case path(env)
      when %r{/groups/[^/]+/transfers/[^/]+/actions} then
        transfers_actions_endpoint(env)
      when %r{/groups/[^/]+/transfers} then
        transfers_endpoint(env)
      when %r{/groups/[^/]+/schedules} then
        schedules_endpoint(env)
      when %r{/groups} then
        groups_endpoint(env)
      else
        [404, headers, []]
      end
    end

    def transfers_actions_endpoint(env)
      path = path(env)
      group_name, xfer_id, action =
        path.match(%r{\A/groups/(.*)/transfers/(.*)/actions/(.*)\z}) && [$1, $2, $3]
      verb = verb(env)
      if verb == 'POST'
        body = body(env)
        args = JSON.parse(body) unless body.empty?
        take_transfer_action(action, args, group_name, xfer_id)
      else
        [405, headers, []]
      end
    end

    def transfers_endpoint(env)
      path = path(env)
      group_name, xfer_id = path.match(%r{\A/groups/(.*)/transfers(?:/(.*))?\z}) && [$1, $2]
      verb = verb(env)
      if verb == 'POST'
        body = body(env)
        xfer = JSON.parse(body)
        unless xfer_id.nil?
          [405, headers, []]
        end
        add_transfer(group_name, xfer)
      elsif verb == 'DELETE'
        unless group_name && xfer_id
          return [404, headers, []]
        end
        delete_transfer(group_name, xfer_id)
      elsif verb == 'GET'
        if xfer_id.nil?
          list_transfers(group_name)
        else
          get_transfer(group_name, xfer_id, params(env)['verbose'] == 'true')
        end
      else
        [405, headers, []]
      end
    end

    def groups_endpoint(env)
      path = path(env)
      verb = verb(env)

      group_name = path.match(%r{\A/groups/(.*)\z}) && $1

      if verb == 'GET'
        if group_name.nil?
          list_groups
        else
          get_group(group_name)
        end
      elsif verb == 'POST'
        body = body(env)
        data = JSON.parse(body)
        add_group(data["name"], data["log_input_url"])
      elsif verb == 'DELETE'
        name = path.match(%r{\A/groups/(.*)\z}) && $1
        unless name
          return [404, headers, []]
        end
        delete_group(name)
      else
        [405, headers, []]
      end
    end

    def schedules_endpoint(env)
      path = path(env)
      group_name, sched_id = path.match(%r{\A/groups/(.*)/schedules(?:/(.*))?\z}) && [$1, $2]
      verb = verb(env)
      if %w(POST PUT).include? verb
        body = body(env)
        sched = JSON.parse(body)
        unless sched_id.nil?
          [405, headers, []]
        end
        add_schedule(group_name, sched)
      elsif verb == 'DELETE'
        unless group_name && sched_id
          return [404, headers, []]
        end
        delete_schedule(group_name, sched_id)
      elsif verb == 'GET'
        if sched_id.nil?
          list_schedules(group_name)
        else
          get_schedule(group_name, sched_id)
        end
      else
        [405, headers, []]
      end
    end

    private
    def verify_auth(env)
      auth = Rack::Auth::Basic::Request.new(env)
      if auth.provided? && auth.basic?
        user, password = auth.credentials
        @users.any? { |u| u.name == user && u.password == password }
      end
    end

    def path(rack_env)
      rack_env['PATH_INFO']
    end

    def verb(rack_env)
      rack_env['REQUEST_METHOD']
    end

    def params(rack_env)
      Rack::Utils.parse_nested_query rack_env['QUERY_STRING']
    end

    def body(rack_env)
      raw_body = rack_env["rack.input"].read
      rack_env["rack.input"].rewind
      raw_body
    end
  end

  describe Client do
    let(:username) { 'reginald' }
    let(:password) { 'hunter2' }
    let(:client)   { Client.new(username, password) }

    describe "#group" do
      context "with an argument" do
        let(:group_name) { 'foo' }

        it "returns a new client rooted at that group's base URL" do
          group_client = client.group(group_name)
          expect(group_client).to be_instance_of(Client)
          expect(group_client.base_url).to eq(client.base_url +
                                              "/groups/#{CGI.escape(group_name)}")
        end
      end

      context "without an argument" do
        it "returns a group client" do
          expect(client.group).to be_instance_of(Group)
        end
      end
    end

    describe "#transfer" do
      it "returns a transfer client" do
        expect(client.transfer).to be_instance_of(Transfer)
      end
    end

    describe "#schedule" do
      it "returns a schedule client" do
        expect(client.schedule).to be_instance_of(Schedule)
      end
    end
  end

  describe "api interactions" do
    let(:username)    { 'vivian' }
    let(:password)    { 'hunter2' }
    let(:user)        { User.new(username, password) }
    let(:fakesferatu) { FakeTransferatu.new(user) }
    let(:host)        { 'transferatu.example.com' }
    let(:client)      { Client.new(username, password, "https://#{host}") }

    before do
      ShamRack.at(host, 443).mount(fakesferatu)
    end

    after do
      ShamRack.unmount_all
    end

    describe Group do
      let(:group_name)    { "edna" }
      let(:log_input_url) { "https://token:t.foo@logplex.example.com/logs" }

      describe "#create" do
        it "creates a new group" do
          client.group.create("edna", log_input_url)
          group = fakesferatu.groups.last
          expect(group[:name]).to eq(group_name)
          expect(group[:log_input_url]).to eq(log_input_url)
        end
      end

      describe "#list" do
        before do
          fakesferatu.add_group('g1')
          fakesferatu.add_group('g2')
        end

        it "lists existing groups" do
          result = client.group.list
          expect(result.count).to eq(2)
          expect(result.first["name"]).to eq('g1')
          expect(result.last["name"]).to eq('g2')
        end
      end

      describe "#info" do
        before do
          fakesferatu.add_group(group_name, log_input_url)
        end

        it "returns details for the given group" do
          info = client.group.info(group_name)
          expect(info["name"]).to eq(group_name)
          expect(info["log_input_url"]).to eq(log_input_url)
        end
      end

      describe "#delete" do
        before do
          fakesferatu.add_group(group_name, log_input_url)
        end

        it "deletes the given group" do
          client.group.delete(group_name)
          deleted_group = fakesferatu.groups.find { |g| g[:name] == group_name }
          expect(deleted_group[:deleted]).to be true
        end
      end
    end

    describe Transfer do
      let(:g)         { "edna" }
      let(:xfer_data) { { from_url: 'postgres:///test1',
                         from_name: 'earl', from_type: 'pg_dump',
                         to_url: 'postgres:///test2',
                         to_name: 'mildred', to_type: 'pg_restore' } }

      before do
        fakesferatu.add_group(g)
      end

      describe "#create" do
        it "creates a new transfer" do
          client.group(g).transfer.create(xfer_data)
          xfer = fakesferatu.last_transfer(g)
          xfer_data.each do |k,v|
            expect(xfer[k]).to eq(v)
          end
        end

        it "accepts an optional log_input_url" do
          log_url = "https://example.com/logs"
          client.group(g).transfer.create(xfer_data.merge(log_input_url: log_url))
          xfer = fakesferatu.last_transfer(g)
          expect(xfer[:log_input_url]).to eq(log_url)
        end

        it "accepts an optional num_keep" do
          num_keep = 3
          client.group(g).transfer.create(xfer_data.merge(num_keep: num_keep))
          xfer = fakesferatu.last_transfer(g)
          expect(xfer[:num_keep]).to eq(num_keep)
        end
      end

      describe "#list" do
        before do
          2.times { fakesferatu.add_transfer(g, Hash[xfer_data.map { |k, v| [k.to_s, v] }]) }
        end

        it "lists existing transfers" do
          xfers = client.group(g).transfer.list
          expect(xfers.count).to eq(2)
          xfers.each do |xfer|
            xfer_data.each do |k,v|
              expect(xfer[k.to_s]).to eq(v)
            end
          end
        end
      end

      describe "#info" do
        before do
          fakesferatu.add_transfer(g, Hash[xfer_data.map { |k, v| [k.to_s, v] }])
        end

        it "gets info for an existing transfer" do
          id = fakesferatu.last_transfer(g)[:uuid]
          xfer = client.group(g).transfer.info(id)
          xfer_data.each do |k,v|
            expect(xfer[k.to_s]).to eq(v)
          end
          expect(xfer["logs"]).to be_nil
        end

        it "includes logs when verbose mode is requested" do
          id = fakesferatu.last_transfer(g)[:uuid]
          xfer = client.group(g).transfer.info(id, verbose: true)
          xfer_data.each do |k,v|
            expect(xfer[k.to_s]).to eq(v)
          end
          expect(xfer["logs"]).not_to be_nil
        end
      end

      describe "#delete" do
        before do
          fakesferatu.add_transfer(g, Hash[xfer_data.map { |k, v| [k.to_s, v] }])
        end

        it "deletes the given transfer" do
          id = fakesferatu.last_transfer(g)[:uuid]
          client.group(g).transfer.delete(id)
          expect(fakesferatu.last_transfer(g)).to be_nil
        end
      end

      describe "#cancel" do
        before do
          fakesferatu.add_transfer(g, Hash[xfer_data.map { |k, v| [k.to_s, v] }])
        end

        it "cancels the given transfer" do
          id = fakesferatu.last_transfer(g)[:uuid]
          before = Time.now
          cancel_data = client.group(g).transfer.cancel(id)
          canceled_at = Time.parse(cancel_data["canceled_at"])
          expect(canceled_at).to be_within(60).of(before)
        end
      end

      describe "#public_url" do
        before do
          fakesferatu.add_transfer(g, Hash[xfer_data.map { |k, v| [k.to_s, v] }])
        end

        it "provides a public url for the given transfer" do
          id = fakesferatu.last_transfer(g)[:uuid]
          url_data = client.group(g).transfer.public_url(id)
          expect { URI.parse(url_data["url"]) }.not_to raise_error
        end

        it "supports an optional ttl parameter" do
          id = fakesferatu.last_transfer(g)[:uuid]
          before = Time.now
          url_data = client.group(g).transfer.public_url(id, ttl: 5 * 60)
          expires_at = Time.parse(url_data["expires_at"])
          expect(expires_at).to be_within(60).of(before + (5 * 60))
        end
      end
    end

    describe Schedule do
      let(:g)          { "edna" }
      let(:sched_data) { { name: 'my schedule',
                          callback_url: 'https://example.com/callback/foo',
                          hour: 13,
                          days: Date::DAYNAMES,
                          timezone: 'UTC' } }

      before do
        fakesferatu.add_group(g)
      end

      describe "#create" do
        it "creates a new schedule" do
          client.group(g).schedule.create(sched_data)
          sched = fakesferatu.last_schedule(g)
          sched_data.each do |k,v|
            expect(sched[k]).to eq(v)
          end
        end

        it "accepts an optional retain_weeks and retain_months" do
          retain_weeks = 7
          retain_months = 8
          client.group(g).schedule.create(sched_data.merge(retain_weeks: retain_weeks,
                                                           retain_months: retain_months))
          sched = fakesferatu.last_schedule(g)
          expect(sched[:retain_weeks]).to eq(retain_weeks)
          expect(sched[:retain_months]).to eq(retain_months)
        end
      end

      describe "#list" do
        before do
          2.times { fakesferatu.add_schedule(g, Hash[sched_data.map { |k, v| [k.to_s, v] }]) }
        end

        it "lists existing schedules" do
          scheds = client.group(g).schedule.list
          expect(scheds.count).to eq(2)
          scheds.each do |sched|
            sched_data.each do |k,v|
              expect(sched[k.to_s]).to eq(v)
            end
          end
        end
      end

      describe "#info" do
        before do
          fakesferatu.add_schedule(g, Hash[sched_data.map { |k, v| [k.to_s, v] }])
        end

        it "gets info for an existing schedule" do
          id = fakesferatu.last_schedule(g)[:uuid]
          sched = client.group(g).schedule.info(id)
          sched_data.each do |k,v|
            expect(sched[k.to_s]).to eq(v)
          end
        end
      end

      describe "#delete" do
        before do
          fakesferatu.add_schedule(g, Hash[sched_data.map { |k, v| [k.to_s, v] }])
        end

        it "deletes the given schedule" do
          id = fakesferatu.last_schedule(g)[:uuid]
          client.group(g).schedule.delete(id)
          expect(fakesferatu.last_schedule(g)).to be_nil
        end
      end
    end
  end
end
