package processing.app;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import cc.arduino.files.DeleteFilesOnShutdown;
import processing.app.helpers.FileUtils;

import static processing.app.I18n.tr;

/**
 * This represents a single sketch, consisting of one or more files.
 */
public class Sketch {
  public static final String DEFAULT_SKETCH_EXTENSION = "ino";
  public static final List<String> OLD_SKETCH_EXTENSIONS = Arrays.asList("pde");
  public static final List<String> SKETCH_EXTENSIONS = Stream.concat(Stream.of(DEFAULT_SKETCH_EXTENSION), OLD_SKETCH_EXTENSIONS.stream()).collect(Collectors.toList());
  public static final List<String> OTHER_ALLOWED_EXTENSIONS = Arrays.asList("c", "cpp", "h", "hh", "hpp", "s");
  public static final List<String> EXTENSIONS = Stream.concat(SKETCH_EXTENSIONS.stream(), OTHER_ALLOWED_EXTENSIONS.stream()).collect(Collectors.toList());

  /**
   * folder that contains this sketch
   */
  private File folder;

  private List<SketchFile> files = new ArrayList<>();

  private File buildPath;

  public static final Comparator<SketchFile> CODE_DOCS_COMPARATOR = new Comparator<SketchFile>() {
    @Override
    public int compare(SketchFile x, SketchFile y) {
      if (x.isPrimary() && !y.isPrimary())
        return -1;
      if (y.isPrimary() && !x.isPrimary())
        return 1;
      return x.getFileName().compareTo(y.getFileName());
    }
  };

  /**
   * Create a new SketchData object, and looks at the sketch directory
   * on disk to get populate the list of files in this sketch.
   *
   * @param file
   *          Any file inside the sketch directory.
   */
  Sketch(File file) throws IOException {
    folder = file.getParentFile();
    files = listSketchFiles(true);
  }

  static public File checkSketchFile(File file) {
    // check to make sure that this .pde file is
    // in a folder of the same name
    String fileName = file.getName();
    File parent = file.getParentFile();
    String parentName = parent.getName();
    String pdeName = parentName + ".pde";
    File altPdeFile = new File(parent, pdeName);
    String inoName = parentName + ".ino";
    File altInoFile = new File(parent, inoName);

    if (pdeName.equals(fileName) || inoName.equals(fileName))
      return file;

    if (altPdeFile.exists())
      return altPdeFile;

    if (altInoFile.exists())
      return altInoFile;

    return null;
  }

  /**
   * Reload the list of files. This checks the sketch directory on disk,
   * to see if any files were added or removed. This does *not* check
   * the contents of the files, just their presence.
   *
   * @return true when the list of files was changed, false when it was
   *         not.
   */
  public boolean reload() throws IOException {
    List<SketchFile> reloaded = listSketchFiles(false);
    if (!reloaded.equals(files)) {
      files = reloaded;
      return true;
    }
    return false;
  }

  /**
   * Scan this sketch's directory for files that should be loaded as
   * part of this sketch. Doesn't modify this SketchData instance, just
   * returns a filtered and sorted list of File objects ready to be
   * passed to the SketchFile constructor.
   *
   * @param showWarnings
   *          When true, any invalid filenames will show a warning.
   */
  private List<SketchFile> listSketchFiles(boolean showWarnings) throws IOException {
    Set<SketchFile> result = new TreeSet<>(CODE_DOCS_COMPARATOR);
    for (File file : FileUtils.listFiles(folder, false, EXTENSIONS)) {
      if (BaseNoGui.isSanitaryName(FileUtils.splitFilename(file).basename)) {
        result.add(new SketchFile(this, file));
      } else if (showWarnings) {
        System.err.println(I18n.format(tr("File name {0} is invalid: ignored"), file.getName()));
      }
    }

    if (result.size() == 0)
      throw new IOException(tr("No valid code files found"));

    return new ArrayList<>(result);
  }

