// ICeCoffEE - Internet Config Cocoa Editor Extension
// Nicholas Riley <mailto:icecoffee@sabi.net>

/* To do/think about:

- Carbon contextual menu plugin which presents Services (yah!)
  for both files and text
- Carbon version
- app exclusion list - make a pref pane (see AquaShade config)
- if it's not a URL, try using TextExtras' open list
- John Hayes' suggestions
- ICeCoffEE 2 functionality (bookmark helper app for cmd-option-click)
- adjust URL blinking

Carbon support ideas:
    TEClick - TextEdit
    TXNClick - MLTE
    ? ATSUI
    WASTE has its own support

Done:

- flash on success (like BBEdit)
- display dialog on failure (decode OSStatus)

*/

#import "ICeCoffEE.h"
#import <Carbon/Carbon.h>
#include <unistd.h>
#import "ICeCoffEESuper.h"

@implementation ICeCoffEE

typedef struct {
    OSStatus status;
    NSString * const desc;
} errRec, errList[];

static errList ERRS = {
    // Internet Config errors
    { icPrefNotFoundErr, @"No helper application is defined for the selected URLÕs scheme (e.g. http:)" },
    { icNoURLErr, @"The selection is not a URL" },
    { icInternalErr, @"Internet Config experienced an internal error" },
    // Misc. errors
    { paramErr, @"The selection is not a complete URL" },
    { 0, NULL }
};

NSString *ICCF_ErrString(OSStatus err, NSString *context) {
    errRec *rec;
    NSString *errDesc = [NSString stringWithFormat: @"An unknown error occurred in %@", context];
    if (err == noErr) return nil;
    for (rec = &(ERRS[0]) ; rec->status != 0 ; rec++)
        if (rec->status == err) {
            errDesc = rec->desc;
            break;
        }
    return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err];
}

BOOL ICCF_EventIsCommandMouseDown(NSEvent *e) {
    return ([e type] == NSLeftMouseDown && [e modifierFlags] == NSCommandKeyMask && [e clickCount] == 1);
}

void ICCF_CheckRange(NSRange range) {
    NSCAssert(range.length > 0, @"No URL is selected");
    NSCAssert1(range.length <= ICCF_MAX_URL_LEN, @"The potential URL is longer than %ld characters", ICCF_MAX_URL_LEN);
}

void ICCF_Delimiters(NSCharacterSet **leftPtr, NSCharacterSet **rightPtr) {
    static NSCharacterSet *urlLeftDelimiters = nil, *urlRightDelimiters = nil;

    if (urlLeftDelimiters == nil || urlRightDelimiters == nil) {
        NSMutableCharacterSet *set = [[NSCharacterSet whitespaceAndNewlineCharacterSet] mutableCopy];
        NSMutableCharacterSet *tmpSet;
        [urlLeftDelimiters release];
        [urlRightDelimiters release];

        [set autorelease];
        [set formUnionWithCharacterSet: [NSCharacterSet punctuationCharacterSet]];
        [set removeCharactersInString: @";/?:@&=+$,-_.!~*'()%#"]; // RFC 2396 ¤2.2, 2.3, 2.4, plus #

        tmpSet = [[set mutableCopy] autorelease];
        [tmpSet formUnionWithCharacterSet: [NSCharacterSet characterSetWithCharactersInString: @"><("]];
        urlLeftDelimiters = [tmpSet copy]; // make immutable again - for efficiency

        tmpSet = [[set mutableCopy] autorelease];
        [tmpSet formUnionWithCharacterSet: [NSCharacterSet characterSetWithCharactersInString: @"><)"]];
        urlRightDelimiters = [tmpSet copy]; // make immutable again - for efficiency
    }

    *leftPtr = urlLeftDelimiters; *rightPtr = urlRightDelimiters;
}

static ICInstance ICCF_icInst = NULL;

void ICCF_StartIC() {
    OSStatus err;
    
    if (ICCF_icInst != NULL) {
        ICLog(@"ICCF_StartIC: Internet Config is already running!");
        ICCF_StopIC();
    }
    err = ICStart(&ICCF_icInst, 'ICCF');
    NSCAssert1(err == noErr, @"Unable to start Internet Config (error %d)", err);
}

void ICCF_StopIC() {
    if (ICCF_icInst == NULL) {
        ICLog(@"ICCF_StopIC: Internet Config is not running!");
    } else {
        ICStop(ICCF_icInst);
        ICCF_icInst = NULL;
    }
}

ICInstance ICCF_GetInst() {
    NSCAssert(ICCF_icInst != NULL, @"Internal error: Called ICCF_GetInst without ICCF_StartIC");
    return ICCF_icInst;
}

