/*
    ProjectDocument.m

    Implementation of the ProjectDocument class for the
    ProjectManager application.

    Copyright (C) 2005, 2006  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 "ProjectDocument.h"

#import <Foundation/NSString.h>
#import <Foundation/NSArray.h>
#import <Foundation/NSDictionary.h>
#import <Foundation/NSUserDefaults.h>
#import <Foundation/NSProcessInfo.h>

#import <AppKit/NSWorkspace.h>
#import <AppKit/NSDocumentController.h>
#import <AppKit/NSImage.h>
#import <AppKit/NSPanel.h>
#import <AppKit/NSOpenPanel.h>
#import <AppKit/NSButton.h>

#import "SourceEditorDocument.h"
#import "ProjectWindowController.h"
#import "ProjectType.h"
#import "ProjectTypeLoader.h"
#import "ProjectModule.h"
#import "ProjectModuleLoader.h"

#import "ProjectCreator.h"

#import "NSArrayAdditions.h"

NSString * const ProjectNameDidChangeNotification =
  @"ProjectNameDidChangeNotification";

NSString * const ProjectDocumentErrorDomain = @"ProjectDocumentErrorDomain";

/**
 * Compares two version numbers. The arguments are version strings,
 * which is a string containing numbers delimited by ".", such as
 * "0.1.2". The numbers must be ordered from most significant number
 * to least significant.
 *
 * @return The standard values of the NSComparisonResult enum.
 */
static NSComparisonResult
CompareVersions (NSString * first, NSString * second)
{
  NSArray * firstVersion = [first componentsSeparatedByString: @"."],
          * secondVersion = [second componentsSeparatedByString: @"."];
  int i, n1, n2;

  for (i = 0, n1 = [firstVersion count], n2 = [secondVersion count];
    i < n1 || i < n2;
    i++)
    {
      int firstNumber, secondNumber;

      if (i < n1)
        {
          firstNumber = [[firstVersion objectAtIndex: i] intValue];
        }
      else
        {
          firstNumber = 0;
        }

      if (i < n2)
        {
          secondNumber = [[secondVersion objectAtIndex: i] intValue];
        }
      else
        {
          secondNumber = 0;
        }

      // if it's greater we don't have to compare the rest anymore
      if (firstNumber > secondNumber)
        {
          return NSOrderedDescending;
        }
      // simmilarily, if it's lower the rest doesn't matter anymore
      else if (firstNumber < secondNumber)
        {
          return NSOrderedAscending;
        }
    }

  // both versions are equal
  return NSOrderedSame;
}

/**
 * Checks whether the provided string is a valid framework (i.e
 * doesn't contain spaces and a couple of other limitations).
 * If the name isn't valid, the user is informed of the fact with
 * an alert panel.
 *
 * @return YES if the string is a valid framework name, NO otherwise.
 */
BOOL
IsValidFrameworkName (NSString * frameworkName)
{
  if ([frameworkName length] != [[frameworkName
    stringByTrimmingCharactersInSet: [NSCharacterSet
    whitespaceAndNewlineCharacterSet]] length])
    {
      NSRunAlertPanel(_(@"Invalid framework name"),
        _(@"A framework name may not contain whitespace characters."),
        nil, nil, nil);

      return NO;
    }

  return YES;
}

/**
 * Gets the category contents array of a category description
 * contained in `supercategoryContentsArray' for a category named
 * `categoryName'.
 *
 * @return The category contents array if the category is found,
 * otherwise nil.
 */
static NSMutableArray *
GetCategoryContentsArray(NSArray * supercategoryContentsArray,
                         NSString * categoryName)
{
  NSEnumerator * e = [supercategoryContentsArray objectEnumerator];
  NSDictionary * entry;
  Class dictionaryClass = [NSDictionary class];

  while ((entry = [e nextObject]) != nil)
    {
      if ([entry isKindOfClass: dictionaryClass] &&
        [[entry objectForKey: @"Name"] isEqualToString: categoryName])
        {
          return [entry objectForKey: @"Contents"];
        }
    }

  return nil;
}

@interface ProjectDocument (Private)

- (BOOL) loadProjectModules: (NSArray *) moduleNames
       withInfoDictionaries: (NSDictionary *) dicts
             forProjectType: (NSString *) typeName;

