// Copyright 2016 Google Inc. All Rights Reserved.
//
// 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.google.api.ads.adwords.awreporting.model.csv;

import com.google.api.ads.adwords.awreporting.model.csv.annotation.CsvField;
import com.google.api.ads.adwords.awreporting.model.csv.annotation.CsvReport;
import com.google.api.ads.adwords.awreporting.model.entities.DateReport;
import com.google.api.ads.adwords.awreporting.model.entities.Report;
import com.google.api.ads.adwords.lib.jaxb.v201809.ReportDefinitionReportType;
import com.google.api.client.util.Maps;
import com.google.common.collect.Sets;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.util.ClassUtils;
import org.springframework.util.SystemPropertyUtils;

/**
 * Class responsible to hold the mapping between report type and the class that represents the CSV
 * file data.
 *
 * Note: only one bean per report type is allowed. If a second mapping is found, the first one will
 * be overwritten.
 */
public class CsvReportEntitiesMapping {
  private final String packageToScan;

  // The map of report type name -> report class.
  private final Map<ReportDefinitionReportType, Class<? extends Report>> reportDefinitionMap =
      Maps.newHashMap();

  // The map of experimental report type name -> report class (legacy).
  private final Map<String, Class<? extends Report>> experimentalReportsDefinitionMap =
      Maps.newHashMap();

  // The map of report type name -> list of report fields.
  private final Map<ReportDefinitionReportType, List<String>> reportProperties = Maps.newHashMap();

  // The set of report type names that don't support date range.
  private final Set<ReportDefinitionReportType> nonDateRangeReports = Sets.newHashSet();

  private static final Logger logger =
      Logger.getLogger(CsvReportEntitiesMapping.class.getCanonicalName());

  /**
   * @param packageToScan the package to scan.
   */
  public CsvReportEntitiesMapping(String packageToScan) {
    this.packageToScan = packageToScan;
  }

  /**
   * Initializes the report type definition map.
   *
   * The base package is scanned in order to find the candidates to report beans, and the map of
   * {@code ReportDefinitionReportType} to the report bean class is created, based on the annotated
   * classes.
   *
   */
  public void initializeReportMap() {
    List<Class<? extends Report>> reportBeans;
    try {
      reportBeans = findReportBeans(packageToScan);
    } catch (ClassNotFoundException e) {
      logger.severe("Class not found in classpath: " + e.getMessage());
      throw new IllegalStateException(e);
    } catch (IOException e) {
      logger.severe("Could not read class file: " + e.getMessage());
      throw new IllegalStateException(e);
    }

    for (Class<? extends Report> reportBeanClass : reportBeans) {
      CsvReport csvReport = reportBeanClass.getAnnotation(CsvReport.class);

      if (csvReport.value().equals(ReportDefinitionReportType.UNKNOWN)) {
        experimentalReportsDefinitionMap.put(csvReport.fileOnlyReportType(), reportBeanClass);
      } else {
        ReportDefinitionReportType reportType = csvReport.value();
        reportDefinitionMap.put(reportType, reportBeanClass);

        Set<String> propertyExclusions = Sets.newHashSet();
        String[] reportExclusionsArray = csvReport.reportExclusions();
        propertyExclusions.addAll(Arrays.asList(reportExclusionsArray));
        List<String> propertiesToSelect =
            findReportPropertiesToSelect(reportBeanClass, propertyExclusions);
        reportProperties.put(csvReport.value(), propertiesToSelect);

        if (!isSubclass(reportBeanClass, DateReport.class)) {
          nonDateRangeReports.add(reportType);
        }
      }
    }
  }

  /**
   * Retrieves the report definitions defined by the report bean classes.
   *
   * @return the {@code Set} with all the definitions found in the report bean classes
   */
  public Set<ReportDefinitionReportType> getDefinedReports() {
    return reportProperties.keySet();
  }

  /**
   * Retrieves the bean class that maps the report data in the CSV file.
   *
   * @param reportType the type of the report.
   * @return the class of the bean that represents the report data.
   */
  public Class<? extends Report> getReportBeanClass(ReportDefinitionReportType reportType) {
    return reportDefinitionMap.get(reportType);
  }

  /**
   * Retrieves the bean class that maps the report data in the CSV file, and it's in the
   * experimental set.
   *
   * @param reportTypeName the name of the report that is in the experimental set.
   * @return the class of the bean that represents the report data.
   */
  public Class<? extends Report> getExperimentalReportBeanClass(String reportTypeName) {
    return experimentalReportsDefinitionMap.get(reportTypeName);
  }

  /**
   * Retrieves the properties that should be selected in the report.
   *
   * @param reportType the report type.
   * @return the list of properties that should be selected in the report.
   */
  public List<String> retrievePropertiesToSelect(ReportDefinitionReportType reportType) {
    return reportProperties.get(reportType);
  }

  /**
   * Checks whether the specified report type supports date range.
   *
   * @param reportType the report type.
   * @return whether the report type supports date range.
   */
  public boolean supportsDateRange(ReportDefinitionReportType reportType) {
    return !nonDateRangeReports.contains(reportType);
  }

