/* $Id$ */

/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.manifoldcf.crawler.tests;

import org.apache.manifoldcf.core.interfaces.*;
import org.apache.manifoldcf.agents.interfaces.*;
import org.apache.manifoldcf.crawler.interfaces.*;
import org.apache.manifoldcf.crawler.system.ManifoldCF;
import org.apache.manifoldcf.agents.system.AgentsDaemon;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.nio.charset.Charset;
import org.junit.*;

import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.handler.ContextHandlerCollection;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.ee10.webapp.WebAppContext;

import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HttpRequestExecutor;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.config.SocketConfig;
import org.apache.http.HttpStatus;
import org.apache.http.HttpException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.conn.ConnectTimeoutException;
import org.apache.http.HttpResponse;
import org.apache.http.HttpEntity;
import org.apache.http.util.EntityUtils;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.ContentType;
import org.apache.http.ParseException;


/** Tests that run the "agents daemon" should be derived from this */
public class ManifoldCFInstance
{
  protected final boolean webapps;
  protected final boolean singleWar;
  protected final int testPort;
  protected final String processID;
  
  protected DaemonThread daemonThread = null;
  protected Server server = null;

  public ManifoldCFInstance()
  {
    this("", 8346, false, true);
  }
  
  public ManifoldCFInstance(String processID)
  {
    this(processID, 8346, false, true);
  }

  public ManifoldCFInstance(boolean singleWar)
  {
    this("", 8346, singleWar, true);
  }
  
  public ManifoldCFInstance(String processID, boolean singleWar)
  {
    this(processID, 8346, singleWar, true);
  }

  public ManifoldCFInstance(boolean singleWar, boolean webapps)
  {
    this("", 8346, singleWar, webapps);
  }

  public ManifoldCFInstance(String processID, boolean singleWar, boolean webapps)
  {
    this(processID, 8346, singleWar, webapps);
  }
  
  public ManifoldCFInstance(String processID, int testPort)
  {
    this(processID, testPort, false, true);
  }

  public ManifoldCFInstance(String processID, int testPort, boolean singleWar)
  {
    this(processID, testPort, singleWar, true);
  }

  public ManifoldCFInstance(String processID, int testPort, boolean singleWar, boolean webapps)
  {
    this.processID = processID;
    this.webapps = webapps;
    this.testPort = testPort;
    this.singleWar = singleWar;
  }
  
  // Basic job support
  
  public void waitJobInactiveNative(IJobManager jobManager, Long jobID, long maxTime)
    throws ManifoldCFException, InterruptedException
  {
    long startTime = System.currentTimeMillis();
    while (System.currentTimeMillis() < startTime + maxTime)
    {
      JobStatus status = jobManager.getStatus(jobID);
      if (status == null)
        throw new ManifoldCFException("No such job: '"+jobID+"'");
      int statusValue = status.getStatus();
      switch (statusValue)
      {
        case JobStatus.JOBSTATUS_NOTYETRUN:
          throw new ManifoldCFException("Job was never started.");
        case JobStatus.JOBSTATUS_COMPLETED:
          break;
        case JobStatus.JOBSTATUS_ERROR:
          throw new ManifoldCFException("Job reports error status: "+status.getErrorText());
        default:
          ManifoldCF.sleep(1000L);
          continue;
      }
      return;
    }
    throw new ManifoldCFException("ManifoldCF did not terminate in the allotted time of "+new Long(maxTime).toString()+" milliseconds");
  }
  
  public void waitJobDeletedNative(IJobManager jobManager, Long jobID, long maxTime)
    throws ManifoldCFException, InterruptedException
  {
    long startTime = System.currentTimeMillis();
    while (System.currentTimeMillis() < startTime + maxTime)
    {
      JobStatus status = jobManager.getStatus(jobID);
      if (status == null)
        return;
      ManifoldCF.sleep(1000L);
    }
    throw new ManifoldCFException("ManifoldCF did not delete in the allotted time of "+new Long(maxTime).toString()+" milliseconds");
  }
    
