/*
 * Copyright 2018-2019 the original author or authors.
 *
 * 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
 *
 *      https://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 org.springframework.cloud.deployer.spi.scheduler.cloudfoundry;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import io.pivotal.scheduler.SchedulerClient;
import io.pivotal.scheduler.v1.Pagination;
import io.pivotal.scheduler.v1.calls.Calls;
import io.pivotal.scheduler.v1.jobs.CreateJobRequest;
import io.pivotal.scheduler.v1.jobs.CreateJobResponse;
import io.pivotal.scheduler.v1.jobs.DeleteJobRequest;
import io.pivotal.scheduler.v1.jobs.DeleteJobScheduleRequest;
import io.pivotal.scheduler.v1.jobs.ExecuteJobRequest;
import io.pivotal.scheduler.v1.jobs.ExecuteJobResponse;
import io.pivotal.scheduler.v1.jobs.GetJobRequest;
import io.pivotal.scheduler.v1.jobs.GetJobResponse;
import io.pivotal.scheduler.v1.jobs.Job;
import io.pivotal.scheduler.v1.jobs.JobSchedule;
import io.pivotal.scheduler.v1.jobs.Jobs;
import io.pivotal.scheduler.v1.jobs.ListJobHistoriesRequest;
import io.pivotal.scheduler.v1.jobs.ListJobHistoriesResponse;
import io.pivotal.scheduler.v1.jobs.ListJobScheduleHistoriesRequest;
import io.pivotal.scheduler.v1.jobs.ListJobScheduleHistoriesResponse;
import io.pivotal.scheduler.v1.jobs.ListJobSchedulesRequest;
import io.pivotal.scheduler.v1.jobs.ListJobSchedulesResponse;
import io.pivotal.scheduler.v1.jobs.ListJobsRequest;
import io.pivotal.scheduler.v1.jobs.ListJobsResponse;
import io.pivotal.scheduler.v1.jobs.ScheduleJobRequest;
import io.pivotal.scheduler.v1.jobs.ScheduleJobResponse;
import io.pivotal.scheduler.v1.schedules.ExpressionType;
import org.cloudfoundry.client.CloudFoundryClient;
import org.cloudfoundry.client.v3.applications.ApplicationsV3;
import org.cloudfoundry.client.v3.tasks.Tasks;
import org.cloudfoundry.operations.CloudFoundryOperations;
import org.cloudfoundry.operations.applications.ApplicationSummary;
import org.cloudfoundry.operations.applications.Applications;
import org.cloudfoundry.operations.spaces.SpaceSummary;
import org.cloudfoundry.operations.spaces.Spaces;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import org.springframework.cloud.deployer.spi.cloudfoundry.CloudFoundryConnectionProperties;
import org.springframework.cloud.deployer.spi.cloudfoundry.CloudFoundryTaskLauncher;
import org.springframework.cloud.deployer.spi.core.AppDefinition;
import org.springframework.cloud.deployer.spi.core.AppDeploymentRequest;
import org.springframework.cloud.deployer.spi.scheduler.CreateScheduleException;
import org.springframework.cloud.deployer.spi.scheduler.ScheduleInfo;
import org.springframework.cloud.deployer.spi.scheduler.ScheduleRequest;
import org.springframework.cloud.deployer.spi.scheduler.SchedulerException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.cloud.deployer.spi.scheduler.SchedulerPropertyKeys.CRON_EXPRESSION;

/**
 * Test the core features of the Spring Cloud Scheduler implementation.
 *
 * @author Glenn Renfro
 * @author Ilayaperumal Gopinathan
 */
public class CloudFoundryAppSchedulerTests {

	@Rule
	public ExpectedException thrown = ExpectedException.none();

	public static final String DEFAULT_CRON_EXPRESSION = "0/5 * ? * *";

	public static final String CRON_EXPRESSION_FOR_SIX_MIN = "0/6 * ? * *";

	public static final String BAD_CRON_EXPRESSION = "FOOBAD";

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private Applications applications;

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private CloudFoundryOperations operations;

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private Spaces spaces;

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private ApplicationsV3 applicationsV3;

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private CloudFoundryClient cloudFoundryClient;

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private Tasks tasks;

	@Mock(answer = Answers.RETURNS_SMART_NULLS)
	private CloudFoundryTaskLauncher taskLauncher;

	private CloudFoundryAppScheduler cloudFoundryAppScheduler;

