/*
 * Copyright 2015-2018 The OpenZipkin 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
 *
 * 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 zipkin.execjar;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import javax.net.ServerSocketFactory;
import org.junit.AssumptionViolatedException;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.springframework.boot.loader.JarLauncher;

/**
 * This is a JUnit Rule that allows you to test your Spring Boot exec jar.
 *
 * <p>It will start on a random port, and waits until the server is started before your tests
 * execute.
 *
 * <p>Often, the test classpath interferes with your ability to test your autoconfiguration, or
 * environment mappings. This class forks a process and watches it. On failure, you can look at its
 * {@link #consoleOutput() console output} for details.
 */
public final class ExecJarRule implements TestRule {

  public ExecJarRule() {
    this.execJar = JarLauncher.class.getProtectionDomain().getCodeSource().getLocation().getFile();
  }

  /** Adds a variable to the environment used by the forked boot app. */
  public ExecJarRule putEnvironment(String key, String value) {
    environment.put(key, value);
    return this;
  }

  /** Returns stderr and stdout dumped into the same place */
  public String consoleOutput() {
    return String.join("\n", console);
  }

  /** Lazy-chooses a server port, or returns the port the server started with */
  public synchronized int port() throws IOException {
    if (port != null) return port;
    try (ServerSocket socket = ServerSocketFactory.getDefault().createServerSocket(0)) {
      return (this.port = socket.getLocalPort());
    }
  }

  private final String execJar;
  private Map<String, String> environment = new LinkedHashMap<>();
  private Integer port;
  private Process bootApp;
  private ConcurrentLinkedQueue<String> console = new ConcurrentLinkedQueue<>();

  @Override public Statement apply(Statement base, Description description) {
    return new Statement() {
      public void evaluate() throws Throwable {
        try {
          ProcessBuilder bootBuilder = new ProcessBuilder("java", "-jar", execJar);
          bootBuilder.environment().put("SERVER_PORT", String.valueOf(port()));
          bootBuilder.environment().putAll(environment);
          bootBuilder.redirectErrorStream(true);
          bootApp = bootBuilder.start();

          CountDownLatch startedOrCrashed = new CountDownLatch(1);
          Thread consoleReader = new Thread(() -> {
            boolean foundStartMessage = false;
            try (BufferedReader reader =
                     new BufferedReader(new InputStreamReader(bootApp.getInputStream()))) {
              String line;
              while ((line = reader.readLine()) != null) {
                if (line.indexOf("JVM running for") != -1) {
                  foundStartMessage = true;
                  startedOrCrashed.countDown();
                }
                console.add(line);
              }
            } catch (Exception e) {
            } finally {
              if (!foundStartMessage) startedOrCrashed.countDown();
            }
          });
          consoleReader.setDaemon(true);
          consoleReader.start();

          if (!startedOrCrashed.await(10, TimeUnit.SECONDS)) {
            throw new AssumptionViolatedException("Took too long to start or crash");
          }

          base.evaluate();
        } finally {
          bootApp.destroy();
        }
      }
    };
  }
}