/*
    SourceEditorDocument.m

    Implementation of the SourceEditorDocument 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 "SourceEditorDocument.h"

#import <Foundation/NSKeyedArchiver.h>
#import <Foundation/NSBundle.h>
#import <Foundation/NSFileHandle.h>
#import <Foundation/NSIndexSet.h>
#import <Foundation/NSKeyValueCoding.h>
#import <Foundation/NSString.h>
#import <Foundation/NSTask.h>
#import <Foundation/NSUserDefaults.h>
#import <Foundation/NSValue.h>

#import <AppKit/NSFont.h>
#import <AppKit/NSImage.h>
#import <AppKit/NSLayoutManager.h>
#import <AppKit/NSPanel.h>
#import <AppKit/NSParagraphStyle.h>
#import <AppKit/NSRulerView.h>
#import <AppKit/NSScrollView.h>
#import <AppKit/NSTextStorage.h>
#import <AppKit/NSTextView.h>

#import <HighlighterKit/HighlighterKit.h>

#import "CommandQueryPanel.h"
#import "LineQueryPanel.h"
#import "TextFinder.h"
#import "EditorRulerView.h"
#import "EditorTextView.h"

/**
 * Checks whether a character is a delimiter.
 *
 * This function checks whether `character' is a delimiter character,
 * (i.e. one of "(", ")", "[", "]", "{", "}") and returns YES if it
 * is and NO if it isn't. Additionaly, if `character' is a delimiter,
 * `oppositeDelimiter' is set to a string denoting it's opposite
 * delimiter and `searchBackwards' is set to YES if the opposite
 * delimiter is located before the checked delimiter character, or
 * to NO if it is located after the delimiter character.
 */
static inline BOOL CheckDelimiter(unichar character,
                                  unichar * oppositeDelimiter,
                                  BOOL * searchBackwards)
{
  if (character == '(')
    {
      *oppositeDelimiter = ')';
      *searchBackwards = NO;

      return YES;
    }
  else if (character == ')')
    {
      *oppositeDelimiter = '(';
      *searchBackwards = YES;

      return YES;
    }
  else if (character == '[')
    {
      *oppositeDelimiter = ']';
      *searchBackwards = NO;

      return YES;
    }
  else if (character == ']')
    {
      *oppositeDelimiter = '[';
      *searchBackwards = YES;

      return YES;
    }
  else if (character == '{')
    {
      *oppositeDelimiter = '}';
      *searchBackwards = NO;

      return YES;
    }
  else if (character == '}')
    {
      *oppositeDelimiter = '{';
      *searchBackwards = YES;

      return YES;
    }
  else
    {
      return NO;
    }
}

/**
 * Attempts to find a delimiter in a certain string around a certain location.
 *
 * Attempts to locate `delimiter' in `string', starting at
 * location `startLocation' a searching forwards (backwards if
 * searchBackwards = YES) at most 1000 characters. The argument
 * `oppositeDelimiter' denotes what is considered to be the opposite
 * delimiter of the one being search for, so that nested delimiters
 * are ignored correctly.
 *
 * @return The location of the delimiter if it is found, or NSNotFound
 *      if it isn't.
 */
unsigned int FindDelimiterInString(NSString * string,
                                   unichar delimiter,
                                   unichar oppositeDelimiter,
                                   unsigned int startLocation,
                                   BOOL searchBackwards)
{
  unsigned int i;
  unsigned int length;
  unichar (*charAtIndex)(id, SEL, unsigned int);
  SEL sel = @selector(characterAtIndex:);
  int nesting = 1;

  charAtIndex = (unichar (*)(id, SEL, unsigned int)) [string
    methodForSelector: sel];

  if (searchBackwards)
    {
      if (startLocation < 1000)
        length = startLocation;
      else
        length = 1000;

      for (i=1; i <= length; i++)
        {
          unichar c;

          c = charAtIndex(string, sel, startLocation - i);
          if (c == delimiter)
            nesting--;
          else if (c == oppositeDelimiter)
            nesting++;

          if (nesting == 0)
            break;
        }

      if (i > length)
        return NSNotFound;
      else
        return startLocation - i;
    }
  else
    {
      if ([string length] < startLocation + 1000)
        length = [string length] - startLocation;
      else
        length = 1000;

      for (i=1; i < length; i++)
        {
          unichar c;

          c = charAtIndex(string, sel, startLocation + i);
          if (c == delimiter)
            nesting--;
          else if (c == oppositeDelimiter)
            nesting++;

          if (nesting == 0)
            break;
        }

      if (i == length)
        return NSNotFound;
      else
        return startLocation + i;
    }
}