  /**
   * Create the data folder if it does not exist already. As a
   * convenience, it also returns the data folder, since it's likely
   * about to be used.
   */
  public File prepareDataFolder() {
    File dataFolder = getDataFolder();
    if (!dataFolder.exists()) {
      dataFolder.mkdirs();
    }
    return dataFolder;
  }

  public void save() throws IOException {
    for (SketchFile file : getFiles()) {
      if (file.isModified())
        file.save();
    }
  }

  private final String buildToolsHeader = "\n/** Arduino IDE Board Tool details\n";
  private final String buildToolsHeaderEnd = "*/";

  /**
   * Checks the code for a valid build tool header
   * @param program The code to scan for the build tools
   * @return True if the build tool header was found ONE time. Returns false if found MORE than one time, or not found at all.
   */
  private boolean containsBuildSettings(String program){
    return program.contains(buildToolsHeader) && (program.indexOf(buildToolsHeader) == program.lastIndexOf(buildToolsHeader));
  }

  /**
   * This function returns the index of the Nth occurrence of the substring in the specified string (http://programming.guide/java/nth-occurrence-in-string.html)
   * @param str The string to find the Nth occurrence in
   * @param substr The string to find
   * @param n The occurrence number you'd like to find
   * @return
   */
  private static int ordinalIndexOf(String str, String substr, int n) {
    int pos = str.indexOf(substr);
    while (--n > 0 && pos != -1)
      pos = str.indexOf(substr, pos + 1);
    return pos;
  }

  private String removeBuildSettingsHeader(Sketch sketch){
    if(sketch.getPrimaryFile().getProgram().contains(buildToolsHeader)) {
      int headerStartIndex = sketch.getPrimaryFile().getProgram().indexOf(buildToolsHeader);
      int headerStopIndex = sketch.getPrimaryFile().getProgram().indexOf(buildToolsHeaderEnd);
      if (headerStartIndex > headerStopIndex) {
        System.err.println("The build tool header is not the first comment block in your file! Please fix this.");
        for (int i = 0; i < sketch.getPrimaryFile().getProgram().length(); i++) {
          if (headerStartIndex < ordinalIndexOf(sketch.getPrimaryFile().getProgram(), buildToolsHeaderEnd, i)) {
            headerStopIndex = ordinalIndexOf(sketch.getPrimaryFile().getProgram(), buildToolsHeaderEnd, i);
            break;
          }
        }
      }
      String header = sketch.getPrimaryFile().getProgram().substring(headerStartIndex, headerStopIndex + buildToolsHeaderEnd.length());
      return sketch.getPrimaryFile().getProgram().replace(header, "");
    }
    return sketch.getPrimaryFile().getProgram();
  }

  /**
   * This checks the program code for a valid build tool settings header and returns the LinkedHashMap with the setting name and the value.
   * The build tools header should not be changed or manipulated by the pre-processor as the pre-processors output may depend on the build tools.
   * @param program The program code
   * @return The {@code LinkedHashMap} with the settings and their values of the <b>first</b> header that was found in the program code
   */
  public LinkedHashMap<String, String> getBuildSettingsFromProgram(String program){
    LinkedHashMap<String, String> buildSettings = new LinkedHashMap<>();
    if(containsBuildSettings(program)){
        int headerStartIndex = program.indexOf(buildToolsHeader);
        int headerStopIndex = program.indexOf(buildToolsHeaderEnd);
        if(headerStartIndex > headerStopIndex){
          System.err.println("The build tool header is not the first comment block in your file! Please fix this.");
          for(int i = 0; i < program.length(); i++){
            if(headerStartIndex < ordinalIndexOf(program, buildToolsHeaderEnd, i)){
              headerStopIndex = ordinalIndexOf(program, buildToolsHeaderEnd, i);
              break;
            }
          }
        }
        String header = program.substring(headerStartIndex + buildToolsHeader.length(), headerStopIndex);

        String[] headerLines = header.split("\n");

        for(int line = 0; line < headerLines.length; line++){
          String[] setting = headerLines[line].replace("*","").trim().split(": ");
          if(headerLines[line].indexOf(": ") != (headerLines[line].length() -1)){
            // The value of the setting is not empty
            buildSettings.put(setting[0].trim(), setting[1].trim());
          }else{
            buildSettings.put(setting[0], "");
          }
        }
    }else{
      if(!program.contains(buildToolsHeader)){
        // There are multiple headers, remove them
        // TODO Create a dialog asking the user to add a build header to the file
      }
    }

    return buildSettings;
  }

