/*
 * -\-\-
 * Spotify Styx Scheduler Service
 * --
 * Copyright (C) 2017 Spotify AB
 * --
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * -/-/-
 */

package com.spotify.styx.cli;

import static com.spotify.futures.CompletableFutures.exceptionallyCompletedFuture;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;

import com.google.common.base.Throwables;
import com.google.common.io.Resources;
import com.spotify.styx.api.BackfillPayload;
import com.spotify.styx.api.BackfillsPayload;
import com.spotify.styx.api.RunStateDataPayload;
import com.spotify.styx.api.TestServiceAccountUsageAuthorizationResponse;
import com.spotify.styx.cli.CliExitException.ExitStatus;
import com.spotify.styx.cli.CliMain.CliContext;
import com.spotify.styx.cli.CliMain.CliContext.Output;
import com.spotify.styx.client.ApiErrorException;
import com.spotify.styx.client.ClientErrorException;
import com.spotify.styx.client.StyxClient;
import com.spotify.styx.model.Backfill;
import com.spotify.styx.model.BackfillInput;
import com.spotify.styx.model.Schedule;
import com.spotify.styx.model.TriggerParameters;
import com.spotify.styx.model.Workflow;
import com.spotify.styx.model.WorkflowConfiguration;
import com.spotify.styx.model.WorkflowId;
import com.spotify.styx.model.WorkflowState;
import com.spotify.styx.model.WorkflowWithState;
import com.spotify.styx.serialization.Json;
import com.spotify.styx.util.WorkflowValidator;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ConnectException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import javaslang.control.Try;
import junitparams.JUnitParamsRunner;
import junitparams.Parameters;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

@RunWith(JUnitParamsRunner.class)
public class CliMainTest {

  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();

  @Mock CliContext cliContext;
  @Mock StyxClient client;
  @Mock CliOutput cliOutput;
  @Mock WorkflowValidator validator;

  private String requestId = UUID.randomUUID().toString();
  private static final Instant currentTime = Instant.parse("2019-01-01T00:00:00Z");

  @Before
  public void setUp() {
    MockitoAnnotations.initMocks(this);

    when(cliContext.workflowValidator()).thenReturn(validator);
    when(validator.validateWorkflow(any())).thenReturn(Collections.emptyList());
    when(cliContext.createClient(any())).thenReturn(client);
    when(cliContext.output(any())).thenReturn(cliOutput);
    when(cliContext.env()).thenReturn(
        Map.of("STYX_CLI_HOST", "https://styx.foo.bar:4711"));
  }

  @Test
  public void testList() {
    final RunStateDataPayload payload = mock(RunStateDataPayload.class);
    when(client.activeStates(Optional.empty()))
        .thenReturn(CompletableFuture.completedFuture(payload));
    CliMain.run(cliContext, "ls");
    verify(client).activeStates(Optional.empty());
    verify(cliOutput).printStates(payload);
  }

  @Test
  public void testWorkflowList() {
    @SuppressWarnings("unchecked")
    final List<Workflow> payload = mock(List.class);
    when(client.workflows())
        .thenReturn(CompletableFuture.completedFuture(payload));
    CliMain.run(cliContext, "workflow", "ls");
    verify(client).workflows();
    verify(cliOutput).printWorkflows(payload);
  }

  @Test
  public void testWorkflowShow() {
    var payload = mock(WorkflowWithState.class);
    when(client.workflowWithState("foo", "bar"))
        .thenReturn(CompletableFuture.completedFuture(payload));
    CliMain.run(cliContext, "workflow", "show", "foo", "bar");
    verify(client).workflowWithState("foo", "bar");
    verify(cliOutput).printWorkflow(payload.workflow(), payload.state());
  }