	private CloudFoundryAppScheduler noServiceCloudFoundryAppScheduler;

	private SchedulerClient client;

	private SchedulerClient noServiceClient;

	private CloudFoundryConnectionProperties properties = new CloudFoundryConnectionProperties();

	private CloudFoundrySchedulerProperties schedulerProperties = new CloudFoundrySchedulerProperties();

	@Before
	public void setUp() {
		MockitoAnnotations.initMocks(this);
		given(this.cloudFoundryClient.applicationsV3()).willReturn(this.applicationsV3);
		given(this.cloudFoundryClient.tasks()).willReturn(this.tasks);
		given(this.spaces.list()).willReturn(getTestSpaces());

		this.properties.setSpace("test-space");

		given(this.operations.applications()).willReturn(this.applications);
		given(this.operations.spaces()).willReturn(this.spaces);

		this.client = new TestSchedulerClient();
		this.noServiceClient = new NoServiceTestSchedulerClient();

		this.cloudFoundryAppScheduler = new CloudFoundryAppScheduler(this.client, this.operations,
				this.properties, taskLauncher, schedulerProperties);
		this.noServiceCloudFoundryAppScheduler = new CloudFoundryAppScheduler(this.noServiceClient, this.operations,
				this.properties, taskLauncher, schedulerProperties);

	}

	@Test(expected = IllegalArgumentException.class)
	public void testEmptySchedulerProperties() {
		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");
		AppDefinition definition = new AppDefinition("bar", null);
		ScheduleRequest request = new ScheduleRequest(definition, null, null, "testschedule", resource);
		this.cloudFoundryAppScheduler.schedule(request);
	}

	@Test
	public void testCreateNoCommandLineArgs() {
		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");

		mockAppResultsInAppList();
		AppDefinition definition = new AppDefinition("test-application-1", null);
		ScheduleRequest request = new ScheduleRequest(definition, getDefaultScheduleProperties(), null, "test-schedule", resource);

		this.cloudFoundryAppScheduler.schedule(request);
		assertThat(((TestJobs) this.client.jobs()).getCreateJobResponse().getId()).isEqualTo("test-job-id-1");
		assertThat(((TestJobs) this.client.jobs()).getCreateJobResponse().getApplicationId()).isEqualTo("test-application-id-1");
		assertThat(((TestJobs) this.client.jobs()).getCreateJobResponse().getCommand()).isEmpty();
	}

	@Test
	public void testInvalidCron() {
		thrown.expect(CreateScheduleException.class);
		thrown.expectMessage("Illegal characters for this position: 'FOO'");

		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");

		mockAppResultsInAppList();
		AppDefinition definition = new AppDefinition("test-application-1", null);
		Map badCronMap = new HashMap<String, String>();
		badCronMap.put(CRON_EXPRESSION, BAD_CRON_EXPRESSION);

		ScheduleRequest request = new ScheduleRequest(definition, badCronMap, null, "test-schedule", resource);

		this.cloudFoundryAppScheduler.schedule(request);

		assertThat(((TestJobs) this.client.jobs()).getCreateJobResponse()).isNull();
	}

	@Test
	public void testNameTooLong() {
		thrown.expect(CreateScheduleException.class);
		thrown.expectMessage("Schedule can not be created because its name " +
				"'j1-scdf-itcouldbesaidthatthisislongtoowaytoo-oopsitcouldbesaidthatthisis" +
				"longtoowaytoo-oopsitcouldbesaidthatthisislongtoowaytoo-oopsitcouldbe" +
				"saidthatthisislongtoowaytoo-oopsitcouldbesaidthatthisislongtoowaytoo-" +
				"oopsitcouldbesaidthatthisislongtoowaytoo-oops12' has too many characters.  " +
				"Schedule name length must be 255 characters or less");

		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");

		mockAppResultsInAppList();
		AppDefinition definition = new AppDefinition("test-application-1", null);
		Map cronMap = new HashMap<String, String>();
		cronMap.put(CRON_EXPRESSION, DEFAULT_CRON_EXPRESSION);

		ScheduleRequest request = new ScheduleRequest(definition, cronMap, null,
				"j1-scdf-itcouldbesaidthatthisislongtoowaytoo-oopsitcouldbesaidthatthisis" +
						"longtoowaytoo-oopsitcouldbesaidthatthisislongtoowaytoo-oopsitcouldbe" +
						"saidthatthisislongtoowaytoo-oopsitcouldbesaidthatthisislongtoowaytoo-" +
						"oopsitcouldbesaidthatthisislongtoowaytoo-oops12", resource);

		this.cloudFoundryAppScheduler.schedule(request);

		assertThat(((TestJobs) this.client.jobs()).getCreateJobResponse()).isNull();
	}