- (NSDictionary *) getProjectModulesData;

+ (NSArray *) projectModulesForProjectType: (NSString *) projectTypeID;

@end

/**
 * @class ProjectDocument
 *
 * This class is the principal document class for project files.
 * It's responsibility is to manage it's project modules, the project type
 * object, and it's window controller.
 */
@implementation ProjectDocument

static NSString * const ProjectManagerVersion = @"0.2";

/**
 * Checks whether a given string is a valid project name.
 *
 * @param projectName The project name which to check.
 * @param error A pointer which if not set to NULL will be filled with
 *      an object describing the problem with the name.
 *
 * @return YES if the provided name is a valid project name, NO if it isn't.
 */
+ (BOOL) validateProjectName: (NSString *) aProjectName
                       error: (NSError **) error
{
  static NSCharacterSet * allowedProjectNameCharacters = nil;

  if (allowedProjectNameCharacters == nil)
    {
      allowedProjectNameCharacters = [[NSCharacterSet
        characterSetWithCharactersInString: @"abcdefghijklmnopqrstuvwxyz"
                                            @"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
                                            @"0123456789"
                                            @"-_"] retain];
    }

  if ([[aProjectName stringByTrimmingCharactersInSet:
    allowedProjectNameCharacters] length] != 0)
    {
      if (error != NULL)
        {
          NSDictionary * userInfo;

          userInfo = [NSDictionary
            dictionaryWithObject: _(@"A project name may only contain "
              @"non-accented letters a-z, the digits 0-9 and the "
              @"characters \"-\" and \"_\".")
                          forKey: NSLocalizedDescriptionKey];
            

          *error = [NSError errorWithDomain: ProjectDocumentErrorDomain
                                       code: ProjectNameInvalidError
                                   userInfo: userInfo];
        }

      return NO;
    }
  else
    {
      return YES;
    }
}

- (void) dealloc
{
  TEST_RELEASE (projectDirectory);
  TEST_RELEASE (projectName);
  TEST_RELEASE (projectTypeID);
  TEST_RELEASE (projectType);

  TEST_RELEASE (projectModules);
  TEST_RELEASE (moduleMenuEntries);

  TEST_RELEASE (wc);

  [super dealloc];
}

- (BOOL) readFromFile: (NSString *) fileName ofType: (NSString *) fileType
{
  NSDictionary * projectFile;
  NSDictionary * projectTypeData,
               * projectModulesData;
  NSString * versionString;

  ASSIGN (projectDirectory, [fileName stringByDeletingLastPathComponent]);

  projectFile = [NSDictionary dictionaryWithContentsOfFile: fileName];
  if (projectFile == nil)
    {
      return NO;
    }

  versionString = [projectFile objectForKey: @"Version"];

  // as a special precaution - the project file format changed in an
  // incompatible way in version 0.2, so warn about stuff older than that
  if (CompareVersions (@"0.2", versionString) == NSOrderedDescending)
    {
      NSString * message;

      if (versionString != nil)
        {
          message =
            _(@"The project has been created by Project Manager version %@,\n"
              @"but since the project format has been changed in an\n"
              @"incompatible way, so your project is likely not going to\n"
              @"work correctly. Do you want to try loading it anyway?");
        }
      else
        {
          message =
            _(@"The project has probably been created by a Project Manager\n"
              @"build older than version 0.2, but in 0.2 the project format\n"
              @"has changed in an incompatible way, so your project is\n"
              @"likely not going to work correctly. Do you want to try\n"
              @"loading it anyway?");
        }

      if (NSRunAlertPanel (_(@"Old version of project"),
        message, _(@"No"), _(@"Yes"), nil, versionString) ==
        NSAlertDefaultReturn)
        {
          return NO;
        }
    }

  if (versionString != nil)
    {
      if (CompareVersions (ProjectManagerVersion, versionString) ==
        NSOrderedAscending)
        {
          if (NSRunAlertPanel(_(@"Newer version of project"),
            _(@"This project was created with a newer version (%@)\n"
              @"of ProjectManager than is this one (%@).\n"
              @"Do you want me to try to open the project anyway?"),
            _(@"Yes"), _(@"Cancel"), nil,
            versionString, ProjectManagerVersion) == NSAlertAlternateReturn)
            {
              return NO;
            }
        }
    }

  ASSIGN (projectTypeID, [projectFile objectForKey: @"ProjectType"]);
  if (projectTypeID == nil)
    {
      return NO;
    }

  ASSIGN (projectName, [projectFile objectForKey: @"ProjectName"]);
  if (projectName == nil)
    {
      return NO;
    }

  projectTypeData = [projectFile objectForKey: @"ProjectTypeData"];

  projectModulesData = [projectFile objectForKey: @"ProjectModulesData"];

  if (![self loadProjectModules: [ProjectDocument
    projectModulesForProjectType: projectTypeID]
           withInfoDictionaries: projectModulesData
                 forProjectType: projectTypeID])
    {
      return NO;
    }

  // get the project type object
  ASSIGN (projectType, [[ProjectTypeLoader shared]
    projectTypeForTypeID: projectTypeID
                 project: self
          infoDictionary: projectTypeData
          projectModules: projectModules]);
  if (projectType == nil)
    {
      return NO;
    }

  [projectModules makeObjectsPerformSelector: @selector (finishInit)];

  return YES;
}