void ICCF_ParseURL(NSString *string, NSRange *range) {
    OSStatus err;
    Handle h;
    long selStart, selEnd;
    char *urlData = NULL;

    NSCAssert(range->length == [string length], @"Internal error: URL string is wrong length");
    
    NS_DURING
        if ([[NSCharacterSet characterSetWithCharactersInString: @";,."] characterIsMember:
            [string characterAtIndex: range->length - 1]]) {
            range->length--;
        }

        string = [string substringToIndex: range->length];

        ICLog(@"Parsing URL |%@|", string);

        urlData = (char *)malloc( (range->length + 1) * sizeof(char));
        NSCAssert(urlData != NULL, @"Internal error: can't allocate memory for URL string");

        selStart = 0; selEnd = range->length;

        [string getCString: urlData];

        h = NewHandle(0);
        NSCAssert(h != NULL, @"Internal error: can't allocate URL handle");
    
        err = ICParseURL(ICCF_GetInst(), "\pmailto", urlData, range->length, &selStart, &selEnd, h);
        ICCF_OSErrCAssert(err, @"ICParseURL");
    
        DisposeHandle(h);
        range->length = range->length - (range->length - selEnd) + selStart;
        range->location += selStart;
    NS_HANDLER
        free(urlData);
        [localException raise];
    NS_ENDHANDLER
    
    free(urlData);
}

void ICCF_LaunchURL(NSString *string) {
    OSStatus err;
    long selStart, selEnd;
    unsigned len = [string length];

    char *urlData = NULL;
    
    NS_DURING
        urlData = (char *)malloc( (len + 1) * sizeof(char));
        NSCAssert(urlData != NULL, @"Internal error: can't allocate memory for URL string");

        [string getCString: urlData];

        selStart = 0; selEnd = len;

        err = ICLaunchURL(ICCF_GetInst(), "\pmailto", urlData, len, &selStart, &selEnd);
        ICCF_OSErrCAssert(err, @"ICLaunchURL");
        
    NS_HANDLER
        free(urlData);
        [localException raise];
    NS_ENDHANDLER

    free(urlData);    
}

// XXX not sure what to do if there's already a selection; BBEdit extends it, Tex-Edit Plus doesn't.
// RFC-ordained max URL length, just to avoid passing IC multi-megabyte documents
#if ICCF_DEBUG
const long ICCF_MAX_URL_LEN = 1024; // XXX change later
#else
const long ICCF_MAX_URL_LEN = 1024;
#endif

BOOL ICCF_enabled = YES;

void ICCF_HandleException(NSException *e) {
    int result = NSRunAlertPanel(@"Open Internet Location", @"The selected Internet location could not be opened.\n\n%@.", @"OK", nil, @"Disable ICeCoffEEÉ", e);
    if (result != NSAlertDefaultReturn) {
        result = NSRunAlertPanel(@"Disable ICeCoffEE", @"If you believe ICeCoffEE is interfering with the normal functioning of this application, you can turn it off in this application until the application has quit.\n\nIf this is the first time you are experiencing this problem, please email icecoffee@sabi.net with the details of the conflict.\n\nTo remove ICeCoffEE permanently, drag its icon to the Trash or use the ICeCoffEE Installer.", @"Disable", @"DonÕt Disable", nil);
        if (result == NSAlertDefaultReturn)
            ICCF_enabled = NO;
    }
}