@interface SourceEditorDocument (Private)

- (void) pipeOutputOfCommand: (NSString *) command;

- (void) updateMiniwindowIconToEdited: (BOOL) flag;

- (void) computeNewParenthesisNesting;

@end

@implementation SourceEditorDocument (Private)

- (void) pipeOutputOfCommand: (NSString *) command
{
  NSTask * task;
  NSPipe * inPipe, * outPipe;
  NSString * inString, * outString;
  NSFileHandle * inputHandle;
  BOOL hadTrailingNewlineInInput;

  inString = [[textView string] substringWithRange:
    [textView selectedRange]];
  hadTrailingNewlineInInput = ([inString characterAtIndex: [inString
    length] - 1] == '\n');

  inPipe = [NSPipe pipe];
  outPipe = [NSPipe pipe];

  task = [[NSTask new] autorelease];

  [task setLaunchPath: @"/bin/sh"];
  [task setArguments: [NSArray arrayWithObjects: @"-c", command, nil]];
  [task setStandardInput: inPipe];
  [task setStandardOutput: outPipe];
  [task setStandardError: outPipe];

  inputHandle = [inPipe fileHandleForWriting];

  [task launch];
  [inputHandle writeData: [inString
    dataUsingEncoding: NSUTF8StringEncoding]];
  [inputHandle closeFile];
  [task waitUntilExit];
  outString = [[[NSString alloc]
    initWithData: [[outPipe fileHandleForReading] availableData]
        encoding: NSUTF8StringEncoding]
    autorelease];

  // strip a trailing newline character if it was not present in the input
  if ([outString characterAtIndex: [outString length] - 1] == '\n' &&
    hadTrailingNewlineInInput == NO)
    {
      outString = [outString substringToIndex: [outString length] - 1];
    }

  if ([task terminationStatus] != 0)
    {
      if (NSRunAlertPanel(_(@"Error running command"),
        _(@"The command returned with a non-zero exit status"
          @" -- aborting pipe.\n"
          @"Do you want to see the command's output?\n"),
        _(@"No"), _(@"Yes"), nil) == NSAlertAlternateReturn)
        {
          NSRunAlertPanel(_(@"The command's output"),
            outString, nil, nil, nil);
        }
    }
  else
    {
      [textView replaceCharactersInRange: [textView selectedRange]
                              withString: outString];
      [self textDidChange: nil];
    }
}

/**
 * This method updates the document's miniwindow icon to either an edited
 * or unedited version. The names of the icons which to use are searched
 * for using -[NSImage imageNamed:]. The icon names are:
 *
 * - "File_<FileType>" for the unedited version
 * - "File_<FileType>_mod" for the edited (dirty) version.
 *
 * In case there is no dirty version of the icon, the normal icon is left
 * in place.
 *
 * @param flag YES if the window's miniwindow icon should be set to the
 *      edited version, NO if it should be reset to the un-edited version.
 */
- (void) updateMiniwindowIconToEdited: (BOOL) flag
{
  NSImage * icon;

  if (flag)
    {
      icon = [NSImage imageNamed:
        [NSString stringWithFormat: @"File_%@_mod", [self fileType]]];
    }
  else
    {
      icon = [NSImage imageNamed:
        [NSString stringWithFormat: @"File_%@", [self fileType]]];
    }

  if (icon != nil)
    {
      [myWindow setMiniwindowImage: icon];
    }
}