- (BOOL) writeToFile: (NSString *) fileName ofType: (NSString *) fileType
{
  BOOL result;
  NSMutableDictionary * dictionary;
  NSDictionary * projectData;

  dictionary = [NSMutableDictionary dictionary];
  [dictionary setObject: ProjectManagerVersion forKey: @"Version"];
  [dictionary setObject: projectTypeID forKey: @"ProjectType"];
  [dictionary setObject: projectName forKey: @"ProjectName"];

  if (![projectType regenerateDerivedFiles])
    {
      return NO;
    }

  projectData = [projectType infoDictionary];
  if (projectData != nil)
    {
      [dictionary setObject: projectData
                     forKey: @"ProjectTypeData"];
    }
  [dictionary setObject: [self getProjectModulesData]
                 forKey: @"ProjectModulesData"];

  result = [dictionary writeToFile: fileName atomically: YES];
  if (result == YES)
    {
      [[wc window] setMiniwindowImage: [NSImage
        imageNamed: @"File_project"]];
    }

  return result;
}

- (void) makeWindowControllers
{
  wc = [[ProjectWindowController alloc]
    initWithWindowNibName: @"Project" ownerDocument: self];

  [self addWindowController: wc];

  [[wc window] setMiniwindowImage: [NSImage
    imageNamed: @"File_project"]];
}

- (NSString *) displayName
{
  return projectName;
}

/**
 * This message is sent to the receiver by the app delegate when the
 * receiver becomes the currently active document in the app to determine
 * what menu entries of it to put into the main menu's 'Project' submenu.
 *
 * @return an array of NSMenuItem objects bound to submenus containing
 *      the menu items of the individual project modules.
 */
- (NSArray *) projectMenuEntries
{
  // recreate this list lazily
  if (moduleMenuEntries == nil)
    {
      NSMutableArray * menuEntries = [NSMutableArray arrayWithCapacity:
        [projectModules count]];
      NSEnumerator * e;
      id <ProjectModule> module;

      e = [projectModules objectEnumerator];
      while ((module = [e nextObject]) != nil)
        {
          NSArray * moduleMenuItems;

          // generate the module's menu items if necessary
          moduleMenuItems = [module moduleMenuItems];

          if ([moduleMenuItems count] > 0)
            {
              NSString * menuTitle = [[module class] humanReadableModuleName];
              NSMenuItem * rootItem;
              NSMenu * submenu;
              NSEnumerator * e;
              NSMenuItem * item;

              rootItem = [[[NSMenuItem alloc]
                initWithTitle: menuTitle action: NULL keyEquivalent: nil]
                autorelease];
              submenu = [[[NSMenu alloc]
                initWithTitle: menuTitle]
                autorelease];
              [rootItem setSubmenu: submenu];

              e = [moduleMenuItems objectEnumerator];
              while ((item = [e nextObject]) != nil)
                {
                  [submenu addItem: item];
                }
              [rootItem setSubmenu: submenu];

              [menuEntries addObject: rootItem];
            }
        }

      ASSIGNCOPY (moduleMenuEntries, menuEntries);
    }

  return moduleMenuEntries;
}

/**
 * Returns the abstract project name.
 *
 * @see -[ProjectDocument setProjectName:]
 */