	@Test
	public void testSuccessJobCreateFailedSchedule() {
		thrown.expect(CreateScheduleException.class);

		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");

		mockAppResultsInAppList();
		AppDefinition definition = new AppDefinition("test-application-1", null);
		Map badCronMap = new HashMap<String, String>();
		badCronMap.put(CRON_EXPRESSION, CRON_EXPRESSION_FOR_SIX_MIN);
		ScheduleRequest request = new ScheduleRequest(definition, badCronMap, null, "test-schedule", resource);

		this.cloudFoundryAppScheduler.schedule(request);

		assertThat(((TestJobs) this.client.jobs()).getCreateJobResponse()).isNull();
	}


	@Test
	public void testCreateWithCommandLineArgs() {
		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");

		mockAppResultsInAppList();
		AppDefinition definition = new AppDefinition("test-application-1", null);
		ScheduleRequest request = new ScheduleRequest(definition,
				getDefaultScheduleProperties(), null,
				Collections.singletonList("TestArg"), "test-schedule", resource);
		this.cloudFoundryAppScheduler.schedule(request);
		ArgumentCaptor<AppDeploymentRequest> argumentCaptor = ArgumentCaptor.forClass(AppDeploymentRequest.class);
		verify(this.taskLauncher).stage(argumentCaptor.capture());
		assertEquals("TestArg", argumentCaptor.getValue().getCommandlineArguments().get(0));
	}

	@Test
	public void testList() {
		setupMockResults();
		List<ScheduleInfo> result = this.cloudFoundryAppScheduler.list();
		assertThat(result.size()).isEqualTo(2);
		verifyScheduleInfo(result.get(0), "test-application-1", "test-job-name-1", DEFAULT_CRON_EXPRESSION);
		verifyScheduleInfo(result.get(1), "test-application-2", "test-job-name-2", DEFAULT_CRON_EXPRESSION);
	}

	@Test
	public void testListWithJobsNoAssociatedSchedule() {
		setupMockResultsNoScheduleForJobs();
		List<ScheduleInfo> result = this.cloudFoundryAppScheduler.list();
		assertThat(result.size()).isEqualTo(2);
		verifyScheduleInfo(result.get(0), "test-application-1", "test-job-name-1", null);
		verifyScheduleInfo(result.get(1), "test-application-2", "test-job-name-2", null);
	}

	@Test
	public void testListWithNoSchedules() {
		given(this.operations.applications()
				.list())
				.willReturn(Flux.empty());
		List<ScheduleInfo> result = this.cloudFoundryAppScheduler.list();
		assertThat(result.size()).isEqualTo(0);
	}

	@Test
	public void testListSchedulesWithAppName() {
		setupMockResults();
		List<ScheduleInfo> result = this.cloudFoundryAppScheduler.list("test-application-2");
		assertThat(result.size()).isEqualTo(1);
		verifyScheduleInfo(result.get(0), "test-application-2", "test-job-name-2", DEFAULT_CRON_EXPRESSION);
	}

	@Test
	public void testListSchedulesWithInvalidAppName() {
		setupMockResults();
		List<ScheduleInfo> result = this.cloudFoundryAppScheduler.list("not-here");
		assertThat(result.size()).isEqualTo(0);
	}

	@Test
	public void testUnschedule() {
		setupMockResults();
		List<ScheduleInfo> result = this.cloudFoundryAppScheduler.list();
		assertThat(result.size()).isEqualTo(2);
		this.cloudFoundryAppScheduler.unschedule("test-job-name-1");
		result = this.cloudFoundryAppScheduler.list();
		assertThat(result.size()).isEqualTo(1);
		assertThat(result.get(0).getScheduleName()).isEqualTo("test-job-name-2");
		assertThat(result.get(0).getTaskDefinitionName()).isEqualTo("test-application-2");
	}

	@Test
	public void testMissingScheduleDelete() {
		boolean exceptionFired = false;
		setupMockResults();
		try {
			this.cloudFoundryAppScheduler.unschedule("test-job-name-3");
		}
		catch (SchedulerException se) {
			assertThat(se.getMessage()).isEqualTo("Failed to unschedule schedule test-job-name-3 does not exist.");
			exceptionFired = true;
		}
		assertThat(exceptionFired).isTrue();
	}