  public void waitJobRunningNative(IJobManager jobManager, Long jobID, long maxTime)
    throws ManifoldCFException, InterruptedException
  {
    long startTime = System.currentTimeMillis();
    while (System.currentTimeMillis() < startTime + maxTime)
    {
      JobStatus status = jobManager.getStatus(jobID);
      if (status == null)
        throw new ManifoldCFException("No such job: '"+jobID+"'");
      int statusValue = status.getStatus();
      switch (statusValue)
      {
        case JobStatus.JOBSTATUS_NOTYETRUN:
          throw new ManifoldCFException("Job was never started.");
        case JobStatus.JOBSTATUS_COMPLETED:
          throw new ManifoldCFException("Job ended on its own!");
        case JobStatus.JOBSTATUS_ERROR:
          throw new ManifoldCFException("Job reports error status: "+status.getErrorText());
        case JobStatus.JOBSTATUS_RUNNING:
          break;
        default:
          ManifoldCF.sleep(1000L);
          continue;
      }
      return;
    }
    throw new ManifoldCFException("ManifoldCF did not start in the allotted time of "+new Long(maxTime).toString()+" milliseconds");
  }

  // API support
  
  // These methods allow communication with the ManifoldCF api webapp, via the locally-instantiated jetty
  
  public void loginAPI(String userID, String password)
    throws Exception
  {
    Configuration requestObject = new Configuration();
    ConfigurationNode cn = new ConfigurationNode("userID");
    cn.setValue(userID);
    requestObject.addChild(requestObject.getChildCount(),cn);
    cn = new ConfigurationNode("password");
    cn.setValue(password);
    requestObject.addChild(requestObject.getChildCount(),cn);
    performAPIPostOperationViaNodes("LOGIN",200,requestObject);
  }
  
  public void startJobAPI(String jobIDString)
    throws Exception
  {
    Configuration requestObject = new Configuration();
    
    Configuration result = performAPIPutOperationViaNodes("start/"+jobIDString,201,requestObject);
    int i = 0;
    while (i < result.getChildCount())
    {
      ConfigurationNode resultNode = result.findChild(i++);
      if (resultNode.getType().equals("error"))
        throw new Exception(resultNode.getValue());
    }
  }
  
  public void deleteJobAPI(String jobIDString)
    throws Exception
  {
    Configuration result = performAPIDeleteOperationViaNodes("jobs/"+jobIDString,200);
    int i = 0;
    while (i < result.getChildCount())
    {
      ConfigurationNode resultNode = result.findChild(i++);
      if (resultNode.getType().equals("error"))
        throw new Exception(resultNode.getValue());
    }

  }
  
  public String getJobStatusAPI(String jobIDString)
    throws Exception
  {
    Configuration result = performAPIGetOperationViaNodes("jobstatusesnocounts/"+jobIDString,200);
    String status = null;
    int i = 0;
    while (i < result.getChildCount())
    {
      ConfigurationNode resultNode = result.findChild(i++);
      if (resultNode.getType().equals("error"))
        throw new Exception(resultNode.getValue());
      else if (resultNode.getType().equals("jobstatus"))
      {
        int j = 0;
        while (j < resultNode.getChildCount())
        {
          ConfigurationNode childNode = resultNode.findChild(j++);
          if (childNode.getType().equals("status"))
            status = childNode.getValue();
        }
      }
    }
    return status;
  }