- (NSString *) projectName
{
  return projectName;
}

/**
 * Sets a new project name. The project's name doesn't necessarily need
 * to be the same as the project file's or project directory's name, but
 * can instead by anything that the user finds descriptive.
 *
 * @see -[ProjectDocument projectName]
 */
- (void) setProjectName: (NSString *) aName
{
  NSError * error;

  if ([ProjectDocument validateProjectName: aName error: &error])
    {
      ASSIGN (projectName, aName);

      [[NSNotificationCenter defaultCenter]
        postNotificationName: ProjectNameDidChangeNotification
                      object: self
                    userInfo: nil];

      [self updateChangeCount: NSChangeDone];
    }
  else
    {
      NSRunAlertPanel(_(@"Invalid project name"),
        [[error userInfo] objectForKey: NSLocalizedDescriptionKey],
        nil, nil, nil);
    }
}

/**
 * Returns a path to where the project's directory is located. The
 * location of the project's project file can be determined by simply
 * saying -[ProjectDocument fileName].
 */
- (NSString *) projectDirectory
{
  return projectDirectory;
}

/**
 * Returns the type ID of the project's project type.
 */
- (NSString *) projectTypeID
{
  return projectTypeID;
}

/**
 * Returns the project modules of the receiver.
 */
- (NSArray *) projectModules
{
  return projectModules;
}

/**
 * Returns the project module of the specified name, or `nil' if
 * no such module is found.
 */
- (id <ProjectModule>) projectModuleWithName: (NSString *) moduleName
{
  NSEnumerator * e;
  id <ProjectModule> module;

  e = [projectModules objectEnumerator];
  while ((module = [e nextObject]) != nil)
    {
      if ([[[module class] moduleName] isEqualToString: moduleName])
        {
          return module;
        }
    }

  // not found
  return nil;
}

/**
 * Sets the currently displayed project module in the receiver's project
 * window.
 *
 * @param aModule The project module which to display. It must one
 *      of the project's modules.
 */
- (void) setCurrentProjectModule: (id <ProjectModule>) aModule
{
  [wc setCurrentModule: aModule];
}

/**
 * Returns the currently displayed project module.
 */
- (id <ProjectModule>) currentProjectModule
{
  return [wc currentModule];
}

/**
 * Returns the project type object associated currently with the project.
 */
- (id <ProjectType>) projectType
{
  return projectType;
}

/**
 * Opens a specified file in a code editor (either the internal code
 * editor, or an external one, if configured to do so).
 *
 * @param aPath The file which to open.
 * @param aLine The line number at which to open the file.
 *      If the file is already open, it is scrolled to that line.
 *      If you pass aLine < 0, no scrolling occurs and the file is
 *      only opened.
 *
 * @return YES if opening the file succeeded, NO if it didn't.
 */
- (BOOL) openFile: (NSString *) aPath inCodeEditorOnLine: (int) aLine
{
  NSUserDefaults * df = [NSUserDefaults standardUserDefaults];
  NSString * appName;

  appName = [df objectForKey: @"ExternalCodeEditorApp"];

  if (appName != nil)
    {
      return [[NSWorkspace sharedWorkspace] openFile: aPath];

      // TODO
/*      NSString * appName;
      id <CodeEditorInterface> app;

      app = [self contactApp: appName];
      if (app != nil && [app respondsToSelector: @selector(openFile:onLine:)])
        {
          return [app openFile: aPath onLine: aLine];
        }
      else
        {
          // as a last resort, try to let the workspace open it (though
          // line information will be lost)
          return [[NSWorkspace sharedWorkspace] openFile: aPath];
        }*/
    }
  else
    {
      NSDocumentController * dc = [NSDocumentController
        sharedDocumentController];
      SourceEditorDocument * doc;

      doc = [dc documentForFileName: aPath];
      if (doc == nil)
        {
          doc = [[[SourceEditorDocument alloc]
            initWithContentsOfFile: aPath ofType: [aPath pathExtension]]
            autorelease];

          if (doc == nil)
            {
              return NO;
            }

          [dc addDocument: doc];
          [doc makeWindowControllers];
        }

      // this must go first, to make sure the document has loaded it's windows
      // before trying to scroll it
      [doc showWindows];

      if (aLine >= 0)
        {
          [doc goToLineNumber: aLine];
        }

      return YES;
    }
}