	@Test
	public void testNoServiceList() {
		thrown.expect(SchedulerException.class);
		thrown.expectMessage("Scheduler Service returned a null response.");
		this.noServiceCloudFoundryAppScheduler.list();
	}

	@Test
	public void testNoServiceListSchedulesWithAppName() {
		thrown.expect(SchedulerException.class);
		thrown.expectMessage("Scheduler Service returned a null response.");
		this.noServiceCloudFoundryAppScheduler.list("test-application-2");
	}

	@Test
	public void testNoServiceCreate() {
		thrown.expect(SchedulerException.class);
		thrown.expectMessage("Scheduler Service returned a null response.");
		Resource resource = new FileSystemResource("src/test/resources/demo-0.0.1-SNAPSHOT.jar");

		mockAppResultsInAppList();
		AppDefinition definition = new AppDefinition("test-application-1", null);
		ScheduleRequest request = new ScheduleRequest(definition, getDefaultScheduleProperties(), null, "test-schedule", resource);

		this.noServiceCloudFoundryAppScheduler.schedule(request);
	}

	private void givenRequestListApplications(Flux<ApplicationSummary> response) {
		given(this.operations.applications()
				.list())
				.willReturn(response);
	}

	private void verifyScheduleInfo(ScheduleInfo scheduleInfo, String taskDefinitionName, String scheduleName, String expression) {
		assertThat(scheduleInfo.getTaskDefinitionName()).isEqualTo(taskDefinitionName);
		assertThat(scheduleInfo.getScheduleName()).isEqualTo(scheduleName);
		if (expression != null) {
			assertThat(scheduleInfo.getScheduleProperties().size()).isEqualTo(1);
			assertThat(scheduleInfo.getScheduleProperties().get(CRON_EXPRESSION)).isEqualTo(expression);
		}
		else {
			assertThat(scheduleInfo.getScheduleProperties().size()).isEqualTo(0);
		}
	}

	private static class TestSchedulerClient implements SchedulerClient {
		private Jobs jobs;

		public TestSchedulerClient() {
			jobs = new TestJobs();
		}

		@Override
		public Calls calls() {
			return null;
		}

		@Override
		public Jobs jobs() {
			return jobs;
		}
	}

	private static class NoServiceTestSchedulerClient implements SchedulerClient {
		private Jobs jobs;

		public NoServiceTestSchedulerClient() {
			jobs = new NoServiceTestJobs();
		}

		@Override
		public Calls calls() {
			return null;
		}

		@Override
		public Jobs jobs() {
			return jobs;
		}
	}

	private static class NoServiceTestJobs extends TestJobs {
		@Override
		public Mono<ListJobsResponse> list(ListJobsRequest request) {
			return Mono.justOrEmpty(null);
		}

		@Override
		public Mono<CreateJobResponse> create(CreateJobRequest request) {
			return Mono.justOrEmpty(null);
		}
	}

	private static class TestJobs implements Jobs {
		private CreateJobResponse createJobResponse;

		private List<Job> jobResources = new ArrayList<>();

		private List<JobSchedule> jobScheduleResources = new ArrayList<>();

		@Override
		public Mono<CreateJobResponse> create(CreateJobRequest request) {
			this.createJobResponse = CreateJobResponse.builder()
					.applicationId(request.getApplicationId())
					.name(request.getName())
					.id("test-job-id-1")
					.command(request.getCommand())
					.build();
			this.jobResources.add(Job.builder().applicationId(request.getApplicationId())
					.command(request.getCommand())
					.id("test-job-1")
					.name(request.getName())
					.build());
			return Mono.just(createJobResponse);
		}

		@Override
		public Mono<Void> delete(DeleteJobRequest request) {
			for (int i = 0; i < this.jobResources.size(); i++) {
				if (this.jobResources.get(i).getId().equals(request.getJobId())) {
					jobResources.remove(i);
					break;
				}
			}
			return Mono.justOrEmpty(null);
		}

		@Override
		public Mono<Void> deleteSchedule(DeleteJobScheduleRequest request) {
			return null;
		}

		@Override
		public Mono<ExecuteJobResponse> execute(ExecuteJobRequest request) {
			return null;
		}

		@Override
		public Mono<GetJobResponse> get(GetJobRequest request) {
			return null;
		}