  public long getJobDocumentsProcessedAPI(String jobIDString)
    throws Exception
  {
    Configuration result = performAPIGetOperationViaNodes("jobstatuses/"+jobIDString,200);
    String documentsProcessed = null;
    int i = 0;
    while (i < result.getChildCount())
    {
      ConfigurationNode resultNode = result.findChild(i++);
      if (resultNode.getType().equals("error"))
        throw new Exception(resultNode.getValue());
      else if (resultNode.getType().equals("jobstatus"))
      {
        int j = 0;
        while (j < resultNode.getChildCount())
        {
          ConfigurationNode childNode = resultNode.findChild(j++);
          if (childNode.getType().equals("documents_processed"))
            documentsProcessed = childNode.getValue();
        }
      }
    }
    if (documentsProcessed == null)
      throw new Exception("Expected a documents_processed field, didn't find it");
    return new Long(documentsProcessed).longValue();
  }

  public void waitJobInactiveAPI(String jobIDString, long maxTime)
    throws Exception
  {
    long startTime = System.currentTimeMillis();
    while (System.currentTimeMillis() < startTime + maxTime)
    {
      String status = getJobStatusAPI(jobIDString);
      if (status == null)
        throw new Exception("No such job: '"+jobIDString+"'");
      if (status.equals("not yet run"))
        throw new Exception("Job was never started.");
      if (status.equals("done"))
        return;
      if (status.equals("error"))
        throw new Exception("Job reports error.");
      ManifoldCF.sleep(1000L);
      continue;
    }
    throw new ManifoldCFException("ManifoldCF did not terminate in the allotted time of "+new Long(maxTime).toString()+" milliseconds");
  }
  
  public void waitJobDeletedAPI(String jobIDString, long maxTime)
    throws Exception
  {
    long startTime = System.currentTimeMillis();
    while (System.currentTimeMillis() < startTime + maxTime)
    {
      String status = getJobStatusAPI(jobIDString);
      if (status == null)
        return;
      ManifoldCF.sleep(1000L);
    }
    throw new ManifoldCFException("ManifoldCF did not delete in the allotted time of "+new Long(maxTime).toString()+" milliseconds");
  }
    
  /** Construct a command url.
  */
  public String makeAPIURL(String command)
    throws Exception
  {
    if (webapps)
    {
      if (singleWar)
        return "http://localhost:"+Integer.toString(testPort)+"/mcf/api/json/"+command;
      else
        return "http://localhost:"+Integer.toString(testPort)+"/mcf-api-service/json/"+command;
    }
    else
      throw new Exception("No API servlet running");
  }

  public static String convertToString(HttpResponse httpResponse)
    throws IOException
  {
    HttpEntity entity = httpResponse.getEntity();
    if (entity != null)
    {
      InputStream is = entity.getContent();
      try
      {
        Charset charSet;
        try
        {
          ContentType ct = ContentType.get(entity);
          if (ct == null)
            charSet = StandardCharsets.UTF_8;
          else
            charSet = ct.getCharset();
        }
        catch (ParseException e)
        {
          charSet = StandardCharsets.UTF_8;
        }
        char[] buffer = new char[65536];
        Reader r = new InputStreamReader(is,charSet);
        Writer w = new StringWriter();
        try
        {
          while (true)
          {
            int amt = r.read(buffer);
            if (amt == -1)
              break;
            w.write(buffer,0,amt);
          }
        }
        finally
        {
          w.flush();
        }
        return w.toString();
      }
      finally
      {
        is.close();
      }
    }
    return "";
  }

  /** Perform an json API GET operation.
  *@param apiURL is the operation.
  *@param expectedResponse is the expected response code.
  *@return the json response.
  */
  public String performAPIGetOperation(String apiURL, int expectedResponse)
    throws Exception
  {
    HttpClient client = HttpClients.createDefault();
    HttpGet method = new HttpGet(apiURL);
    try
    {
      HttpResponse response = client.execute(method);
      int responseCode = response.getStatusLine().getStatusCode();
      String responseString = convertToString(response);
      if (responseCode != expectedResponse)
        throw new Exception("API http error; expected "+Integer.toString(expectedResponse)+", saw "+Integer.toString(responseCode)+": "+responseString);
      return responseString;
    }
    finally
    {
      method.abort();
    }
  }