- (void) computeNewParenthesisNesting
{
  NSRange selectedRange;
  NSString * myString;

  if ([[NSUserDefaults standardUserDefaults] boolForKey: @"DontTrackNesting"])
    {
      return;
    }

  selectedRange = [textView selectedRange];

  if (parenthesesHighlighted)
    {
      [textView setNeedsDisplayInHighlightedCharacters: YES];

      parenthesesHighlighted = NO;
    }

  // if we have a character at the selected location, check
  // to see if it is a delimiter character
  myString = [textView string];
  if (selectedRange.length <= 1 && [myString length] > selectedRange.location)
    {
      unichar c;
      // we must initialize these explicitly in order to make
      // gcc shut up about flow control
      unichar oppositeDelimiter = 0;
      BOOL searchBackwards = NO;

      c = [myString characterAtIndex: selectedRange.location];

      // if it is, search for the opposite delimiter in a range
      // of at most 1000 characters around it in either forward
      // or backward direction (depends on the kind of delimiter
      // we're searching for).
      if (CheckDelimiter(c, &oppositeDelimiter, &searchBackwards))
        {
          unsigned int result;

          result = FindDelimiterInString(myString,
                                         oppositeDelimiter,
                                         c,
                                         selectedRange.location,
                                         searchBackwards);

          // and in case a delimiter is found, highlight it
          if (result != NSNotFound)
            {
              parenthesisA = selectedRange.location;
              parenthesisB = result;
              parenthesesHighlighted = YES;

              [textView setNeedsDisplayInHighlightedCharacters: YES];
            }
        }
    }
}

@end

@implementation SourceEditorDocument

- (void) dealloc
{
  TEST_RELEASE(string);

  TEST_RELEASE(defaultFont);

  TEST_RELEASE(textColor);
  TEST_RELEASE(highlightColor);
  TEST_RELEASE(backgroundColor);
  TEST_RELEASE(insertionPointColor);

  [super dealloc];
}

- init
{
  if ((self = [super init]) != nil)
    {
      NSUserDefaults * df = [NSUserDefaults standardUserDefaults];
      NSData * data;

      ASSIGN(defaultFont, [HKSyntaxHighlighter defaultFont]);

      data = [df dataForKey: @"EditorHighlightColor"];
      if (data != nil)
        {
          ASSIGN (highlightColor, [NSKeyedUnarchiver unarchiveObjectWithData:
            data]);
        }
      else
        {
          ASSIGN (highlightColor, [NSColor redColor]);
        }

      data = [df dataForKey: @"EditorTextColor"];
      if (data != nil)
        {
          ASSIGN (textColor, [NSKeyedUnarchiver unarchiveObjectWithData: data]);
        }

      data = [df dataForKey: @"EditorBackgroundColor"];
      if (data != nil)
        {
          ASSIGN (backgroundColor, [NSKeyedUnarchiver unarchiveObjectWithData:
            data]);
        }

      data = [df dataForKey: @"EditorInsertionPointColor"];
      if (data != nil)
        {
          ASSIGN (insertionPointColor, [NSKeyedUnarchiver
            unarchiveObjectWithData: data]);
        }

      return self;
    }
  else
    {
      return nil;
    }
}

- (BOOL) readFromFile: (NSString *) fileName ofType: (NSString *) fileType
{
  NSString * aString = [NSString stringWithContentsOfFile: fileName];

  if (aString != nil)
    {
      ASSIGN (string, aString);

      return YES;
    }
  else
    {
      return NO;
    }
}

- (BOOL) writeToFile: (NSString *) fileName ofType: (NSString *) fileType
{
  BOOL result;
  result = [[textView string] writeToFile: fileName atomically: NO];

  if (result == YES)
    [self updateMiniwindowIconToEdited: NO];

  return result;
}


