/*
    ProjectCreator.m

    Copyright (C) 2005  Saso Kiselkov

    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 2 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, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
*/

#import "ProjectCreator.h"

#import <Foundation/NSBundle.h>
#import <Foundation/NSSortDescriptor.h>
#import <Foundation/NSString.h>
#import <Foundation/NSFileManager.h>
#import <Foundation/NSUserDefaults.h>
#import <Foundation/NSNotification.h>
#import <Foundation/NSError.h>

#import <AppKit/NSOpenPanel.h>
#import <AppKit/NSView.h>
#import <AppKit/NSNibLoading.h>
#import <AppKit/NSTextField.h>
#import <AppKit/NSDocumentController.h>
#import <AppKit/NSMenu.h>
#import <AppKit/NSMenuItem.h>
#import <AppKit/NSApplication.h>
#import <AppKit/NSForm.h>
#import <AppKit/NSFormCell.h>
#import <AppKit/NSScrollView.h>
#import <AppKit/NSButton.h>
#import <AppKit/NSOutlineView.h>

#import <WizardKit/WizardKit.h>

#import "ProjectTypeLoader.h"
#import "ProjectType.h"
#import "ProjectTypeDescription.h"
#import "ProjectTemplateDescription.h"
#import "Preferences.h"
#import "ProjectDocument.h"

NSString * const ProjectCreatorErrorDomain =
  @"ProjectCreatorErrorDomain";

@implementation ProjectCreator

static ProjectCreator * shared = nil;

+ shared
{
  if (shared == nil)
    {
      shared = [self new];
    }

  return shared;
}

/**
 * Creates a new project.
 *
 * @param projectPath The path where to create the project.
 * @param projectName The name of the new project.
 * @param templatePath A path to the template which will be used
 *      to create the new project's files.
 * @param error A pointer which if not set to NULL will be filled
 *      with an error object describing the problem in case creating
 *      the project fails.
 *
 * After the template has been copied, the project directory is
 * searched for files named "$PROJECT_NAME$".<ext> and these are
 * then renamed to the proper project name. Simmilar variable
 * substitution occurs inside the ProjectInfo.plist.
 *
 * @return YES if creating the new project bundle succeeded, NO if it didn't.
 */
+ (BOOL) createNewProjectAtPath: (NSString *) aProjectPath
                    projectName: (NSString *) aProjectName
                   fromTemplate: (NSString *) aTemplatePath
                          error: (NSError **) error
{
  NSFileManager * fm = [NSFileManager defaultManager];
  NSDirectoryEnumerator * de;
  NSString * filename;

  if (!ImportProjectFile(aTemplatePath, aProjectPath, aProjectName, error))
    {
      return NO;
    }

  // Now read in the project's ".pmproj" file and replace variables in it.
  {
    NSString * projectInfoPlistPath;
    NSString * projectInfoPlist;

    projectInfoPlistPath = [aProjectPath
      stringByAppendingPathComponent: [aProjectName
      stringByAppendingPathExtension: @"pmproj"]];
    if (!ImportProjectFile(projectInfoPlistPath,
                           projectInfoPlistPath,
                           aProjectName,
                           error))
      {
        NSLog(_(@"Failed to create new project at path: %@"), aProjectPath);

        return NO;
      }
  }

  return YES;
}

- (void) awakeFromNib
{
  [projectNameView retain];
  [projectNameView removeFromSuperview];
  [projectTypeView retain];
  [projectTypeView removeFromSuperview];

  DESTROY(window1);
  DESTROY(window2);

  projectNameCell = [projectNameForm cellAtIndex: 0];

  [[projectNameNotice enclosingScrollView] setHasVerticalScroller: NO];
  [[projectNameMistake enclosingScrollView] setHasVerticalScroller: NO];
  [[projectTypeDescription enclosingScrollView] setHasVerticalScroller: NO];

  [projectNameMistake setTextColor: [NSColor redColor]];

  [projectNameNotice setTextColor: [NSColor disabledControlTextColor]];

  [projectTypes setDoubleAction: @selector(doubleClickedProjectType:)];

  [[NSNotificationCenter defaultCenter]
    addObserver: self
       selector: @selector(validateProjectName:)
           name: NSControlTextDidChangeNotification
         object: projectNameForm];
}

- (void) dealloc
{
  TEST_RELEASE(wizard);
  TEST_RELEASE(projectTypesCache);

  TEST_RELEASE(location);
  TEST_RELEASE(projectName);

  [super dealloc];
}