  /** Perform an json API DELETE operation.
  *@param apiURL is the operation.
  *@param expectedResponse is the expected response code.
  *@return the json response.
  */
  public String performAPIDeleteOperation(String apiURL, int expectedResponse)
    throws Exception
  {
    HttpClient client = HttpClients.createDefault();
    HttpDelete method = new HttpDelete(apiURL);
    try
    {
      HttpResponse response = client.execute(method);
      int responseCode = response.getStatusLine().getStatusCode();
      String responseString = convertToString(response);
      if (responseCode != expectedResponse)
        throw new Exception("API http error; expected "+Integer.toString(expectedResponse)+", saw "+Integer.toString(responseCode)+": "+responseString);
      // We presume that the data is utf-8, since that's what the API uses throughout.
      return responseString;
    }
    finally
    {
      method.abort();
    }
  }

  /** Perform an json API PUT operation.
  *@param apiURL is the operation.
  *@param input is the input JSON.
  *@param expectedResponse is the expected response code.
  *@return the json response.
  */
  public String performAPIPutOperation(String apiURL, int expectedResponse, String input)
    throws Exception
  {
    HttpClient client = HttpClients.createDefault();
    HttpPut method = new HttpPut(apiURL);
    try
    {
      method.setEntity(new StringEntity(input,ContentType.create("text/plain",StandardCharsets.UTF_8)));
      HttpResponse response = client.execute(method);
      int responseCode = response.getStatusLine().getStatusCode();
      String responseString = convertToString(response);
      if (responseCode != expectedResponse)
        throw new Exception("API http error; expected "+Integer.toString(expectedResponse)+", saw "+Integer.toString(responseCode)+": "+responseString);
      // We presume that the data is utf-8, since that's what the API uses throughout.
      return responseString;
    }
    finally
    {
      method.abort();
    }
  }

  /** Perform an json API POST operation.
  *@param apiURL is the operation.
  *@param input is the input JSON.
  *@param expectedResponse is the expected response code.
  *@return the json response.
  */
  public String performAPIPostOperation(String apiURL, int expectedResponse, String input)
    throws Exception
  {
    HttpClient client = HttpClients.createDefault();
    HttpPost method = new HttpPost(apiURL);
    try
    {
      method.setEntity(new StringEntity(input,ContentType.create("text/plain",StandardCharsets.UTF_8)));
      HttpResponse response = client.execute(method);
      int responseCode = response.getStatusLine().getStatusCode();
      String responseString = convertToString(response);
      if (responseCode != expectedResponse)
        throw new Exception("API http error; expected "+Integer.toString(expectedResponse)+", saw "+Integer.toString(responseCode)+": "+responseString);
      // We presume that the data is utf-8, since that's what the API uses throughout.
      return responseString;
    }
    finally
    {
      method.abort();
    }
  }

  /** Perform a json GET API operation, using Configuration structures to represent the json.  This is for testing convenience,
  * mostly.
  */
  public Configuration performAPIGetOperationViaNodes(String command, int expectedResponse)
    throws Exception
  {
    String result = performAPIGetOperation(makeAPIURL(command),expectedResponse);
    Configuration cfg = new Configuration();
    cfg.fromJSON(result);
    return cfg;
  }

  /** Perform a json DELETE API operation, using Configuration structures to represent the json.  This is for testing convenience,
  * mostly.
  */
  public Configuration performAPIDeleteOperationViaNodes(String command, int expectedResponse)
    throws Exception
  {
    String result = performAPIDeleteOperation(makeAPIURL(command),expectedResponse);
    Configuration cfg = new Configuration();
    cfg.fromJSON(result);
    return cfg;
  }

