/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community 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://opensource.org/licenses/ecl2.txt
 *
 * 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.opencastproject.serviceregistry.impl;

import static org.opencastproject.db.Queries.namedQuery;
import static org.opencastproject.util.data.Monadics.mlist;
import static org.opencastproject.util.data.Option.none;
import static org.opencastproject.util.data.Option.option;

import org.opencastproject.db.DBSession;
import org.opencastproject.job.api.Incident;
import org.opencastproject.job.api.IncidentImpl;
import org.opencastproject.job.api.IncidentTree;
import org.opencastproject.job.api.IncidentTreeImpl;
import org.opencastproject.job.api.Job;
import org.opencastproject.serviceregistry.api.IncidentL10n;
import org.opencastproject.serviceregistry.api.IncidentService;
import org.opencastproject.serviceregistry.api.IncidentServiceException;
import org.opencastproject.serviceregistry.api.ServiceRegistry;
import org.opencastproject.serviceregistry.api.ServiceRegistryException;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.data.Collections;
import org.opencastproject.util.data.Function;
import org.opencastproject.util.data.Option;
import org.opencastproject.util.data.Tuple;
import org.opencastproject.util.data.functions.Functions;
import org.opencastproject.util.data.functions.Strings;
import org.opencastproject.workflow.api.WorkflowOperationInstance;
import org.opencastproject.workflow.api.WorkflowOperationInstance.OperationState;
import org.opencastproject.workflow.api.WorkflowService;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

public abstract class AbstractIncidentService implements IncidentService {
  public static final String PERSISTENCE_UNIT_NAME = "org.opencastproject.serviceregistry";

  public static final String NO_TITLE = "-";
  public static final String NO_DESCRIPTION = "-";
  public static final String FIELD_TITLE = "title";
  public static final String FIELD_DESCRIPTION = "description";

  /** The logging instance */
  private static final Logger logger = LoggerFactory.getLogger(AbstractIncidentService.class);

  protected abstract ServiceRegistry getServiceRegistry();

  protected abstract WorkflowService getWorkflowService();

  protected abstract DBSession getDBSession();

  @Override
  public Incident storeIncident(Job job, Date timestamp, String code, Incident.Severity severity,
          Map<String, String> descriptionParameters, List<Tuple<String, String>> details)
          throws IncidentServiceException, IllegalStateException {
    try {
      job = getServiceRegistry().getJob(job.getId());

      final IncidentDto dto = getDBSession().execTx(namedQuery.persist(
          IncidentDto.mk(job.getId(), timestamp, code, severity, descriptionParameters, details)
      ));
      return toIncident(job, dto);
    } catch (NotFoundException e) {
      throw new IllegalStateException("Can't create incident for not-existing job");
    } catch (Exception e) {
      logger.error("Could not store job incident: {}", e.getMessage());
      throw new IncidentServiceException(e);
    }
  }

  @Override
  public Incident getIncident(long id) throws IncidentServiceException, NotFoundException {
    Optional<IncidentDto> dto = getDBSession().execTx(namedQuery.findByIdOpt(IncidentDto.class, id));
    if (dto.isPresent()) {
      final Job job = findJob(dto.get().getJobId());
      if (job != null) {
        return toIncident(job, dto.get());
      }
    }
    throw new NotFoundException();
  }

  @Override
  public List<Incident> getIncidentsOfJob(List<Long> jobIds) throws IncidentServiceException {
    List<Incident> incidents = new ArrayList<>();
    for (long jobId : jobIds) {
      try {
        incidents.addAll(getIncidentsOfJob(jobId));
      } catch (NotFoundException ignore) {
      }
    }
    return incidents;
  }

  @Override
  public IncidentTree getIncidentsOfJob(long jobId, boolean cascade) throws NotFoundException, IncidentServiceException {
    List<Incident> incidents = getIncidentsOfJob(jobId);
    List<IncidentTree> childIncidents = new ArrayList<>();

    try {
      Job job = getServiceRegistry().getJob(jobId);
      if (cascade && !"START_WORKFLOW".equals(job.getOperation())) {
        childIncidents = getChildIncidents(jobId);
      } else if (cascade && "START_WORKFLOW".equals(job.getOperation())) {
        for (WorkflowOperationInstance operation : getWorkflowService().getWorkflowById(jobId).getOperations()) {
          if (operation.getState().equals(OperationState.INSTANTIATED))
            continue;
          IncidentTree operationResult = getIncidentsOfJob(operation.getId(), true);
          if (hasIncidents(Collections.list(operationResult)))
            childIncidents.add(operationResult);
        }
      }
      return new IncidentTreeImpl(incidents, childIncidents);
    } catch (NotFoundException ignore) {
      // Workflow deleted
      return new IncidentTreeImpl(incidents, childIncidents);
    } catch (Exception e) {
      logger.error("Error loading child jobs of: {}", jobId);
      throw new IncidentServiceException(e);
    }
  }

  private boolean hasIncidents(List<IncidentTree> incidentResults) {
    for (IncidentTree result : incidentResults) {
      if (result.getIncidents().size() > 0 || hasIncidents(result.getDescendants()))
        return true;
    }
    return false;
  }