- init
{
  if ((self = [super init]) != nil)
    {
      NSArray * classes = [[[ProjectTypeLoader shared] projectTypes]
        allValues];
      NSEnumerator * e = [classes objectEnumerator];
      Class projType;
      NSMutableArray * array = [NSMutableArray arrayWithCapacity: [classes
        count]];

      while ((projType = [e nextObject]) != nil)
        {
          [array addObject: [[[ProjectTypeDescription alloc]
            initWithProjectType: projType]
            autorelease]];
        }

      ASSIGNCOPY(projectTypesCache, array);
    }

  return self;
}

/**
 * Runs a series of panels to ask the user to define a new project.
 *
 * @param withLocation If this argument is NO, then the user isn't
 * asked about the location of the project (and the key `ProjectPath'
 * will be absent in the return value), and the calling code is assumed
 * to know where the project is supposed to reside.
 *
 * @return A dictionary with these key-value pairs:
 * {
 *   ProjectName = projectName;
 *   ProjectPath = projectPath;
 *   ProjectType = projectTypeID;
 *   ProjectTemplate = templateLocation;
 * }
 */
- (NSDictionary *) getNewProjectSetupWithLocation: (BOOL) withLocation
{
  NSSavePanel * sp;
  ProjectTemplateDescription * templateDescription;
  ProjectTypeDescription * typeDescription;

  if (wizard == nil)
    {
      [NSBundle loadNibNamed: @"ProjectCreator" owner: self];
    }

  [finishButton setEnabled: NO];
  [backToLocationButton setEnabled: withLocation];
  [backToLocationButton setTransparent: !withLocation];
  [projectNameCell setStringValue: nil];

  sp = [NSSavePanel savePanel];
  [sp setTitle: _(@"Please choose a project location")];
  [sp setCanCreateDirectories: YES];

  if (withLocation == YES)
    {
      BOOL isDir;

selectLocation:
      if ([sp runModal] == NSCancelButton)
        {
          return nil;
        }

      ASSIGN(location, [sp filename]);

      if ([[NSFileManager defaultManager]
        fileExistsAtPath: location isDirectory: &isDir])
        {
          if (isDir)
            {
              if (NSRunAlertPanel(_(@"Use existing directory?"),
                _(@"You have selected an existing directory. The project\n"
                  @"files will be created in %@.\n"
                  @"Are you sure this is what you want?"),
                _(@"Yes, next"), _(@"No, back"), nil, location) ==
                NSAlertAlternateReturn)
                {
                  goto selectLocation;
                }
            }
          else
            {
              NSRunAlertPanel(_(@"Invalid path"),
                _(@"Cannot overwrite a file. Please choose a directory "
                  @"or specify an unused filename."),
                nil, nil, nil);

              goto selectLocation;
            }
        }

      [projectNameNotice setString: _(@"Leave blank to make the "
        @"project name the same as the name of the project directory")];
    }
  else
    {
      DESTROY(location);

      [projectNameNotice setString: _(@"You must specify a project name")];
    }

  [self validateProjectName: nil];

  switch ([wizard activate: nil])
    {
    case NSRunStoppedResponse:
      break;
    case NSRunAbortedResponse:
      return nil;
    case -1:
      goto selectLocation;
    }

  templateDescription = [projectTypes itemAtRow: [projectTypes selectedRow]];
  typeDescription = [templateDescription parent];

  if (withLocation)
    {
      return [NSDictionary dictionaryWithObjectsAndKeys:
        projectName, @"ProjectName", 
        [typeDescription name], @"ProjectType",
        [[typeDescription projectType] pathToProjectTemplate:
        [templateDescription name]] , @"ProjectTemplate",
        location, @"ProjectPath",
        nil];
    }
  else
    {
      return [NSDictionary dictionaryWithObjectsAndKeys:
        projectName, @"ProjectName", 
        [typeDescription name], @"ProjectType",
        [[typeDescription projectType] pathToProjectTemplate:
        [templateDescription name]] , @"ProjectTemplate",
        nil];
    }
}

- (void) validateProjectName: sender
{
  if ([[projectNameCell stringValue] length] == 0)
    {
      ASSIGN(projectName, [location lastPathComponent]);
    }
  else
    {
      ASSIGN(projectName, [projectNameCell stringValue]);
    }

  if (projectName != nil)
    {
      // validate a non-nil project name
      NSError * error = nil;

      [toProjectTypeSelectionButton setEnabled: [ProjectDocument
        validateProjectName: projectName error: &error]];
      [projectNameMistake setString: [[error userInfo]
        objectForKey: NSLocalizedDescriptionKey]];
    }
  else
    {
      // otherwise require the user to enter a project name
      [toProjectTypeSelectionButton setEnabled: NO];
      [projectNameMistake setString: nil];
    }
}