  /** Perform a json PUT API operation, using Configuration structures to represent the json.  This is for testing convenience,
  * mostly.
  */
  public Configuration performAPIPutOperationViaNodes(String command, int expectedResponse, Configuration argument)
    throws Exception
  {
    String argumentJson;
    if (argument != null)
      argumentJson = argument.toJSON();
    else
      argumentJson = null;
    
    String result = performAPIPutOperation(makeAPIURL(command),expectedResponse,argumentJson);
    Configuration cfg = new Configuration();
    cfg.fromJSON(result);
    return cfg;
  }

  /** Perform a json POST API operation, using Configuration structures to represent the json.  This is for testing convenience,
  * mostly.
  */
  public Configuration performAPIPostOperationViaNodes(String command, int expectedResponse, Configuration argument)
    throws Exception
  {
    String argumentJson;
    if (argument != null)
      argumentJson = argument.toJSON();
    else
      argumentJson = null;
    
    String result = performAPIPostOperation(makeAPIURL(command),expectedResponse,argumentJson);
    Configuration cfg = new Configuration();
    cfg.fromJSON(result);
    return cfg;
  }

  // Setup/teardown
  
  public void start()
    throws Exception
  {
    ContextHandlerCollection contexts = new ContextHandlerCollection();
    if (webapps)
    {
      // Start jetty
      server = new Server( testPort );    
      server.setStopAtShutdown( true );
      // Initialize the servlets
      server.setHandler(contexts);
    }

    if (singleWar)
    {
      if (webapps)
      {
        // Start the single combined war
        String combinedWarPath = "../../framework/build/war-proprietary/mcf-combined-service.war";
        if (System.getProperty("combinedWarPath") != null)
          combinedWarPath = System.getProperty("combinedWarPath");
        
        // Initialize the servlet
        WebAppContext lcfCombined = new WebAppContext(combinedWarPath,"/mcf");
        // This will cause jetty to ignore all of the framework and jdbc jars in the war, which is what we want.
        lcfCombined.setParentLoaderPriority(true);
        contexts.addHandler(lcfCombined);
        server.start();
      }
      else
        throw new Exception("Can't run singleWar without webapps");
    }
    else
    {
      if (webapps)
      {
        String crawlerWarPath = "../../framework/build/war-proprietary/mcf-crawler-ui.war";
        String authorityserviceWarPath = "../../framework/build/war-proprietary/mcf-authority-service.war";
        String apiWarPath = "../../framework/build/war-proprietary/mcf-api-service.war";

        if (System.getProperty("crawlerWarPath") != null)
            crawlerWarPath = System.getProperty("crawlerWarPath");
        if (System.getProperty("authorityserviceWarPath") != null)
            authorityserviceWarPath = System.getProperty("authorityserviceWarPath");
        if (System.getProperty("apiWarPath") != null)
            apiWarPath = System.getProperty("apiWarPath");

        // Initialize the servlets
        WebAppContext lcfCrawlerUI = new WebAppContext(crawlerWarPath,"/mcf-crawler-ui");
        // This will cause jetty to ignore all of the framework and jdbc jars in the war, which is what we want.
        lcfCrawlerUI.setParentLoaderPriority(true);
        contexts.addHandler(lcfCrawlerUI);
        WebAppContext lcfAuthorityService = new WebAppContext(authorityserviceWarPath,"/mcf-authority-service");
        // This will cause jetty to ignore all of the framework and jdbc jars in the war, which is what we want.
        lcfAuthorityService.setParentLoaderPriority(true);
        contexts.addHandler(lcfAuthorityService);
        WebAppContext lcfApi = new WebAppContext(apiWarPath,"/mcf-api-service");
        lcfApi.setParentLoaderPriority(true);
        contexts.addHandler(lcfApi);
        server.start();
      }

      // If all worked, then we can start the daemon.
      // Clear the agents shutdown signal.
      IThreadContext tc = ThreadContextFactory.make();
      AgentsDaemon.clearAgentsShutdownSignal(tc);

      daemonThread = new DaemonThread(processID);
      daemonThread.start();
    }
  }
  