  @Override
  public IncidentL10n getLocalization(long id, Locale locale) throws IncidentServiceException, NotFoundException {
    final Incident incident = getIncident(id);

    final List<String> loc = localeToList(locale);
    // check if cache map is empty
    // fill cache from
    final String title = findText(loc, incident.getCode(), FIELD_TITLE).getOrElse(NO_TITLE);
    final String description = findText(loc, incident.getCode(), FIELD_DESCRIPTION).map(
            replaceVarsF(incident.getDescriptionParameters())).getOrElse(NO_DESCRIPTION);
    return new IncidentL10n() {
      @Override
      public String getTitle() {
        return title;
      }

      @Override
      public String getDescription() {
        return description;
      }
    };
  }

  /**
   * Find a localization text in the database. The keys to look for are made from the locale, the incident code and the
   * type.
   *
   * @param locale
   *          The locale as a list. See {@link AbstractIncidentService#localeToList(java.util.Locale)}
   * @param incidentCode
   *          The incident code. See {@link org.opencastproject.job.api.Incident#getCode()}
   * @param field
   *          The field, e.g. "title" or "description"
   * @return the found text wrapped in an option
   */
  private Option<String> findText(List<String> locale, String incidentCode, String field) {
    final List<String> keys = genDbKeys(locale, incidentCode + "." + field);
    for (String key : keys) {
      final Option<String> text = getText(key);
      if (text.isSome()) {
        return text;
      }
    }
    return none();
  }

  private final Map<String, String> textCache = new HashMap<>();

  /** Get a text. */
  private Option<String> getText(String key) {
    synchronized (textCache) {
      if (textCache.isEmpty()) {
        textCache.putAll(fetchTextsFromDb());
      }
    }
    return option(textCache.get(key));
  }

  /** Fetch all localizations from the database. */
  private Map<String, String> fetchTextsFromDb() {
    final Map<String, String> locs = new HashMap<String, String>();
    for (IncidentTextDto a : getDBSession().execTx(IncidentTextDto.findAllQuery)) {
      locs.put(a.getId(), a.getText());
    }
    return locs;
  }

  private List<IncidentTree> getChildIncidents(long jobId) throws NotFoundException, ServiceRegistryException,
          IncidentServiceException {
    List<Job> childJobs = getServiceRegistry().getChildJobs(jobId);
    List<IncidentTree> incidentResults = new ArrayList<>();
    for (Job childJob : childJobs) {
      if (childJob.getParentJobId() != jobId)
        continue;
      List<Incident> incidentsForJob = getIncidentsOfJob(childJob.getId());
      IncidentTree incidentTree = new IncidentTreeImpl(incidentsForJob, getChildIncidents(childJob.getId()));
      if (hasIncidents(Collections.list(incidentTree)))
        incidentResults.add(incidentTree);
    }
    return incidentResults;
  }

  private List<Incident> getIncidentsOfJob(long jobId) throws NotFoundException, IncidentServiceException {
    final Job job = findJob(jobId);
    try {
      return getDBSession().execTx(IncidentDto.findByJobIdQuery(jobId)).stream()
          .map(i -> toIncident(job, i))
          .collect(Collectors.toList());
    } catch (Exception e) {
      logger.error("Could not retrieve incidents of job '{}': {}", job.getId(), e.getMessage());
      throw new IncidentServiceException(e);
    }
  }

  private Job findJob(long jobId) throws NotFoundException, IncidentServiceException {
    try {
      return getServiceRegistry().getJob(jobId);
    } catch (NotFoundException e) {
      logger.info("Job with Id {} does not exist", jobId);
      throw e;
    } catch (ServiceRegistryException e) {
      logger.error("Could not retrieve job {}: {}", jobId, e.getMessage());
      throw new IncidentServiceException(e);
    }
  }

  private static Incident toIncident(Job job, IncidentDto dto) {
    return new IncidentImpl(dto.getId(), job.getId(), job.getJobType(), job.getProcessingHost(), dto.getTimestamp(),
            dto.getSeverity(), dto.getCode(), dto.getTechnicalInformation(), dto.getParameters());
  }

  /**
   * Create a list of localization database keys from a base key and a locale split into its part, e.g. ["de", "DE"] or
   * ["en"]. The returned list starts with the most specific key getting more common, e.g.
   * ["org.opencastproject.composer.1.title.de.DE", "org.opencastproject.composer.1.title.de",
   * "org.opencastproject.composer.1.title"]
   */
  public static List<String> genDbKeys(List<String> locale, String base) {
    List<String> result = new LinkedList<>();
    StringBuilder key = new StringBuilder(base);
    result.add(base);
    for (String s: locale) {
      key.append('.').append(s);
      result.add(0, key.toString());
    }
    return result;
  }

  /** Convert a locale into a list of strings, [language, country, variant] */
  public static List<String> localeToList(Locale locale) {
    return mlist(Strings.trimToNone(locale.getLanguage()), Strings.trimToNone(locale.getCountry()),
            Strings.trimToNone(locale.getVariant()))
    // flatten
            .bind(Functions.<Option<String>> identity()).value();
  }

  /** Replace variables of the form #{xxx} in a string template. */
  public static String replaceVars(String template, Map<String, String> params) {
    String s = template;
    for (Map.Entry<String, String> e : params.entrySet()) {
      s = s.replace("#{" + e.getKey() + "}", e.getValue());
    }
    return s;
  }

  /**
   * {@link org.opencastproject.serviceregistry.impl.AbstractIncidentService#replaceVars(String, java.util.Map)} as a
   * function.
   */
  public static Function<String, String> replaceVarsF(final Map<String, String> params) {
    return new Function<String, String>() {
      @Override
      public String apply(String s) {
        return replaceVars(s, params);
      }
    };
  }
}