// TODO - clean this method up
- (void) awakeFromNib
{
  NSScrollView * enclosingScrollView;
  NSRulerView * ruler;
  NSRect frame;
  NSMutableDictionary * typingAttrs;
  NSUserDefaults * df;

  if (textColor != nil)
    {
      [textView setTextColor: textColor];
    }
  if (backgroundColor != nil)
    {
      [textView setBackgroundColor: backgroundColor];
      [textView setDrawsBackground: YES];
    }
  if (insertionPointColor != nil)
    {
      [textView setInsertionPointColor: insertionPointColor];
    }

  // set a wide width so that lines don't wrap at the view boundary
  frame = [textView frame];
  frame.size.width = 4096;
  [textView setFrame: frame];

  // don't allow users to change the font with the font panel
  [textView setFont: defaultFont];

  /* turn off ligatures - they're useless for a code editor */
  typingAttrs = [[[textView typingAttributes] mutableCopy] autorelease];
  [typingAttrs setObject: [NSNumber numberWithInt: 0]
                  forKey: NSLigatureAttributeName];
  [textView setTypingAttributes: typingAttrs];

  enclosingScrollView = [textView enclosingScrollView];

  // add a horizontal scroller
  [enclosingScrollView setHasHorizontalScroller: YES];

  // configure the vertical ruler
  [enclosingScrollView setHasVerticalRuler: YES];
  {
    NSMutableParagraphStyle * paraStyle;
    NSMutableDictionary * typingAttributes;
    EditorRulerView * erv = [[[EditorRulerView alloc]
      initWithScrollView: enclosingScrollView orientation: NSVerticalRuler]
      autorelease];
    float value = [defaultFont defaultLineHeightForFont];

    [enclosingScrollView setHasVerticalRuler: YES];
    [enclosingScrollView setVerticalRulerView: erv];
    [erv setRuleThickness: [[NSFont systemFontOfSize: [NSFont
    smallSystemFontSize]] widthOfString: @"99999"]];
    // set up a right margin so that the text view's contents are a
    // bit offset from the rule
    [erv setMargin: 2];

    [erv setUnitSize: value];

    // fixate the line height
    typingAttributes = [[[textView typingAttributes] mutableCopy]
      autorelease];
    paraStyle = [[[typingAttributes objectForKey: NSParagraphStyleAttributeName]
      mutableCopy] autorelease];
    [paraStyle setMinimumLineHeight: value];
    [paraStyle setMaximumLineHeight: value];
    [typingAttributes setObject: paraStyle
                         forKey: NSParagraphStyleAttributeName];
    [textView setTypingAttributes: typingAttributes];
  }

  // required due to a GNUstep bug - we have to make the scroll view
  // forget about it's present horizontal ruler (which get's archived 
  // incorrectly - why??) and then recreate it later on correctly.
  [enclosingScrollView setHasHorizontalRuler: NO];

   // check to see if the font is fixed pitch. If yes, set up a
   // horizontal ruler as well
   // FIXME: this should use -isFixedPitch, but this method is broken
   // on GNUstep - fix it!
  if ([defaultFont widthOfString: @"a"] == [defaultFont widthOfString: @"i"])
    {
      EditorRulerView * erv = [[[EditorRulerView alloc]
        initWithScrollView: enclosingScrollView orientation: NSHorizontalRuler]
        autorelease];

      [enclosingScrollView setHasHorizontalRuler: YES];
      [erv setOriginOffset: [[enclosingScrollView verticalRulerView]
        ruleThickness]];
      [enclosingScrollView setHorizontalRulerView: erv];
      [erv setReservedThicknessForMarkers: 0];
      [erv setUnitSize: [defaultFont widthOfString: @"a"]];
      [textView setDrawsColumnIndicationGuideline: YES];
    }
  else
    {
      [enclosingScrollView setHasHorizontalRuler: NO];
      [textView setDrawsColumnIndicationGuideline: NO];
    }

  [enclosingScrollView setRulersVisible: YES];

  [textView replaceCharactersInRange: NSMakeRange(0, 0) withString: string];
  [textView setSelectedRange: NSMakeRange(0, 0)];
  DESTROY(string);

  [self updateMiniwindowIconToEdited: NO];

  df = [NSUserDefaults standardUserDefaults];
  if (![df boolForKey: @"DisableSyntaxHighlighting"])
    {
      [textView createSyntaxHighlighterForFileType: [self fileType]];
    }
}

