/*
  AutoPathSync
  Copyright (C) 2021 Joey Flamand

  This program is free software: you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation, either version 3 of the License, or
  (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

import javax.swing.*;
import java.awt.GraphicsEnvironment;
import java.util.LinkedList;
import java.util.ListIterator;
import java.io.File;
import java.nio.file.*; // Files, Path
import java.nio.file.attribute.*;
import java.io.IOException;

public class AutoPathSync 
{
  public static void TerminateMessage(String msg)
  {
    System.out.println(msg);
    System.exit(1);
  }
  
  //MAIN =======================================================================
  public static void main(String[] args)
  {
    String pollingDir = "";
    String mirrorDir = "";
    String initSync = "0"; // "0" or "1" defaults to No
    
    if(args.length > 0) // SOME arguments passed, dont use gui
    {
      if(args.length == 2 || args.length == 3) // assume polling/mirror supplied
      {
        // we have at least two args.
        pollingDir = args[0];
        mirrorDir  = args[1];
        
        if(args.length == 3) // set the third arg to the initSync
          initSync = args[2];
      }
      else
        TerminateMessage("Program must be supplied arguments: [polldir] [mirrordir] [0|1 initSync optional]");
    }
    else // if nothing passed, check if GUI, else error.
    {
      if(!GraphicsEnvironment.isHeadless()) // GUI
      {
        pollingDir = InvokeDirectoryChooser("Select Polling Directory");
        if(pollingDir.length() == 0)
          TerminateMessage("Error: Polling Directory Not Set.");
        
        mirrorDir = InvokeDirectoryChooser("Select Mirror Directory");
        if(mirrorDir.length() == 0)
          TerminateMessage("Error: Mirror Directory Not Set.");
        
        // Init Sync?
        if (JOptionPane.showConfirmDialog(null, "Synchronize the Mirror Directory with the Polling Directory?", "Notice", JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION) 
          initSync = "1";
        
      }
      else
        TerminateMessage("(No GUI available) Program must be supplied arguments: [polldir] [mirrordir] [0|1 initSync]");
    }
    
    AutoPathSync ps = new AutoPathSync(pollingDir, mirrorDir, initSync);
    
    ps.Run();
  }
  //============================================================================
  
  private abstract class ThinParent
  {
    public String name;
    public long modDate;
    public boolean bTicked;
  }
  private class ThinFile extends ThinParent
  {
    public ThinFile(String n, long m)
    {
      name = n;
      modDate = m;
      bTicked = false; 
    }
  }
  private class ThinFolder extends ThinParent
  {
    public LinkedList<ThinParent> subFolders; // recursive store for more subfolders and thier files 
    public LinkedList<ThinParent> subFiles;
    
    public int nameCollectionHash; // hash of all FILEnames + all (FOLDERnames+thier nameHashCollections) 
    public int modCollectionHash; // hash of all FILEmods + FOLDERmods 
    
    public ThinFolder(String n, long m)
    {
      name = n;
      modDate = m; // represents the latest edited file that resides under this folder in the file-tree
      bTicked = false; 
      subFolders = new LinkedList<ThinParent>();
      subFiles = new LinkedList<ThinParent>();
    }
    public long hasFile(ThinFile file)
    {
      ListIterator<ThinParent> it = subFiles.listIterator(0);
      ThinParent tempFile;
      while(it.hasNext())
      {
        tempFile = it.next();
        if(file.name.equals(tempFile.name) /*&& (file.modDate == tempFile.modDate)*/ )
        {
          tempFile.bTicked = true;
          return tempFile.modDate;
        }
      }      
      return -1L;
    }
    public ThinFolder hasFolder(ThinFolder folder)
    {
      ListIterator<ThinParent> it = subFolders.listIterator(0);
      ThinParent tempFolder;
      while(it.hasNext())
      {
        tempFolder = it.next();
        if(folder.name.equals(tempFolder.name))
        {
          tempFolder.bTicked = true;
          return (ThinFolder)tempFolder;
        }
      }      
      return null;
    }
    
  }
  private class LoadResultHolder
  {
    public int nameHash;
    public int modHash;
    public LoadResultHolder(int n, int m)
    {
      nameHash = n;
      modHash = m;
    }
  }
  
  // System-wide params 
  public String pollingDir; // working directory
  public String mirrorDir;  // where its copied to 
  
  public boolean bRunInitSync;
  
  public ThinFolder root1;
  public ThinFolder root2;
  public ThinFolder root3;
  
  public void loadRoot1()
  {
    File aFile = new File(pollingDir);
    loadAFolder(root1 = new ThinFolder(aFile.getName(), 0), aFile);
  }
  public void loadRoot2()
  {
    File aFile = new File(pollingDir);
    loadAFolder(root2 = new ThinFolder(aFile.getName(), 0), aFile);
  }
  public void loadRoot3()
  {
    File aFile = new File(pollingDir);
    loadAFolder(root3 = new ThinFolder(aFile.getName(), 0), aFile);
  }
  public LoadResultHolder loadAFolder(ThinFolder curThin, File curFile)
  {
    String filenameBar = "";
    String modBar = "";

    File temp;
    File[] allSubItems = curFile.listFiles();
    for(int i=0; i < allSubItems.length; ++i)
    {
      temp = allSubItems[i];
      if(temp.isFile())
      {
        long templastmod = temp.lastModified();
        
        curThin.subFiles.add(new ThinFile(temp.getName(), templastmod));
        
        filenameBar += temp.getName();      
        modBar += templastmod; // becomes a String of longs tacked together...
      }
      else // is dir
      {
        ThinFolder newFold = new ThinFolder(temp.getName(), 0);
        curThin.subFolders.add(newFold);

        // recurse
        LoadResultHolder holder = loadAFolder(newFold, temp);
        int namehashFromBelow = holder.nameHash;   
        int modHashFromBelow =  holder.modHash;
        
        // add name of subfolder + names from below
        filenameBar += temp.getName() + namehashFromBelow; 
        modBar += modHashFromBelow; // here we're tacking on an int, but should be ok...
      }
    }    
    // assign hashes and return them 
    int filenameBarHash = filenameBar.hashCode();
    curThin.nameCollectionHash = filenameBarHash;    
    int modBarHash = modBar.hashCode();
    curThin.modCollectionHash = modBarHash;    
    return new LoadResultHolder(filenameBarHash, modBarHash);
  }
  
  // bConsolidate, if true, pushes out differences, if false, returns false upon detecting the first difference. 
  public boolean RootCompare(String curPath, ThinFolder foldA, ThinFolder foldB, boolean bConsolidate)
  {
    if( (foldA.nameCollectionHash != foldB.nameCollectionHash) || 
        (foldA.modCollectionHash != foldB.modCollectionHash) )
    {
      if(!bConsolidate)
        return false;
      else
      {
        // Check all files and subfolders...
        
        ListIterator<ThinParent> d = foldA.subFolders.listIterator(0);
        ThinParent tempFolder;
        while(d.hasNext())
        {
          tempFolder = d.next();
          ThinFolder candidate = foldB.hasFolder((ThinFolder)tempFolder); // its ticked inside the call
          if( candidate != null ) // exists in foldA AND foldB 
          {        
            boolean bRet = RootCompare(curPath + "/" + tempFolder.name,(ThinFolder)tempFolder, candidate, bConsolidate);
            if(!bConsolidate && !bRet)
              return false;
          }
          else // if now not in 'B', DELETE from target
          {
            System.out.println(mirrorDir + curPath + "/" + tempFolder.name + "  (Removing Folder from Mirror Dir)");
            MyDeleteDir(mirrorDir + curPath + "/" + tempFolder.name);            
          }          
        }
        //  checkfor unticked folders (copy them to the target)
        ListIterator<ThinParent> dd = foldB.subFolders.listIterator(0);
        while(dd.hasNext())
        {
          tempFolder = dd.next();
          if(tempFolder.bTicked == false)
          {
            System.out.println(pollingDir + curPath + "/" + tempFolder.name + "  (Copying New Folder to Mirror Dir)");
            Path source = Paths.get( pollingDir + curPath + "/" + tempFolder.name );
            Path target = Paths.get( mirrorDir  + curPath + "/" + tempFolder.name );
            
            try
            {
              Files.walkFileTree( source , new CopyFileVisitor(target) );
            }
            catch (Exception e)
            {
              e.printStackTrace();
            }
          }
        }
        
        // iterate over A's files, ticking b's files...
        ListIterator<ThinParent> e = foldA.subFiles.listIterator(0);
        ThinParent tempFile;
        while(e.hasNext())
        {
          tempFile = e.next();
          long retLong;
          if( (retLong = foldB.hasFile((ThinFile)tempFile)) != -1L ) // its ticked inside the call
          {            
            // Make sure the mod time matches...
            if(tempFile.modDate != retLong)
            {
              // Copy the current one to the target
              System.out.println(pollingDir + curPath + "/" + tempFile.name + " (Modify Date Changed, Copying to Mirror Dir)");
              MyDeleteFileIfThere(mirrorDir + curPath + "/" + tempFile.name);
              MyCopyFileAttributes(pollingDir + curPath + "/" + tempFile.name,
                                   mirrorDir + curPath + "/" + tempFile.name     );
            }
          }
          else // if now not in 'B', DELETE from target
          {
            System.out.println(pollingDir + curPath + "/" + tempFile.name + " (No Longer Exists, Removing from Mirror Dir)");
            MyDeleteFileIfThere(mirrorDir + curPath + "/" + tempFile.name);
          }
        }
        // checkfor unticked files (copy them to the target)
        ListIterator<ThinParent> it2 = foldB.subFiles.listIterator(0);
        while(it2.hasNext())
        {
          tempFile = it2.next();
          if(tempFile.bTicked == false)
          {
            System.out.println(pollingDir + curPath + "/" + tempFile.name + " (New File Detected, Copying to Mirror Dir)");
            MyCopyFileAttributes(pollingDir + curPath + "/" + tempFile.name,
                mirrorDir + curPath + "/" + tempFile.name     );   
          }
        }        
      }
    }    
    return true; // no differences 
  }
  
  public AutoPathSync(String p, String m, String runInitSync)
  {
    pollingDir = p;
    mirrorDir = m;
    if(runInitSync.equals("1"))
      bRunInitSync = true;
    else
      bRunInitSync = false;
      
  }
  
  public void InitializationSync()
  {
    // make sure the Dirs dont end in a slash...
    if(!pollingDir.equals("") &&( pollingDir.endsWith("/") || pollingDir.endsWith("\\") ))
      pollingDir = pollingDir.substring(0, pollingDir.length()-1); // cut a char off
    if(!mirrorDir.equals("") &&( mirrorDir.endsWith("/") || mirrorDir.endsWith("\\") ))
      mirrorDir = mirrorDir.substring(0, mirrorDir.length()-1); // cut a char off
      
    //load up 2 instances...
    ThinFolder thinFolA, thinFolB;
    File mirrorDirFile = new File(mirrorDir);
    File workingDirFile = new File(pollingDir);    
    if(!mirrorDirFile.exists())
    {
      System.out.println("Mirror Dir (2nd Argument) does not exist.");
      System.exit(1);
    }
    if(!workingDirFile.exists())
    {
      System.out.println("Source Dir (1st Argument) does not exist.");
      System.exit(1);
    }
      
    if(bRunInitSync)
    {
      System.out.println("Performing Initial Sync."); 
      loadAFolder( thinFolA = new ThinFolder(mirrorDirFile.getName(), 0), mirrorDirFile); // mirrored dir init state
      
      loadAFolder( thinFolB = new ThinFolder(workingDirFile.getName(), 0), workingDirFile);//pretend state 2
      
      RootCompare("", thinFolA, thinFolB, true); // sync them 
      System.out.println("Initial Sync Complete."); 
    }
  }
  
  public void Run()
  {
    // First make sure the two paths are there and synced up beforehand, then begin polling.
    InitializationSync();
    
    loadRoot1();
    //PrintThinFolder( root1 , "");
    
    System.out.println("Loaded Initial State.");
    try
    {
      Thread.sleep(2000);
      int cnt=0;
      while(true)
      {
        loadRoot2();
        
        String dots = ".";
        if(cnt%3==1)
          dots+=".";
        else if(cnt%3==2)
          dots+="..";
        
        if(!RootCompare("", this.root1, this.root2, false)) // if we detect a change...
        {
          
          System.out.println("Change Detected.");
          while(true)
          {
            Thread.sleep(1500);
            loadRoot3();
            if(RootCompare("", this.root2, this.root3, false)) // if it STOPPED changing.
            {
              System.out.println("Stopped Changing. Pushing changes.");
              RootCompare("", this.root1, this.root3, true); // PUSH OUT WHAT HAS CHANGED...
              root1 = root3; // latest root is now the main.
              break;
            }
            else
              this.root2 = this.root3;
          }
        }
        else
          System.out.println("No Differences" + dots);
        
        Thread.sleep(2000);
        cnt++;
      }
    }
    catch(Exception e)
    {
      e.printStackTrace();
      System.exit(1);
    }
  }
  
  public void PrintThinFolder(ThinFolder fold, String prefix) // for debugging/visualizaion
  {
    System.out.println(prefix + ">" + fold.name + " " + fold.modDate + "  name:" + Integer.toHexString(fold.nameCollectionHash) + "  mod:" + Integer.toHexString(fold.modCollectionHash));
    
    ListIterator<ThinParent> d = fold.subFolders.listIterator(0);
    ThinParent tempFolder;
    while(d.hasNext())
    {
      tempFolder = d.next();
      
      PrintThinFolder((ThinFolder)tempFolder, prefix + " ");
    }
    
    // go over the files
    ListIterator<ThinParent> e = fold.subFiles.listIterator(0);
    ThinParent tempFile;
    while(e.hasNext())
    {
      tempFile = e.next();
      System.out.println(prefix + " " + tempFile.name + " " + tempFile.modDate + " "  );
    }    
  }
  
  // DELETE/COPY STATIC HELPER FNs ==============================================================
  public static void MyCopyFileAttributes(String source, String target)
  {
    Path sourceFile = Paths.get(source);
    Path targetFile = Paths.get(target);
    try
    {
      Files.copy(sourceFile, targetFile, StandardCopyOption.COPY_ATTRIBUTES);
    }
    catch(Exception e)
    {
      e.printStackTrace();
    }
  }
  public static void MyDeleteFileIfThere(String path)
  {
    Path sourceFile = Paths.get(path);
    try
    {
      Files.deleteIfExists(sourceFile);
    }
    catch(Exception e)
    {
      e.printStackTrace();
    }
  }
  public static void MyDeleteDir(String path)
  {
    Path rootPath = Paths.get(path);
    try
    {
      Files.walkFileTree(rootPath, new SimpleFileVisitor<Path>() {
        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException
        {
          Files.delete(file);
          return FileVisitResult.CONTINUE;
        }
        @Override
        public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException
        {
          if( e==null )
          {
            Files.delete(dir);
            return FileVisitResult.CONTINUE;
          }
          else
          {
            throw e;
          }
        }
      });
    }
    catch(Exception e)
    {
      e.printStackTrace();
    }
  }
  
  public static class CopyFileVisitor extends SimpleFileVisitor<Path> // USAGE: Files.walkFileTree(sourcePath, new CopyFileVisitor(targetPath));
  {
    private final Path targetPath;
    private Path sourcePath = null;
    public CopyFileVisitor(Path targetPath) throws IOException
    {
        this.targetPath = targetPath;
        
        Files.createDirectory(targetPath); // initially, the target dir wont be there, make it
    }

    @Override
    public FileVisitResult preVisitDirectory(final Path dir,
    final BasicFileAttributes attrs) throws IOException {
        if (sourcePath == null) {
            sourcePath = dir;
        } else {
        Files.createDirectories(targetPath.resolve(sourcePath.relativize(dir)));
        }
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFile(final Path file,
    final BasicFileAttributes attrs) throws IOException {
    Files.copy(file,
        targetPath.resolve(sourcePath.relativize(file)));
    return FileVisitResult.CONTINUE;
    }
   }
   
   // if user selects a dir then return its string, else empty string
  // requires (import javax.swing.*;)
  public static String InvokeDirectoryChooser(String windowTitle)
  {
    JFileChooser chooser;
    
    chooser = new JFileChooser(); 
    chooser.setCurrentDirectory(new java.io.File("."));
    chooser.setDialogTitle(windowTitle);
    chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
    chooser.setAcceptAllFileFilterUsed(false); // disable the "All files" option.
    
    if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) 
      return chooser.getSelectedFile().getPath();

    return "";
  }

}