/**
 * Appends a message to the project log. This method serves as
 * a frontend to the -[ProjectWindowController logMessage:] method,
 * so that project modules and project types can log messages (since
 * they don't have direct access to the window controller object).
 *
 * @param aMessage The message which to send to the log.
 */
- (void) logMessage: (NSString *) aMessage
{
  [wc logMessage: aMessage];
}

- (void) updateChangeCount: (NSDocumentChangeType) change
{
  if (change == NSChangeDone && [self isDocumentEdited] == NO)
    {
      [[wc window] setMiniwindowImage: [NSImage
        imageNamed: @"File_project_mod"]];
    }

  [super updateChangeCount: change];
}

@end

@implementation ProjectDocument (Private)

/**
 * Attempts to load project modules given by names in `moduleNames'.
 * Their info dictionaries are in `dicts', each bound to the name
 * of the respective project module.
 * The `typeName' argument is only used to identify the project type
 * in case loading a module fails.
 *
 * @return YES if loading all project modules succeeds, NO otherwise.
 */
- (BOOL) loadProjectModules: (NSArray *) moduleNames
       withInfoDictionaries: (NSDictionary *) dicts
             forProjectType: (NSString *) typeName
{
  NSMutableArray * modules = [NSMutableArray arrayWithCapacity:
    [moduleNames count]];
  NSEnumerator * e;
  NSString * moduleName;
  ProjectModuleLoader * loader = [ProjectModuleLoader shared];

  e = [moduleNames objectEnumerator];
  while ((moduleName = [e nextObject]) != nil)
    {
      id <ProjectModule> module;
      NSDictionary * infoDict = [dicts objectForKey: moduleName];

      module = [loader projectModuleForModuleName: moduleName
                                          project: self
                                   infoDictionary: infoDict];
      if (module != nil)
        {
          [modules addObject: module];
        }
      else
        {
          NSLog(_(@"Warning: project module %@ required by project type %@ "
                  @"not found."), moduleName, typeName);

          return NO;
        }
    }

  ASSIGNCOPY (projectModules, modules);

  return YES;
}

/**
 * Queries all project modules for their data dictionaries and
 * returns them all in one aggregate dictionary.
 *
 * @return The data of all project modules in a dictionary. Each
 *  key in the dictionary represents the data of one project module.
 *  Not all project modules necessarily have an entry in the
 *  returned dictionary - modules which don't have an module data
 *  are ommited.
 */
- (NSDictionary *) getProjectModulesData
{
  NSMutableDictionary * dict;
  NSEnumerator * e;
  id <ProjectModule> module;

  dict = [NSMutableDictionary dictionaryWithCapacity: [projectModules count]];
  e = [projectModules objectEnumerator];
  while ((module = [e nextObject]) != nil)
    {
      NSDictionary * moduleData = [module infoDictionary];

      if (moduleData != nil)
        {
          [dict setObject: moduleData forKey: [[module class] moduleName]];
        }
    }

  return [[dict copy] autorelease];
}

/**
 * Returns an array of project module names to be loaded for a project
 * type.
 *
 * @param type The project type for which to return the list.
 *
 * @return An array of project module names to load for the project type.
 * This array includes the standard required modules, plus the list of
 * user-defined modules to be loaded.
 */
+ (NSArray *) projectModulesForProjectType: (NSString *) type
{
  // we use an array here so that the list is sorted according to how the
  // project module wants - extra user-defined modules thus appear at
  // the list's end.
  NSMutableArray * list;
  NSUserDefaults * df = [NSUserDefaults standardUserDefaults];
  NSArray * extraModules;
  NSEnumerator * e;
  NSString * module;

  list = [[[(Class) [[(ProjectTypeLoader *)
    [ProjectTypeLoader shared] projectTypes] objectForKey: type]
    projectModules] mutableCopy] autorelease];

  extraModules = [[df objectForKey: @"ExtraProjectModules"]
    objectForKey: type];
  e = [extraModules objectEnumerator];
  while ((module = [e nextObject]) != nil)
    {
      if (![list containsObject: module])
        {
          [list addObject: module];
        }
    }

  return [[list copy] autorelease];
}

@end