  @Test
  public void testWorkflowCreate() throws Exception {
    final String component = "quux";

    final Path workflowsFile = fileFromResource("workflows.yaml");
    final List<WorkflowConfiguration> expected = Json.YAML_MAPPER
        .reader().forType(WorkflowConfiguration.class)
        .<WorkflowConfiguration>readValues(workflowsFile.toFile())
        .readAll();
    assertThat(expected, is(not(Matchers.empty())));

    when(client.createOrUpdateWorkflow(any(), any())).thenAnswer(a -> {
      final String comp = a.getArgument(0);
      final WorkflowConfiguration wfConfig = a.getArgument(1);
      return CompletableFuture.completedFuture(Workflow.create(comp, wfConfig));
    });

    CliMain.run(cliContext, "workflow", "create", component, "-f", workflowsFile.toString());

    for (WorkflowConfiguration workflowConfiguration : expected) {
      verify(client).createOrUpdateWorkflow(component, workflowConfiguration);
      verify(cliOutput).printMessage("Workflow " + workflowConfiguration.id() + " in component "
          + component + " created.");
    }
  }

  @Test
  public void testWorkflowCreateInvalidStructure() throws Exception {
    final Path workflowsFile = fileFromResource("wf-missing-schedule.yaml");
    try {
      CliMain.run(cliContext, "workflow", "create", "-f", workflowsFile.toString(), "foo");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.InputError));
    }
    verify(cliOutput).printError(
        "Workflow configuration doesn't conform to the expected structure, problem: "
            + "schedule\n at [Source: (File); line: 4, column: 1]\n"
            + "Minimal valid example: \n"
            + "========================\n"
            + "id: bar\n"
            + "schedule: DAYS\n"
            + "docker_image: busybox\n"
            + "docker_args: [echo, bar]\n"
            + "========================\n");
  }

  @Test
  public void testWorkflowCreateBadInput() throws Exception {
    final Path workflowsFile = fileFromResource("bad-content.yaml");
    try {
      CliMain.run(cliContext, "workflow", "create", "-f", workflowsFile.toString(), "foo");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.InputError));
    }
    verify(cliOutput).printError(contains(
        "Workflow configuration doesn't conform to the expected structure, "));
  }

  @Test
  public void testWorkflowCreateInvalid() throws Exception {
    final String component = "quux";

    final Path workflowsFile = fileFromResource("workflows.yaml");
    final List<WorkflowConfiguration> expected = Json.YAML_MAPPER
        .reader().forType(WorkflowConfiguration.class)
        .<WorkflowConfiguration>readValues(workflowsFile.toFile())
        .readAll();
    assertThat(expected, is(not(Matchers.empty())));

    for (WorkflowConfiguration configuration : expected) {
      var workflow = Workflow.create(component, configuration);
      when(validator.validateWorkflow(workflow)).thenReturn(
          List.of("bad-" + configuration.id(), "cfg-" + configuration.id()));
    }

    try {
      CliMain.run(cliContext, "workflow", "create", component, "-f", workflowsFile.toString());
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ArgumentError));
    }

    for (WorkflowConfiguration configuration : expected) {
      verify(cliOutput).printError("Invalid workflow configuration: " + configuration.id());
      verify(cliOutput).printError("  error: bad-" + configuration.id());
      verify(cliOutput).printError("  error: cfg-" + configuration.id());
    }

    verify(client, never()).createOrUpdateWorkflow(any(), any());
  }

  @Test
  public void testWorkflowDelete() {
    final String component = "quux";

    when(client.deleteWorkflow(any(), any())).thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "workflow", "delete", component, "foo", "bar");

    verify(client).deleteWorkflow(component, "foo");
    verify(client).deleteWorkflow(component, "bar");

    verify(cliOutput).printMessage("Workflow foo in component " + component + " deleted.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " deleted.");
  }

  @Test
  public void testBackfillCreate() {
    final String component = "quux";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, "foo"))
        .concurrency(1)
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();

    final BackfillInput expectedInput = BackfillInput.newBuilder()
        .component(backfill.workflowId().componentId())
        .workflow(backfill.workflowId().id())
        .start(backfill.start())
        .end(backfill.end())
        .reverse(backfill.reverse())
        .concurrency(backfill.concurrency())
        .triggerParameters(TriggerParameters.zero())
        .build();

    when(client.backfillCreate(expectedInput, false))
        .thenReturn(CompletableFuture.completedFuture(backfill));

    CliMain.run(cliContext, "backfill", "create", component, "foo", "2017-01-01", "2017-01-30",
        "1");

    verify(client).backfillCreate(expectedInput, false);
    verify(cliOutput).printBackfill(backfill, true);
  }

  @Test
  public void testBackfillCreateWithEnv() {
    final String component = "quux";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final TriggerParameters expectedTriggerParameters = TriggerParameters.builder()
        .env("FOO", "foo",
            "BAR", "bar",
            "BAZ", "baz",
            "FOOBAR", "")
        .build();
    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, "foo"))
        .concurrency(1)
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .triggerParameters(expectedTriggerParameters)
        .created(currentTime)
        .lastModified(currentTime)
        .build();

    final BackfillInput expectedInput = BackfillInput.newBuilder()
        .component(backfill.workflowId().componentId())
        .workflow(backfill.workflowId().id())
        .start(backfill.start())
        .end(backfill.end())
        .reverse(backfill.reverse())
        .concurrency(backfill.concurrency())
        .triggerParameters(expectedTriggerParameters)
        .build();

    when(client.backfillCreate(expectedInput, false))
        .thenReturn(CompletableFuture.completedFuture(backfill));

    CliMain.run(cliContext, "backfill", "create", "-e", "FOO=foo", component, "foo", "2017-01-01", "2017-01-30",
        "1", "-e", "BAR=bar", "--env", "BAZ=baz", "-e", "FOOBAR=");

    verify(client).backfillCreate(expectedInput, false);
    verify(cliOutput).printBackfill(backfill, true);
  }

  @Test
  public void testBackfillCreateWithInvalidEnv() {
    final String component = "quux";

    try {
      CliMain.run(cliContext, "backfill", "create", "-e", "FOO=foo", component, "foo", "2017-01-01", "2017-01-30",
          "1", "-e", "BAR=bar", "--env", "BAZ=baz", "-e", "FOOBAR");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.UnknownError));
    }
  }

  @Test
  public void testBackfillCreateReverse() {
    final String component = "quux";
    final Instant start = Instant.parse("2017-01-01T00:00:00Z");
    final Instant end = Instant.parse("2017-01-30T00:00:00Z");

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(start)
        .end(end)
        .reverse(true)
        .workflowId(WorkflowId.create(component, "foo"))
        .concurrency(1)
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();

    final BackfillInput expectedInput = BackfillInput.newBuilder()
        .component(backfill.workflowId().componentId())
        .workflow(backfill.workflowId().id())
        .start(backfill.start())
        .end(backfill.end())
        .reverse(backfill.reverse())
        .concurrency(backfill.concurrency())
        .triggerParameters(TriggerParameters.zero())
        .build();

    when(client.backfillCreate(expectedInput, false))
        .thenReturn(CompletableFuture.completedFuture(backfill));

    CliMain.run(cliContext, "backfill", "create", component, "foo", "2017-01-01", "2017-01-30", "1", "--reverse");

    verify(client).backfillCreate(expectedInput, false);
    verify(cliOutput).printBackfill(backfill, true);
  }

  @Test
  public void testBackfillCreateWithDescription() {
    final String component = "quux";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, "foo"))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();

    final BackfillInput expectedInput = BackfillInput.newBuilder()
        .component(backfill.workflowId().componentId())
        .workflow(backfill.workflowId().id())
        .start(backfill.start())
        .end(backfill.end())
        .concurrency(backfill.concurrency())
        .description("Description")
        .triggerParameters(TriggerParameters.zero())
        .build();

    when(client.backfillCreate(expectedInput, false))
        .thenReturn(CompletableFuture.completedFuture(backfill));

    CliMain.run(cliContext, "backfill", "create", component, "foo", "2017-01-01", "2017-01-30", "1",
        "-d", "Description");

    verify(client).backfillCreate(expectedInput, false);
    verify(cliOutput).printBackfill(backfill, true);
  }

  @Test
  public void testBackfillCreateAllowFuture() {
    final String component = "quux";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, "foo"))
        .concurrency(1)
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();

    final BackfillInput expectedInput = BackfillInput.newBuilder()
        .component(backfill.workflowId().componentId())
        .workflow(backfill.workflowId().id())
        .start(backfill.start())
        .end(backfill.end())
        .reverse(backfill.reverse())
        .concurrency(backfill.concurrency())
        .triggerParameters(TriggerParameters.zero())
        .build();

    when(client.backfillCreate(expectedInput, true))
        .thenReturn(CompletableFuture.completedFuture(backfill));

    CliMain.run(cliContext, "backfill", "create", component, "foo", "2017-01-01", "2017-01-30",
        "1", "--allow-future");

    verify(client).backfillCreate(expectedInput, true);
    verify(cliOutput).printBackfill(backfill, true);
  }

  @Test
  public void testBackfillShow() {
    final String backfillId = "backfill-2";

    final Backfill backfill = Backfill.newBuilder()
        .id(backfillId)
        .start(Instant.parse("2017-01-01T00:00:00Z"))
        .end(Instant.parse("2017-01-30T00:00:00Z"))
        .workflowId(WorkflowId.create("quux", backfillId))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();
    
    final BackfillPayload backfillPayload = BackfillPayload.create(backfill,
        Optional.empty());

    when(client.backfill(backfillId, true))
        .thenReturn(CompletableFuture.completedFuture(backfillPayload));

    CliMain.run(cliContext, "backfill", "show", backfillId, "--no-trunc");
    
    verify(client).backfill(backfillId, true);
    verify(cliOutput).printBackfillPayload(backfillPayload, true);
  }

  @Test
  public void testBackfillShowTruncating() {
    final String backfillId = "backfill-2";

    final Backfill backfill = Backfill.newBuilder()
        .id(backfillId)
        .start(Instant.parse("2017-01-01T00:00:00Z"))
        .end(Instant.parse("2017-01-30T00:00:00Z"))
        .workflowId(WorkflowId.create("quux", backfillId))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();
    
    final BackfillPayload backfillPayload = BackfillPayload.create(backfill,
        Optional.empty());

    when(client.backfill(backfillId, true))
        .thenReturn(CompletableFuture.completedFuture(backfillPayload));

    CliMain.run(cliContext, "backfill", "show", backfillId);
    
    verify(client).backfill(backfillId, true);
    verify(cliOutput).printBackfillPayload(backfillPayload, false);
  }

  @Test
  public void testBackfillEdit() {
    final String backfillId = "backfill-2";

    final Backfill backfill = Backfill.newBuilder()
        .id(backfillId)
        .start(Instant.parse("2017-01-01T00:00:00Z"))
        .end(Instant.parse("2017-01-30T00:00:00Z"))
        .workflowId(WorkflowId.create("quux", backfillId))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();

    when(client.backfillEditConcurrency(backfillId, 1))
        .thenReturn(CompletableFuture.completedFuture(backfill));

    CliMain.run(cliContext, "backfill", "edit", backfillId, "--concurrency", "1");

    verify(client).backfillEditConcurrency(backfillId, 1);
    verify(cliOutput).printBackfill(backfill, true);
  }

  @Test
  public void testBackfillList() {
    final String component = "quux";
    final String workflow = "foo";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, workflow))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();
    
    final BackfillsPayload backfillsPayload = BackfillsPayload.create(
        List.of(BackfillPayload.create(backfill, Optional.empty())));

    when(client.backfillList(Optional.of(component), Optional.of(workflow), false, false))
        .thenReturn(CompletableFuture.completedFuture(backfillsPayload));

    CliMain.run(cliContext, "backfill", "list", "-c", component, "-w", workflow, "--no-trunc");

    verify(client).backfillList(Optional.of(component), Optional.of(workflow), false, false);
    verify(cliOutput).printBackfills(backfillsPayload.backfills(), true);
  }

  @Test
  public void testBackfillListWithoutCreatedTS() {
    final String component = "quux";
    final String workflow = "foo";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, workflow))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .build();

    final BackfillsPayload backfillsPayload = BackfillsPayload.create(
        List.of(BackfillPayload.create(backfill, Optional.empty())));

    when(client.backfillList(Optional.of(component), Optional.of(workflow), false, false))
        .thenReturn(CompletableFuture.completedFuture(backfillsPayload));

    CliMain.run(cliContext, "backfill", "list", "-c", component, "-w", workflow, "--no-trunc");

    verify(client).backfillList(Optional.of(component), Optional.of(workflow), false, false);
    verify(cliOutput).printBackfills(backfillsPayload.backfills(), true);
  }

  @Test
  public void testBackfillListTruncating() {
    final String component = "quux";
    final String workflow = "foo";
    final String start = "2017-01-01T00:00:00Z";
    final String end = "2017-01-30T00:00:00Z";

    final Backfill backfill = Backfill.newBuilder()
        .id("backfill-2")
        .start(Instant.parse(start))
        .end(Instant.parse(end))
        .workflowId(WorkflowId.create(component, workflow))
        .concurrency(1)
        .description("Description")
        .nextTrigger(Instant.parse("2017-01-01T00:00:00Z"))
        .schedule(Schedule.DAYS)
        .created(currentTime)
        .lastModified(currentTime)
        .build();
    
    final BackfillsPayload backfillsPayload = BackfillsPayload.create(
        List.of(BackfillPayload.create(backfill, Optional.empty())));

    when(client.backfillList(Optional.of(component), Optional.of(workflow), false, false))
        .thenReturn(CompletableFuture.completedFuture(backfillsPayload));

    CliMain.run(cliContext, "backfill", "list", "-c", component, "-w", workflow);

    verify(client).backfillList(Optional.of(component), Optional.of(workflow), false, false);
    verify(cliOutput).printBackfills(backfillsPayload.backfills(), false);
  }

  @Test
  public void testBackfillHalt() {
    var backfillId = "backfill-2";

    when(client.backfillHalt(backfillId, false))
        .thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "backfill", "halt", backfillId);

    verify(client).backfillHalt(backfillId, false);
  }

  @Test
  public void testBackfillHaltGracefully() {
    var backfillId = "backfill-2";

    when(client.backfillHalt(backfillId, true))
        .thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "backfill", "halt", backfillId, "--graceful");

    verify(client).backfillHalt(backfillId, true);
  }

  @Test
  @Parameters({
      "n",
      "N",
      "Y",
      "",
      "  ",
      "dfgdfgd",
  })
  public void testWorkflowDeleteInteractiveNo(String reply) {
    when(cliContext.hasConsole()).thenReturn(true);
    when(cliContext.consoleReadLine(any())).thenReturn(reply);

    try {
      CliMain.run(cliContext, "workflow", "delete", "quux", "foo", "bar");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.UnknownError));
    }

    verify(cliContext).consoleReadLine(
        "Sure you want to delete the workflows foo, bar in component quux? [y/N] ");
  }

  @Test
  @Parameters({
      "y",
      " y",
      "y ",
      " y ",
  })
  public void testWorkflowDeleteInteractiveYes(String reply) {
    final String component = "quux";

    when(cliContext.hasConsole()).thenReturn(true);
    when(cliContext.consoleReadLine(any())).thenReturn(reply);

    when(client.deleteWorkflow(any(), any())).thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "workflow", "delete", component, "foo", "bar");

    verify(cliContext).consoleReadLine(
        "Sure you want to delete the workflows foo, bar in component quux? [y/N] ");

    verify(client).deleteWorkflow(component, "foo");
    verify(client).deleteWorkflow(component, "bar");

    verify(cliOutput).printMessage("Workflow foo in component " + component + " deleted.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " deleted.");
  }

  @Test
  public void testWorkflowDeleteInteractiveForce() {
    final String component = "quux";

    when(cliContext.hasConsole()).thenReturn(true);

    when(client.deleteWorkflow(any(), any())).thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "workflow", "delete", component, "foo", "bar", "--force");

    verify(cliContext, never()).consoleReadLine(any());

    verify(client).deleteWorkflow(component, "foo");
    verify(client).deleteWorkflow(component, "bar");

    verify(cliOutput).printMessage("Workflow foo in component " + component + " deleted.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " deleted.");
  }

  @Test
  public void testWorkflowEnable() {
    final String component = "quux";
    final WorkflowState workflowState = WorkflowState.builder()
        .enabled(true)
        .build();

    when(client.updateWorkflowState(any(), any(), eq(workflowState)))
        .thenReturn(CompletableFuture.completedFuture(workflowState));

    CliMain.run(cliContext, "workflow", "enable", component, "foo", "bar");

    verify(client).updateWorkflowState(component, "foo", workflowState);
    verify(client).updateWorkflowState(component, "bar", workflowState);
    verify(cliOutput).printMessage("Workflow foo in component " + component + " enabled.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " enabled.");
  }

  @Test
  public void testWorkflowDisable() {
    final String component = "quux";
    final WorkflowState workflowState = WorkflowState.builder()
        .enabled(false)
        .build();

    when(client.updateWorkflowState(any(), any(), eq(workflowState)))
        .thenReturn(CompletableFuture.completedFuture(workflowState));

    CliMain.run(cliContext, "workflow", "disable", component, "foo", "bar");

    verify(client).updateWorkflowState(component, "foo", workflowState);
    verify(client).updateWorkflowState(component, "bar", workflowState);
    verify(cliOutput).printMessage("Workflow foo in component " + component + " disabled.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " disabled.");
  }

  @Test
  public void shouldHandleWorkflowNotFoundWhenEnabling() {
    final String component = "quux";
    final WorkflowState workflowState = WorkflowState.builder()
        .enabled(true)
        .build();

    final ApiErrorException exception = new ApiErrorException("not found", 404, true, requestId);
    when(client.updateWorkflowState(any(), any(), eq(workflowState)))
        .thenReturn(exceptionallyCompletedFuture(exception));

    CliMain.run(cliContext, "workflow", "enable", component, "foo", "bar");
    verify(cliOutput).printMessage("Workflow foo in component " + component + " not found.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " not found.");
  }

  @Test
  public void shouldHandleWorkflowNotFoundWhenDisabling() {
    final String component = "quux";
    final WorkflowState workflowState = WorkflowState.builder()
        .enabled(false)
        .build();

    final ApiErrorException exception = new ApiErrorException("not found", 404, true, requestId);
    when(client.updateWorkflowState(any(), any(), eq(workflowState)))
        .thenReturn(exceptionallyCompletedFuture(exception));

    CliMain.run(cliContext, "workflow", "disable", component, "foo", "bar");
    verify(cliOutput).printMessage("Workflow foo in component " + component + " not found.");
    verify(cliOutput).printMessage("Workflow bar in component " + component + " not found.");
  }

  @Test
  public void testClientError() {
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenReturn(exceptionallyCompletedFuture(
            new ClientErrorException("foo failure",
                new IOException("bar failure",
                    new ConnectException("failed to connect to baz")))));

    try {
      CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ClientError));
    }

    verify(cliOutput).printError("Client error: foo failure: ConnectException: failed to connect to baz");
  }

  @Test
  public void testClientErrorDebug() {
    final Throwable cause = new ClientErrorException("foo failure",
        new IOException("bar failure",
            new ConnectException("failed to connect to baz")));
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenReturn(exceptionallyCompletedFuture(cause));

    try {
      CliMain.run(cliContext, "--debug", "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ClientError));
    }

    verify(cliOutput).printError(Throwables.getStackTraceAsString(cause));
    verify(cliOutput).printError("Client error: foo failure: ConnectException: failed to connect to baz");
  }

  @Test
  public void testApiError() {
    final ApiErrorException exception = new ApiErrorException("bar failure", 500, true, requestId);
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenReturn(exceptionallyCompletedFuture(exception));

    try {
      CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ApiError));
    }

    verify(cliOutput).printError("API error: " + exception.getMessage());
  }

  @Test
  public void testClientUnknownError() {
    final Exception exception = new UnsupportedOperationException();
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenReturn(exceptionallyCompletedFuture(exception));

    try {
      CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ClientError));
    }

    verify(cliOutput).printError(Throwables.getStackTraceAsString(exception));
  }


  @Test
  public void testUnknownError() {
    final Exception exception = new UnsupportedOperationException();
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenThrow(exception);

    try {
      CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.UnknownError));
    }

    verify(cliOutput).printError(Throwables.getStackTraceAsString(exception));
  }

  @Test
  public void testMissingCredentialsHelpMessage() {
    final ApiErrorException apiError = new ApiErrorException("foo", 401, false, requestId);
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenReturn(exceptionallyCompletedFuture(apiError));

    try {
      CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.AuthError));
    }

    verify(cliOutput).printError(contains("gcloud auth application-default login"));
    verify(cliOutput).printError(contains(apiError.getMessage()));
  }

  @Test
  public void testUnauthorizedMessage() {
    final ApiErrorException apiError = new ApiErrorException("foo", 401, true, requestId);
    when(client.triggerWorkflowInstance(any(), any(), any(), any(), eq(false)))
        .thenReturn(exceptionallyCompletedFuture(apiError));

    try {
      CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.AuthError));
    }

    verify(cliOutput).printError("API error: Unauthorized: " + apiError.getMessage());
  }

  @Test
  public void testHelp() {
    try {
      CliMain.run(cliContext, "--help");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.Success));
    }

    try {
      CliMain.run(cliContext);
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.Success));
    }

    verifyZeroInteractions(client);
  }

  @Test
  public void testArgumentError() {
    try {
      CliMain.run(cliContext, "foozbarz");
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ArgumentError));
    }

    verifyZeroInteractions(client);
  }

  @Test
  public void testTrigger() {
    final TriggerParameters expectedParameters = TriggerParameters.zero();
    when(client.triggerWorkflowInstance("foo", "bar", "2017-01-02", expectedParameters, false))
        .thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02");

    verify(client).triggerWorkflowInstance("foo", "bar", "2017-01-02", expectedParameters, false);
  }

  @Test
  public void testTriggerWithEnv() {
    final TriggerParameters expectedParameters = TriggerParameters.builder()
        .env("FOO", "foo",
            "BAR", "bar",
            "BAZ", "baz")
        .build();
    when(client.triggerWorkflowInstance("foo", "bar", "2017-01-02", expectedParameters, false))
        .thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "t", "-e", "FOO=foo", "foo", "bar", "2017-01-02", "-e", "BAR=bar", "--env", "BAZ=baz");

    verify(client).triggerWorkflowInstance("foo", "bar", "2017-01-02", expectedParameters, false);
  }

  @Test
  public void testTriggerAllowFuture() {
    final TriggerParameters expectedParameters = TriggerParameters.zero();
    when(client.triggerWorkflowInstance("foo", "bar", "2017-01-02", expectedParameters, true))
        .thenReturn(CompletableFuture.completedFuture(null));

    CliMain.run(cliContext, "t", "foo", "bar", "2017-01-02", "--allow-future");

    verify(client).triggerWorkflowInstance("foo", "bar", "2017-01-02", expectedParameters, true);
  }

  @Test
  @Parameters({
      "--json workflow ls",
      "workflow --json ls",
      "workflow ls --json",
  })
  public void testJsonOptionIsGlobal(final String argLine) {
    when(client.workflows()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
    CliMain.run(cliContext, argLine.split(" "));
    verify(cliContext).output(Output.JSON);
  }

  @Test
  @Parameters({
      "--plain workflow ls",
      "workflow --plain ls",
      "workflow ls --plain",
  })
  public void testPlainOptionIsGlobal(final String argLine) {
    when(client.workflows()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
    CliMain.run(cliContext, argLine.split(" "));
    verify(cliContext).output(Output.PLAIN);
  }

  @Test
  @Parameters({
      "--debug workflow ls",
      "workflow --debug ls",
      "workflow ls --debug",
  })
  public void testDebugOptionIsGlobal(final String argLine) {
    when(client.workflows()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
    assertThat(Try.run(() -> CliMain.run(cliContext, argLine.split(" "))).isSuccess(), is(true));
  }

  @Test
  @Parameters({
      "--host https://foo.bar workflow ls",
      "workflow --host https://foo.bar ls",
      "workflow ls --host https://foo.bar",
  })
  public void testHostOptionIsGlobal(final String argLine) {
    when(client.workflows()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
    CliMain.run(cliContext, argLine.split(" "));
    verify(cliContext).createClient("https://foo.bar");
  }

  @Test
  public void testAuthTestServiceAccountUsageSuccess() {
    final String serviceAccount = "[email protected]";
    final String principal = "[email protected]";

    final TestServiceAccountUsageAuthorizationResponse response = TestServiceAccountUsageAuthorizationResponse.builder()
        .authorized(true)
        .serviceAccount(serviceAccount)
        .principal(principal)
        .message("foobar")
        .build();

    when(client.testServiceAccountUsageAuthorization(serviceAccount, principal))
        .thenReturn(CompletableFuture.completedFuture(response));

    CliMain.run(cliContext, "auth", "test", "--service-account", serviceAccount, "--principal", principal);

    verify(client).testServiceAccountUsageAuthorization(serviceAccount, principal);
    verify(cliOutput).printMessage("The principal " + principal + " is authorized to use the service account "
                                   + serviceAccount + ". " + response.message().get());
  }

  @Test
  public void testAuthTestServiceAccountUsageFailure() {
    final String serviceAccount = "[email protected]";
    final String principal = "[email protected]";

    final TestServiceAccountUsageAuthorizationResponse response = TestServiceAccountUsageAuthorizationResponse.builder()
        .authorized(false)
        .serviceAccount(serviceAccount)
        .principal(principal)
        .message("foobar")
        .build();

    when(client.testServiceAccountUsageAuthorization(serviceAccount, principal))
        .thenReturn(CompletableFuture.completedFuture(response));

    try {
      CliMain.run(cliContext, "auth", "test", "--service-account", serviceAccount, "--principal", principal);
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.UnknownError));
    }

    verify(client).testServiceAccountUsageAuthorization(serviceAccount, principal);
    verify(cliOutput).printMessage("The principal " + principal + " is not authorized to use the service account "
                                   + serviceAccount + ". " + response.message().orElse(""));
  }

  @Test
  @Parameters({
      "auth test --service-account [email protected]",
      "auth test --principal [email protected]",
      "auth test"
  })
  public void testAuthTestServiceAccountUsageMissingRequiredArgument(final String argLine) {
    when(client.workflows()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
    try {
      CliMain.run(cliContext, argLine.split(" "));
      fail();
    } catch (CliExitException e) {
      assertThat(e.status(), is(ExitStatus.ArgumentError));
    }
  }

  private Path fileFromResource(String name) throws IOException {
    final File workflowsFile = temporaryFolder.newFile();
    try (OutputStream os = Files.newOutputStream(workflowsFile.toPath())) {
      Resources.copy(Resources.getResource(this.getClass(), name), os);
    }
    return workflowsFile.toPath();
  }
}