- (NSString *) windowNibName
{
  return @"Editor";
}

- (NSString *) displayName
{
  if ([self fileName] != nil)
    {
      return [NSString stringWithFormat: @"%@ -- %@", [[self fileName]
        lastPathComponent], [[self fileName]
        stringByDeletingLastPathComponent]];
    }
  else
    {
      return [super displayName];
    }
}

- (void) customPipeOutput: sender
{
  NSString * command;

  command = [(CommandQueryPanel *) [CommandQueryPanel shared] runModal];
  if (command != nil)
    {
      [self pipeOutputOfCommand: command];
    }
}

- (void) pipeOutput: sender
{
  NSString * command;

  command = [[[[NSUserDefaults standardUserDefaults]
    objectForKey: @"UserPipes"]
    objectForKey: [sender title]]
    objectForKey: @"Command"];

  if (command != nil)
    {
      [self pipeOutputOfCommand: command];
    }
  else
    {
      NSRunAlertPanel(_(@"Associated command not found"),
        _(@"I couldn't find the command associated with this user-\n"
          @"defined pipe. Your user-defaults could be corrupt..."),
        nil, nil, nil);
    }
}

- (void) goToLine: sender
{
  LineQueryPanel * lqp = [LineQueryPanel shared];

  if ([lqp runModal] == NSOKButton)
    {
      [self goToLineNumber: (unsigned int) [lqp unsignedIntValue]];
    }
}

- (void) goToLineNumber: (unsigned int) lineNumber
{
  unsigned int offset;
  unsigned int i;
  NSString * line;
  NSEnumerator * e;
  NSArray * lines = [[textView string] componentsSeparatedByString: @"\n"];
  e = [lines objectEnumerator];
  NSRange r;

  for (offset = 0, i=1;
       (line = [e nextObject]) != nil && i < lineNumber;
       i++, offset += [line length] + 1);

  if (line != nil)
    {
      r = NSMakeRange(offset, [line length]);
    }
  else
    {
      r = NSMakeRange([[textView string] length], 0);
    }
  [textView setSelectedRange: r];
  [textView scrollRangeToVisible: r];
}

- (void) textViewDidChangeSelection: (NSNotification *) notification
{
  if (editorTextViewIsPressingKey == NO)
    {
      [self computeNewParenthesisNesting];
    }
  [(EditorRulerView *) [[textView enclosingScrollView] horizontalRulerView]
    refreshHighlightedArea];
  [(EditorRulerView *) [[textView enclosingScrollView] verticalRulerView]
    refreshHighlightedArea];
}

/**
 * Notification method invoked when the text view changes. This method
 * marks the window as edited and sets the window's miniwindow icon to
 * an edited (normally dimmed) version.
 */
- (void) textDidChange: (NSNotification *) notif
{

  if (![self isDocumentEdited])
    {
      [self updateMiniwindowIconToEdited: YES];
    }

  [self updateChangeCount: NSChangeDone];
}

- (void) editorTextViewWillPressKey: sender
{
  editorTextViewIsPressingKey = YES;
}

- (void) editorTextViewDidPressKey: sender
{
  [self computeNewParenthesisNesting];

  editorTextViewIsPressingKey = NO;
}

- (void) findNext: sender
{
  [[TextFinder sharedInstance] findNext: self];
}

- (void) findPrevious: sender
{
  [[TextFinder sharedInstance] findPrevious: self];
}

- (void) jumpToSelection: sender
{
  [textView scrollRangeToVisible: [textView selectedRange]];
}

- (NSIndexSet *) highlightedCharacterIndexes
{
  if (parenthesesHighlighted)
    {
      NSMutableIndexSet * indexes = [NSMutableIndexSet indexSet];

      [indexes addIndex: parenthesisA];
      [indexes addIndex: parenthesisB];

      return indexes;
    }
  else
    {
      return nil;
    }
}

- (NSColor *) highlightColor
{
  return highlightColor;
}

- (NSColor *) textColor
{
  return textColor;
}

@end