  private boolean isBuildSettingsEqual(LinkedHashMap<String,String> first, LinkedHashMap<String, String> second){
    return first.keySet().containsAll(second.keySet()) && first.values().containsAll(second.values());
  }

  public String setBuildSettings(Sketch sketch, LinkedHashMap<String, String> buildSettings){
    if(sketch != this){
      return "";
    }

    String customBoardSettingsHeader = buildSettings.entrySet().stream().map(entry-> String.format(" * %s: %s\n", entry.getKey(), entry.getValue())).collect(Collectors.joining("", buildToolsHeader, "*/"));
    if(!isBuildSettingsEqual(getBuildSettingsFromProgram(sketch.getPrimaryFile().getProgram()),buildSettings)){
      String headerLessProgram = removeBuildSettingsHeader(sketch);
      return customBoardSettingsHeader + ((headerLessProgram.charAt(0) == '\n') ? "" : "\n") + headerLessProgram;
    }
    return "";
  }

  public int getCodeCount() {
    return files.size();
  }

  public SketchFile[] getFiles() {
    return files.toArray(new SketchFile[0]);
  }

  /**
   * Returns a file object for the primary .pde of this sketch.
   */
  public SketchFile getPrimaryFile() {
    return files.get(0);
  }

  /**
   * Returns path to the main .pde file for this sketch.
   */
  public String getMainFilePath() {
    return getPrimaryFile().getFile().getAbsolutePath();
  }

  public SketchFile getFile(int i) {
    return files.get(i);
  }

  /**
   * Gets the build path for this sketch. The first time this is called,
   * a build path is generated and created and the same path is returned
   * on all subsequent calls.
   *
   * This takes into account the build.path preference. If it is set,
   * that path is always returned, and the directory is *not* deleted on
   * shutdown. If the preference is not set, a random pathname in a
   * temporary directory is generated, which is automatically deleted on
   * shutdown.
   */
  public File getBuildPath() throws IOException {
    if (buildPath == null) {
      if (PreferencesData.get("build.path") != null) {
        buildPath = BaseNoGui.absoluteFile(PreferencesData.get("build.path"));
        Files.createDirectories(buildPath.toPath());
      } else {
        buildPath = FileUtils.createTempFolder("arduino_build_");
        DeleteFilesOnShutdown.add(buildPath);
      }
    }

    return buildPath;
  }

  protected void removeFile(SketchFile which) {
    if (!files.remove(which))
      System.err.println("removeCode: internal error.. could not find code");
  }

  public String getName() {
    return folder.getName();
  }

  public File getFolder() {
    return folder;
  }

  public File getDataFolder() {
    return new File(folder, "data");
  }

  /**
   * Is any of the files in this sketch modified?
   */
  public boolean isModified() {
    for (SketchFile file : files) {
      if (file.isModified())
        return true;
    }
    return false;
  }

  /**
   * Finds the file with the given filename and returns its index.
   * Returns -1 when the file was not found.
   */
  public int findFileIndex(File filename) {
    int i = 0;
    for (SketchFile file : files) {
      if (file.getFile().equals(filename))
        return i;
      i++;
    }
    return -1;
  }

  /**
   * Check if renaming/saving this sketch to the given folder would
   * cause a problem because: 1. The new folder already exists 2.
   * Renaming the primary file would cause a conflict with an existing
   * file. If so, an IOEXception is thrown. If not, the name of the new
   * primary file is returned.
   */
  protected File checkNewFoldername(File newFolder) throws IOException {
    String newPrimary = FileUtils.addExtension(newFolder.getName(), DEFAULT_SKETCH_EXTENSION);
    // Verify the new folder does not exist yet
    if (newFolder.exists()) {
      String msg = I18n.format(tr("Sorry, the folder \"{0}\" already exists."), newFolder.getAbsoluteFile());
      throw new IOException(msg);
    }

    // If the folder is actually renamed (as opposed to moved somewhere
    // else), check for conflicts using the new filename, but the
    // existing folder name.
    if (!newFolder.getName().equals(folder.getName()))
      checkNewFilename(new File(folder, newPrimary));

    return new File(newFolder, newPrimary);
  }