- (void) cancel: sender
{
  [wizard deactivateWithCode: NSRunAbortedResponse];
}

- (void) goToProjectLocationSelection: sender
{
  [wizard deactivateWithCode: -1];
}

- (void) projectTypeSelected: sender
{
  int row = [projectTypes selectedRow];

  if (row >= 0)
    {
      id object = [projectTypes itemAtRow: row];

      if ([object isKindOfClass: [ProjectTemplateDescription class]])
        {
          [finishButton setEnabled: YES];
          [templateNeededNotice setStringValue: nil];
        }
      else
        {
          [finishButton setEnabled: NO];
          [templateNeededNotice setStringValue: _(@"Please select "
            @"a template")];
        }

      [projectTypeIcon setImage: [object icon]];
      [projectTypeDescription setString: [object description]];
    }
  else
    {
      [finishButton setEnabled: NO];
      [templateNeededNotice setStringValue: _(@"Please select "
            @"a project type")];
    }
}

/**
 * Action invoked when the user double-clicks the projectType
 * outline. Since each double-click also invokes a single-click
 * before (which means that the user's choice has been validated
 * in -[ProjectCreator projectTypeSelected:], we only check whether
 * the button is enabled (meaning that the selection is valid)
 * and aftewards make the button perform a click.
 */
- (void) doubleClickedProjectType: sender
{
  if ([finishButton isEnabled])
    {
      [finishButton performClick: self];
    }
}

- (NSView *) wizardPanel: (WKWizardPanel *) sender
            viewForStage: (NSString *) aStageName
{
  if ([aStageName isEqualToString: @"Project Name"])
    {
      return projectNameView;
    }
  else
    {
      return projectTypeView;
    }
}

- (NSView *)       wizardPanel: (WKWizardPanel *) sender
 initialFirstResponderForStage: (NSString *) aStageName
{
  if ([aStageName isEqualToString: @"Project Name"])
    {
      return projectNameForm;
    }
  else
    {
      return projectTypes;
    }
}

- (int)     outlineView: (NSOutlineView *) outlineView
 numberOfChildrenOfItem: (id) item
{
  if (item == nil)
    {
      return [projectTypesCache count];
    }
  else
    {
      if ([item isKindOfClass: [ProjectTypeDescription class]])
        {
          return [[item templates] count];
        }
      else
        {
          return 0;
        }
    }
}

- (BOOL) outlineView: (NSOutlineView *) outlineView
    isItemExpandable: (id) item
{
  if ([item isKindOfClass: [ProjectTypeDescription class]])
    {
      return YES;
    }
  else
    {
      return NO;
    }
}

- (id) outlineView: (NSOutlineView *) outlineView
             child: (int) index
            ofItem: (id) item
{
  if (item == nil)
    {
      return [projectTypesCache objectAtIndex: index];
    }
  else
    {
      return [[item templates] objectAtIndex: index];
    }
}

- (id)          outlineView: (NSOutlineView *) outlineView
  objectValueForTableColumn: (NSTableColumn *) tableColumn
                     byItem: (id) item
{
  return [item name];
}

@end

/**
 * Creates a directory at `dirPath' and the intermediate directories.
 *
 * This function creates a directory at `dirPath' (if it doesn't exist
 * already), and any intermediate directories as necessary (simmilar
 * to mkdir -p). If a file exists at the specified `dirPath' (or any of
 * intermediate directories is in fact a file), the operation fails.
 *
 * @return YES if the directory is created (or exists already), NO otherwise.
 */