void ICCF_LaunchURLFromTextView(NSTextView *self) {
    NSCharacterSet *urlLeftDelimiters = nil, *urlRightDelimiters = nil;
    NSRange range = [self selectedRange], delimiterRange;
    NSColor *insertionPointColor = [self insertionPointColor];
    NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
    unsigned extraLen;
    int i;

    NS_DURING

        NSCAssert(range.location != NSNotFound, @"There is no insertion point or selection in the text field you clicked");
        NSCAssert(s != nil, @"Sorry, ICeCoffEE is unable to locate the insertion point or selection");

        ICCF_StartIC();

        NSCAssert([s length] != 0, @"No text was found");

        if (range.location == [s length]) range.location--; // work around bug in selectionRangeForProposedRange (r. 2845418)

        range = [self selectionRangeForProposedRange: range granularity: NSSelectByWord];

        // However, NSSelectByWord does not capture even the approximate boundaries of a URL
        // (text to a space/line ending character); it'll stop at a period in the middle of a hostname.
        // So, we expand it as follows:

        ICCF_CheckRange(range);

        ICCF_Delimiters(&urlLeftDelimiters, &urlRightDelimiters);

        // XXX instead of 0, make this stop at the max URL length to prevent protracted searches
        // add 1 to range to trap delimiters that are on the edge of the selection (i.e., <...)
        delimiterRange = [s rangeOfCharacterFromSet: urlLeftDelimiters
                                            options: NSLiteralSearch | NSBackwardsSearch
                                              range: NSMakeRange(0, range.location + (range.location != [s length]))];
        if (delimiterRange.location == NSNotFound) {
            // extend to beginning of string
            range.length += range.location;
            range.location = 0;
        } else {
            NSCAssert(delimiterRange.length == 1, @"Internal error: delimiter matched range is not of length 1");
            range.length += range.location - delimiterRange.location - 1;
            range.location = delimiterRange.location + 1;
        }

        ICCF_CheckRange(range);

        // XXX instead of length of string, make this stop at the max URL length to prevent protracted searches
        // add 1 to range to trap delimiters that are on the edge of the selection (i.e., ...>)
        extraLen = [s length] - range.location - range.length;
        delimiterRange = [s rangeOfCharacterFromSet: urlRightDelimiters
                                            options: NSLiteralSearch
                                              range: NSMakeRange(range.location + range.length - (range.length != 0),
                                                                 extraLen + (range.length != 0))];
        if (delimiterRange.location == NSNotFound) {
            // extend to end of string
            range.length += extraLen;
        } else {
            NSCAssert(delimiterRange.length == 1, @"Internal error: delimiter matched range is not of length 1");
            range.length += delimiterRange.location - range.location - range.length;
        }

        ICCF_CheckRange(range);

        ICCF_ParseURL([s substringWithRange: range], &range);

        for (i = 0 ; i < 3 ; i++) {
            NSRange emptyRange = {range.location, 0};
            [self setInsertionPointColor: [self backgroundColor]];
            [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
            [self display];
            usleep(60000);
            [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
            [self display];
            usleep(60000);
        }
        [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
        [self display];

        ICCF_LaunchURL([s substringWithRange: range]);

    NS_HANDLER
        ICCF_HandleException(localException);
    NS_ENDHANDLER

    ICCF_StopIC();
    [self setInsertionPointColor: insertionPointColor];
}

NSString * const ICCF_SERVICES_ITEM = @"ICeCoffEE Services Item";

NSMenuItem *ICCF_ServicesMenuItem() {
    NSMenuItem *servicesItem;
    NSMenu *servicesMenu;
    // XXX better to just use [[NSApp servicesMenu] title]?  That grabs the title from the existing Services submenu.
    NSString *servicesTitle = [[NSBundle bundleWithIdentifier: @"com.apple.AppKit"] localizedStringForKey: @"Services" value: nil table: @"ServicesMenu"];
    if (servicesTitle == nil) {
        ICLog(@"CanÕt get localized text for ÒServicesÓ in AppKit.framework");
        servicesTitle = @"Services";
    }
    servicesMenu = [[NSMenu alloc] initWithTitle: servicesTitle];
    servicesItem = [[NSMenuItem alloc] initWithTitle: servicesTitle action:nil keyEquivalent:@""];
    [[NSApplication sharedApplication] setServicesMenu: servicesMenu];
    [servicesItem setSubmenu: servicesMenu];
    [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
    [servicesMenu release];
    return servicesItem;
}

void ICCF_AddServicesMenu() {
    [ICeCoffEE performSelector: @selector(IC_addServicesMenu) withObject: nil afterDelay: 0.0];
}

NSMenu *ICCF_MenuForEvent(NSTextView *self, NSMenu *contextMenu, NSEvent *e) {    
    if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
        int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
        if (servicesItemIndex == -1) {
            [contextMenu addItem: [NSMenuItem separatorItem]];
            [contextMenu addItem: ICCF_ServicesMenuItem()];
        }
    }
    return contextMenu;
}

+ (NSString *)IC_version;
{
    // XXX get from bundle if possible: centralize
    return [NSString stringWithCString: ICCF_VERSION];
}

+ (void)IC_addServicesMenu;
{
    NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
    int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];

    if (insertLoc == -1)
        insertLoc = [mainMenu numberOfItems];

    [mainMenu insertItem: ICCF_ServicesMenuItem() atIndex: insertLoc];
}

// XXX localization?
- (NSMenu *)menuForEvent:(NSEvent *)e;
{
    NSMenu *myMenu = [super menuForEvent: e];
    return ICCF_MenuForEvent(self, myMenu, e);
}

- (void)mouseDown:(NSEvent *)e;
{
#if ICCF_DEBUG
    static BOOL down = NO;
    if (down) {
        ICLog(@"recursive invocation!");
        return;
    }
    down = YES;
    ICLog(@"ICeCoffEE down: %@", e);
    NSLog(@"super is %@", self);
#endif
    // we don't actually get a mouseUp event, just wait for mouseDown to return
    [super mouseDown: e];
    if (!ICCF_enabled) return;
    // don't want command-option-click, command-shift-click, etc. to trigger
    if (ICCF_EventIsCommandMouseDown(e)) {
        NSEvent *upEvent = [[self window] currentEvent];
        NSPoint downPt = [e locationInWindow];
        NSPoint upPt = [upEvent locationInWindow];
        ICLog(@"next: %@", upEvent);
        NSAssert([upEvent type] == NSLeftMouseUp, @"NSTextView mouseDown: did not return with current event as mouse up!");
        if (abs(downPt.x - upPt.x) <= 4 && abs(downPt.y - upPt.y) <= 4) {
            ICCF_LaunchURLFromTextView(self);
        }
    }
#if ICCF_DEBUG
    down = NO;
#endif
}

@end