  /**
   * Check if renaming or adding a file would cause a problem because
   * the file already exists in this sketch. If so, an IOEXception is
   * thrown.
   *
   * @param newFile
   *          The filename of the new file, or the new name for an
   *          existing file.
   */
  protected void checkNewFilename(File newFile) throws IOException {
    // Verify that the sketch doesn't have a filem with the new name
    // already, other than the current primary (index 0)
    if (findFileIndex(newFile) >= 0) {
      String msg = I18n.format(tr("The sketch already contains a file named \"{0}\""), newFile.getName());
      throw new IOException(msg);
    }

  }

  /**
   * Rename this sketch' folder to the given name. Unlike saveAs(), this
   * moves the sketch directory, not leaving anything in the old place.
   * This operation does not *save* the sketch, so the files on disk are
   * moved, but not modified.
   *
   * @param newFolder
   *          The new folder name for this sketch. The new primary
   *          file's name will be derived from this.
   *
   * @throws IOException
   *           When a problem occurs. The error message should be
   *           already translated.
   */
  public void renameTo(File newFolder) throws IOException {
    // Check intended rename (throws if there is a problem)
    File newPrimary = checkNewFoldername(newFolder);

    // Rename the sketch folder
    if (!getFolder().renameTo(newFolder))
      throw new IOException(tr("Failed to rename sketch folder"));

    folder = newFolder;

    // Tell each file about its new name
    for (SketchFile file : files)
      file.renamedTo(new File(newFolder, file.getFileName()));

    // And finally, rename the primary file
    getPrimaryFile().renameTo(newPrimary.getName());
  }


  public SketchFile addFile(String newName) throws IOException {
    // Check the name will not cause any conflicts
    File newFile = new File(folder, newName);
    checkNewFilename(newFile);

    // Add a new sketchFile
    SketchFile sketchFile = new SketchFile(this, newFile);
    files.add(sketchFile);
    Collections.sort(files, CODE_DOCS_COMPARATOR);

    return sketchFile;
  }

  /**
   * Save this sketch under the new name given. Unlike renameTo(), this
   * leaves the existing sketch in place.
   *
   * @param newFolder
   *          The new folder name for this sketch. The new primary
   *          file's name will be derived from this.
   *
   * @throws IOException
   *           When a problem occurs. The error message should be
   *           already translated.
   */
  public void saveAs(File newFolder) throws IOException {
    // Check intented rename (throws if there is a problem)
    File newPrimary = checkNewFoldername(newFolder);

    // Create the folder
    if (!newFolder.mkdirs()) {
      String msg = I18n.format(tr("Could not create directory \"{0}\""), newFolder.getAbsolutePath());
      throw new IOException(msg);
    }

    // Save the files to their new location
    for (SketchFile file : files) {
      if (file.isPrimary())
        file.saveAs(newPrimary);
      else
        file.saveAs(new File(newFolder, file.getFileName()));
    }


    // Copy the data folder (this may take a while.. add progress bar?)
    if (getDataFolder().exists()) {
      File newDataFolder = new File(newFolder, "data");
      // Check if data folder exits, if not try to create the data folder
      if (!newDataFolder.exists() && !newDataFolder.mkdirs()) {
        String msg = I18n.format(tr("Could not create directory \"{0}\""), newFolder.getAbsolutePath());
        throw new IOException(msg);
      }
      // Copy the data files into the new folder
      FileUtils.copy(getDataFolder(), newDataFolder);
    }
    
    // Change folder to the new folder
    folder = newFolder;
    
  }

  /**
   * Deletes this entire sketch from disk.
   */
  void delete() {
    FileUtils.recursiveDelete(folder);
  }
}