BOOL
CreateDirectoryAndIntermediateDirectories(NSString * dirPath,
                                          NSError ** error)
{
  NSFileManager * fm = [NSFileManager defaultManager];
  BOOL isDir;

  // does it already exist?
  if ([fm fileExistsAtPath: dirPath isDirectory: &isDir])
    {
      if (isDir)
        {
          return YES;
        }
      // some file is in the way
      else
        {
          if (error != NULL)
            {
              NSDictionary * userInfo;

              userInfo = [NSDictionary
                dictionaryWithObject: [NSString stringWithFormat:
                _(@"Couldn't create intermediate "
                  @"nodes to %@: a file is in the way."), dirPath]
                              forKey: NSLocalizedDescriptionKey];

              *error = [NSError errorWithDomain: ProjectCreatorErrorDomain
                                           code: ProjectDirectoryCreationError
                                       userInfo: userInfo];
            }

          return NO;
        }
    }
  // no, start creating the intermediate directories as necessary
  else
    {
      NSEnumerator * e = [[dirPath pathComponents] objectEnumerator];
      NSString * path, * pathComponent;

      for (path = [e nextObject]; (pathComponent = [e nextObject]) != nil;)
        {
          path = [path stringByAppendingPathComponent: pathComponent];

          if ([fm fileExistsAtPath: path isDirectory: &isDir])
            {
              if (isDir)
                {
                  continue;
                }
              else
                {
                  if (error != NULL)
                    {
                      NSDictionary * userInfo;

                      userInfo = [NSDictionary
                        dictionaryWithObject: [NSString stringWithFormat:
                        _(@"Couldn't create intermediate "
                          @"node %@: a file is in the way."), path]
                                      forKey: NSLocalizedDescriptionKey];

                      *error = [NSError
                        errorWithDomain: ProjectCreatorErrorDomain
                                   code: ProjectDirectoryCreationError
                               userInfo: userInfo];
                    }

                  return NO;
                }
            }
          else
            {
              if (![fm createDirectoryAtPath: path attributes: nil])
                {
                  if (error != NULL)
                    {
                      NSDictionary * userInfo;

                      userInfo = [NSDictionary
                        dictionaryWithObject: [NSString stringWithFormat:
                        _(@"Couldn't create intermediate directory %@"), path]
                                      forKey: NSLocalizedDescriptionKey];

                      *error = [NSError
                        errorWithDomain: ProjectCreatorErrorDomain
                                   code: ProjectDirectoryCreationError
                               userInfo: userInfo];
                    }

                  return NO;
                }
            }
        }
    }

  return YES;
}

static BOOL
ImportProjectDirectoryFile(NSString * sourcePath,
                           NSString * destinationPath,
                           NSString * projectName,
                           NSError ** error)
{
  NSFileManager * fm = [NSFileManager defaultManager];
  NSString * filepath;
  NSDirectoryEnumerator * de;

  // do we need to copy at all?
  if (![sourcePath isEqualToString: destinationPath])
    {
      if (!CreateDirectoryAndIntermediateDirectories([destinationPath
        stringByDeletingLastPathComponent], error))
        {
          return NO;
        }

      if (![fm copyPath: sourcePath toPath: destinationPath handler: nil])
        {
          if (error != NULL)
            {
              NSDictionary * userInfo;

              userInfo = [NSDictionary
                dictionaryWithObject: @"Failed to copy source directory "
                @"to destination"
                              forKey: NSLocalizedDescriptionKey];

              *error = [NSError errorWithDomain: ProjectCreatorErrorDomain
                                           code: ProjectFileImportError
                                       userInfo: userInfo];
            }

          return NO;
        }
    }

  // enumerate through the files of the project and if we find
  // a file which has $PROJECT_NAME$ as part of it's name we
  // rename it to the proper project name
performRenaming:
  de = [fm enumeratorAtPath: destinationPath];
  while ((filepath = [de nextObject]) != nil)
    {
      NSString * origFilename = [filepath lastPathComponent];
      NSMutableString * filename = [[origFilename mutableCopy] autorelease];

      // substitute variables in the filename
      [filename replaceString: @"$PROJECT_NAME$" withString: projectName];
      [filename replaceString: @"$HOSTNAME$"
                   withString: [[NSProcessInfo processInfo] hostName]];
      [filename replaceString: @"$USER$" withString: NSUserName()];
      [filename replaceString: @"$DATE$"
                   withString: [[NSDate date] description]];

      // if the result is different from the original, perform
      // a move operation
      if (![filename isEqualToString: origFilename])
        {
          NSDictionary * fattrs = [de fileAttributes];
          NSString * newPath = [[filepath stringByDeletingLastPathComponent]
            stringByAppendingPathComponent: filename];

          if (![fm movePath:
            [destinationPath stringByAppendingPathComponent: filepath]
                     toPath:
            [destinationPath stringByAppendingPathComponent: newPath]
                    handler: nil])
            {
              if (error != NULL)
                {
                  NSDictionary * userInfo;

                  userInfo = [NSDictionary
                    dictionaryWithObject: [NSString stringWithFormat:
                    _(@"Failed to substitute %@ for %@"), newPath, filepath]
                                  forKey: NSLocalizedDescriptionKey];

                  *error = [NSError errorWithDomain: ProjectCreatorErrorDomain
                                               code: ProjectFileImportError
                                           userInfo: userInfo];
                }

              return NO;
            }

          // if it's a directory we need to restart the search
          if ([[fattrs fileType] isEqualToString: NSFileTypeDirectory])
            {
              goto performRenaming;
            }
        }
    }

  return YES;
}