  public void stop()
    throws Exception
  {
    Exception currentException = null;
    IThreadContext tc = ThreadContextFactory.make();

    // Delete all jobs (and wait for them to go away)
    if (daemonThread != null || singleWar)
    {
      IJobManager jobManager = JobManagerFactory.make(tc);
          
      // Get a list of the current active jobs
      IJobDescription[] jobs = jobManager.getAllJobs();
      int i = 0;
      while (i < jobs.length)
      {
        IJobDescription desc = jobs[i++];
        // Abort this job, if it is running
        try
        {
          jobManager.manualAbort(desc.getID());
        }
        catch (ManifoldCFException e)
        {
          // This generally means that the job was not running
        }
      }
      i = 0;
      while (i < jobs.length)
      {
        IJobDescription desc = jobs[i++];
        // Wait for this job to stop
        while (true)
        {
          JobStatus status = jobManager.getStatus(desc.getID(),false);
          if (status != null)
          {
            int statusValue = status.getStatus();
            switch (statusValue)
            {
            case JobStatus.JOBSTATUS_NOTYETRUN:
            case JobStatus.JOBSTATUS_COMPLETED:
            case JobStatus.JOBSTATUS_ERROR:
              break;
            default:
              ManifoldCF.sleep(10000);
              continue;
            }
          }
          break;
        }
      }

      // Now, delete them all
      i = 0;
      while (i < jobs.length)
      {
        IJobDescription desc = jobs[i++];
        try
        {
          jobManager.deleteJob(desc.getID());
        }
        catch (ManifoldCFException e)
        {
          // This usually means that the job is already being deleted
        }
      }

      i = 0;
      while (i < jobs.length)
      {
        IJobDescription desc = jobs[i++];
        // Wait for this job to disappear
        while (true)
        {
          JobStatus status = jobManager.getStatus(desc.getID(),false);
          if (status != null)
          {
            ManifoldCF.sleep(10000);
            continue;
          }
          break;
        }
      }

      try
      {
        stopNoCleanup();
      }
      catch (Exception e)
      {
        if (currentException == null)
          currentException = e;
      }
    }
  }
  
  public void stopNoCleanup()
    throws Exception
  {
    if (daemonThread != null)
    {
      Exception currentException = null;
      
      // Shut down daemon - but only ONE daemon
      //AgentsDaemon.assertAgentsShutdownSignal(tc);
        
      // Wait for daemon thread to exit.
      while (true)
      {
        daemonThread.interrupt();
        if (daemonThread.isAlive())
        {
          Thread.sleep(1000L);
          continue;
        }
        break;
      }

      Exception e = daemonThread.getDaemonException();
      if (e != null || !(e instanceof InterruptedException))
        currentException = e;

      if (currentException != null)
        throw currentException;
    }
        
  }
  
  public void unload()
    throws Exception
  {
    if (server != null)
    {
      // Unfortunately, this causes the shutdown hooks to be called, which causes
      // no end of trouble unless it is done last.
      server.stop();
      server.join();
      server = null;
    }
  }
  
  protected static class DaemonThread extends Thread
  {
    protected final String processID;
    protected Exception daemonException = null;
    
    public DaemonThread(String processID)
    {
      this.processID = processID;
      setName("Daemon thread");
    }
    
    public void run()
    {
      IThreadContext tc = ThreadContextFactory.make();
      // Now, start the server, and then wait for the shutdown signal.  On shutdown, we have to actually do the cleanup,
      // because the JVM isn't going away.
      AgentsDaemon ad = new AgentsDaemon(processID);
      try
      {
        ad.runAgents(tc);
      }
      catch (ManifoldCFException e)
      {
        daemonException = e;
      }
      finally
      {
        try
        {
          ad.stopAgents(tc);
        }
        catch (ManifoldCFException e)
        {
          daemonException = e;
        }
      }
    }
    
    public Exception getDaemonException()
    {
      return daemonException;
    }
    
  }

}