		@Override
		public Mono<ListJobsResponse> list(ListJobsRequest request) {
			ListJobsResponse response = ListJobsResponse.builder()
					.addAllResources(jobResources)
					.pagination(Pagination.builder().totalPages(1).build())
					.build();
			return Mono.just(response);
		}

		@Override
		public Mono<ListJobHistoriesResponse> listHistories(ListJobHistoriesRequest request) {
			return null;
		}

		@Override
		public Mono<ListJobScheduleHistoriesResponse> listScheduleHistories(ListJobScheduleHistoriesRequest request) {
			return null;
		}

		@Override
		public Mono<ListJobSchedulesResponse> listSchedules(ListJobSchedulesRequest request) {

			ListJobSchedulesResponse response = ListJobSchedulesResponse.builder()
					.addAllResources(jobScheduleResources.stream().filter(jobScheduleResource -> jobScheduleResource.getJobId().equals(request.getJobId())).collect(Collectors.toList()))
					.build();
			return Mono.just(response);
		}

		@Override
		public Mono<ScheduleJobResponse> schedule(ScheduleJobRequest request) {
			if(request.getExpression().equals(CRON_EXPRESSION_FOR_SIX_MIN)) {
				throw new IllegalStateException();
			}
			return Mono.just(ScheduleJobResponse.builder().expression(request.getExpression())
					.expressionType(request.getExpressionType())
					.enabled(true)
					.jobId(request.getJobId())
					.id("schedule-1234")
					.build());
		}

		public CreateJobResponse getCreateJobResponse() {
			if(this.jobResources.size() == 0) {
				this.createJobResponse = null;
			}
			return createJobResponse;
		}
	}

	private Flux<SpaceSummary> getTestSpaces() {
		return Flux.just(SpaceSummary.builder().id("test-space-1")
				.name("test-space")
				.build());
	}

	private void setupMockResults() {
		mockJobsInJobList();
		mockAppResultsInAppList();
	}

	private void setupMockResultsNoScheduleForJobs() {
		mockJobsInJobListNoSchedule();
		mockAppResultsInAppList();
	}

	private void mockAppResultsInAppList() {
		givenRequestListApplications(Flux.just(ApplicationSummary.builder()
						.diskQuota(0)
						.id("test-application-id-1")
						.instances(1)
						.memoryLimit(0)
						.name("test-application-1")
						.requestedState("RUNNING")
						.runningInstances(1)
						.build(),
				ApplicationSummary.builder()
						.diskQuota(0)
						.id("test-application-id-2")
						.instances(1)
						.memoryLimit(0)
						.name("test-application-2")
						.requestedState("RUNNING")
						.runningInstances(1)
						.build()));
	}

	private void mockJobsInJobListNoSchedule() {
		TestJobs localJobs = (TestJobs) client.jobs();
		localJobs.jobResources.add(Job.builder().applicationId("test-application-id-1")
				.command("test-command")
				.id("test-job-1")
				.name("test-job-name-1")
				.build());
		localJobs.jobResources.add(Job.builder().applicationId("test-application-id-2")
				.command("test-command")
				.id("test-job-2")
				.name("test-job-name-2")
				.build());
	}

	private void mockJobsInJobList() {
		TestJobs localJobs = (TestJobs) client.jobs();
		localJobs.jobResources.add(Job.builder().applicationId("test-application-id-1")
				.command("test-command")
				.id("test-job-1")
				.name("test-job-name-1")
				.jobSchedules(createJobScheduleList("test-job-1", "test-schedule-1"))
				.build());
		localJobs.jobResources.add(Job.builder().applicationId("test-application-id-2")
				.command("test-command")
				.id("test-job-2")
				.name("test-job-name-2")
				.jobSchedules(createJobScheduleList("test-job-2", "test-schedule-2"))
				.build());
	}

	private List<JobSchedule> createJobScheduleList(String jobId, String scheduleId) {
		List<JobSchedule> jobSchedules = new ArrayList<>();
		jobSchedules.add(JobSchedule.builder()
				.enabled(true)
				.expression(DEFAULT_CRON_EXPRESSION)
				.expressionType(ExpressionType.CRON)
				.id(scheduleId)
				.jobId(jobId)
				.build());
		return jobSchedules;
	}

	private Map<String, String> getDefaultScheduleProperties() {
		Map result = new HashMap<String, String>();
		result.put(CRON_EXPRESSION, DEFAULT_CRON_EXPRESSION);
		return result;
	}

}