static BOOL
ImportProjectPlainFile(NSString * sourceFile,
                       NSString * destinationFile,
                       NSString * projectName,
                       NSError ** error)
{
  NSMutableString * string;

  string = [NSMutableString stringWithContentsOfFile: sourceFile];
  if (string == nil)
    {
      if (error != NULL)
        {
          NSDictionary * userInfo;

          userInfo = [NSDictionary
            dictionaryWithObject: [NSString stringWithFormat:
            _(@"Couldn't import file %@: file not readable"), sourceFile]
                          forKey: NSLocalizedDescriptionKey];

          *error = [NSError errorWithDomain: ProjectCreatorErrorDomain
                                       code: ProjectFileImportError
                                   userInfo: userInfo];
        }

      return NO;
    }

  // the following variables are expanded:
  // $PROJECT_NAME$ - expands to the project's name
  // $FILENAME$ - expands to the file's name, i.e. the last path
  //        component of the path to the file
  // $FILENAME_EXT$ - expands to the file name's last path component
  //        with it's path extension trimmed
  // $FILENAME_CAPS$ - expands to the file's name with all
  //        letters uppercase. Additionally, all '.' will be replaced
  //        with '_'
  // $FILENAME_LOWER$ - expands to the file's name with all
  //        letters lowercase. Additionally, all '.' will be replaced
  //        with '_'
  // $FILEPATH$ - expands to the destination path where in the project
  //        the file will reside
  // $FILEPATH_DIR$ - expands to the destination path where in the project
  //        the file will reside with the last path component trimmed
  // $USER$ - expands to the name of the user running ProjectManager
  // $DATE$ - expands to the current date as returned by
  //        [[NSDate date] description]
  // $HOSTNAME$ - the hostname of the machine ProjectManager is running on

  [string replaceString: @"$PROJECT_NAME$" withString: projectName];

  [string replaceString: @"$FILENAME$" withString: [destinationFile
    lastPathComponent]];

  [string replaceString: @"$FILENAME_EXT$" withString: [[destinationFile
    lastPathComponent] stringByDeletingPathExtension]];
  [string replaceString: @"$FILENAME_CAPS$" withString: [[[destinationFile
    lastPathComponent] uppercaseString] stringByReplacingString: @"."
                                                     withString: @"_"]];
  [string replaceString: @"$FILENAME_LOWER$" withString: [[[destinationFile
    lastPathComponent] lowercaseString] stringByReplacingString: @"."
                                                     withString: @"_"]];
  [string replaceString: @"$FILEPATH$" withString: destinationFile];
  [string replaceString: @"$FILEPATH_DIR$" withString: [destinationFile
    stringByDeletingLastPathComponent]];

  [string replaceString: @"$USER$" withString: NSUserName()];
  [string replaceString: @"$DATE$" withString: [[NSDate date] description]];
  [string replaceString: @"$HOSTNAME$"
             withString: [[NSProcessInfo processInfo] hostName]];

  if ([string writeToFile: destinationFile atomically: NO])
    {
      return YES;
    }
  else
    {
      if (error != NULL)
        {
          NSDictionary * userInfo;

          userInfo = [NSDictionary
            dictionaryWithObject: [NSString stringWithFormat:
            _(@"Couldn't import file %@: failed to write to destination %@."),
            sourceFile, destinationFile]
                          forKey: NSLocalizedDescriptionKey];

          *error = [NSError errorWithDomain: ProjectCreatorErrorDomain
                                       code: ProjectFileImportError
                                   userInfo: userInfo];
        }

      return NO;
    }
}

/**
 * Copies `sourceFile' to `destinationFile' and replaces special
 * project variables (such as $FILENAME$ or $USER$) in the file
 * while doing so. If, instead, the file is a directory, the filenames
 * of it's contents are (recursively) replaced. The argument
 * `projectName' specifies the name of the project that's importing
 * the file.
 *
 * The arguments `sourceFile' and `destinationFile' can be identical,
 * in which case only variable substitution occurs without any copying.
 *
 * @return YES if the import succeeds, otherwise NO.
 */
BOOL
ImportProjectFile(NSString * sourceFile,
                  NSString * destinationFile,
                  NSString * projectName,
                  NSError ** error)
{
  NSFileManager * fm = [NSFileManager defaultManager];

  if ([[[fm fileAttributesAtPath: sourceFile traverseLink: YES]
    fileType]
    isEqualToString: NSFileTypeDirectory])
    {
      return ImportProjectDirectoryFile(sourceFile,
                                        destinationFile,
                                        projectName,
                                        error);
    }

  // otherwise do some variable expansion on the file before copying it
  else
    {
      return ImportProjectPlainFile(sourceFile,
                                    destinationFile,
                                    projectName,
                                    error);
    }
}