  /**
   * Finds the properties that will be selected to be part of the report.
   *
   * @param reportBeanClass the report class.
   * @param propertyExclusions the properties that must not be added to the report.
   * @return the list of properties to be part of the report
   */
  private List<String> findReportPropertiesToSelect(Class<? extends Report> reportBeanClass,
      Set<String> propertyExclusions) {
    List<String> propertiesToSelect = new ArrayList<String>();
    Class<?> currentClass = reportBeanClass;

    while (currentClass != Object.class) {
      addAllMappedSelectionProperties(propertiesToSelect, currentClass, propertyExclusions);
      currentClass = currentClass.getSuperclass();
    }

    return propertiesToSelect;
  }

  /**
   * Adds all the mapped report properties to the selection list.
   *
   * @param propertiesToSelect the selection list
   * @param currentClass the actual class
   * @param propertyExclusions the properties that must not be added to the report.
   */
  private void addAllMappedSelectionProperties(List<String> propertiesToSelect,
      Class<?> currentClass, Set<String> propertyExclusions) {
    Field[] declaredFields = currentClass.getDeclaredFields();
    for (int i = 0; i < declaredFields.length; i++) {

      Field field = declaredFields[i];
      addPropertyNameIfAnnotationPresent(propertiesToSelect, field, propertyExclusions);
    }
  }

  /**
   * Adds the report property to select if the CSV annotation is present
   *
   * @param propertiesToSelect the list of properties that will be selected for the report.
   * @param field the field
   * @param propertyExclusions the properties that must not be added to the report.
   */
  private void addPropertyNameIfAnnotationPresent(List<String> propertiesToSelect, Field field,
      Set<String> propertyExclusions) {
    if (field.isAnnotationPresent(CsvField.class)) {
      CsvField reportFieldAnnotation = field.getAnnotation(CsvField.class);
      String reportPropertyName = reportFieldAnnotation.reportField();

      if (!propertyExclusions.contains(reportPropertyName)) {
        propertiesToSelect.add(reportPropertyName);
      }
    }
  }

  /**
   * Finds the beans classes that are annotated with {@code CsvReport} and extends the
   * {@code Report} base class.
   *
   * @param basePackage the package to be scanned.
   * @return the list of classes that match the requirements to be a report bean.
   */
  private List<Class<? extends Report>> findReportBeans(String basePackage) throws IOException,
      ClassNotFoundException {
    ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
    MetadataReaderFactory metadataReaderFactory =
        new CachingMetadataReaderFactory(resourcePatternResolver);
    String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX
        + resolveBasePackage(basePackage) + "/" + "**/*.class";
    Resource[] resources = resourcePatternResolver.getResources(packageSearchPath);

    List<Class<? extends Report>> candidates = new ArrayList<Class<? extends Report>>();
    for (Resource resource : resources) {
      addCandidateIfApplicable(resource, metadataReaderFactory, candidates);
    }

    return candidates;
  }

  /**
   * Adds the resource as a candidate if the resource matches the rules.
   *
   * @param resource the current resource.
   * @param metadataReaderFactory the meta data factory for the bean.
   * @param candidates the list of candidates.
   * @throws IOException in case the meta data could not be created.
   * @throws ClassNotFoundException in case the class is not present in the classpath
   */
  private void addCandidateIfApplicable(
      Resource resource,
      MetadataReaderFactory metadataReaderFactory,
      List<Class<? extends Report>> candidates)
      throws IOException, ClassNotFoundException {
    if (resource.isReadable()) {
      MetadataReader metadataReader = metadataReaderFactory.getMetadataReader(resource);
      
      if (isAnnotationPresentAndReportSubclass(metadataReader)) {
        String reportClassName = metadataReader.getClassMetadata().getClassName();
        Class<? extends Report> candidate = Class.forName(reportClassName).asSubclass(Report.class);
        candidates.add(candidate);
      }
    }
  }

  /**
   * Resolve the package name to a canonical path, in case there any place holders.
   *
   * @param basePackage the base package to be scanned.
   * @return the canonical version of the package name.
   */
  private static String resolveBasePackage(String basePackage) {
    return ClassUtils.convertClassNameToResourcePath(
        SystemPropertyUtils.resolvePlaceholders(basePackage));
  }

  /**
   * Checks for the annotation that maps the bean to a CSV file report.
   *
   * @param metadataReader the meta data reader for the bean class.
   * @return true if the {@code CsvReport} annotation is present and the bean class is a sub class
   *         of {@code Report}.
   */
  private boolean isAnnotationPresentAndReportSubclass(MetadataReader metadataReader) {
    String className = metadataReader.getClassMetadata().getClassName();
    try {
      Class<?> beanClass = Class.forName(className);
      if (beanClass.getAnnotation(CsvReport.class) != null && isSubclass(beanClass, Report.class)) {
        return true;
      }
    } catch (ClassNotFoundException e) {
      logger.warning("Class not found in classpath: " + className);
    }
    return false;
  }

  /**
   * Check if the given class is a subclass of the specified superclass.
   *
   * @param beanClass the class to check.
   * @param superClass the specified superclass to check against.
   * @return true if the given class is a subclass of the specified superclass.
   */
  private boolean isSubclass(Class<?> beanClass, Class<?> superClass) {
    Class<?> currentClass = beanClass;
    while (currentClass != Object.class) {
      if (currentClass == superClass) {
        return true;
      }
      currentClass = currentClass.getSuperclass();
    }
    return false;
  }
}