// 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
- if it's not a URL, try using TextExtras' open list
- TXNClick - MLTE has its own support in Jaguar and later, but it's lousy

Done:

- TEClick - TextEdit
- flash on success (like BBEdit)
- display dialog on failure (decode OSStatus)
- adjust URL blinking
- app exclusion list - make a pref pane (see AquaShade config)
- _LSCopyApplicationURLsForItemURL - list apps
- Menu on command-option-click: add bookmark, open with other helper, pass to configurable service, ...?

*/

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

iccfPrefRec ICCF_prefs;

NSString *ICCF_ErrString(OSStatus err, NSString *context) {    
    if (err == noErr || err == userCanceledErr) return nil;

    NSString *errNum = [NSString stringWithFormat: @"%ld", err];
    NSString *errDesc = ICCF_LocalizedString(errNum);

    if (errDesc == NULL || errDesc == errNum)
        errDesc = [NSString stringWithFormat: ICCF_LocalizedString(@"An unknown error occurred in %@"), context];

    return [NSString stringWithFormat: @"%@ (%d)", errDesc, (int)err];
}

CFStringRef ICCF_CopyErrString(OSStatus err, CFStringRef context) {
    if (err == noErr || err == userCanceledErr) return NULL;

    CFStringRef errNum = CFStringCreateWithFormat(NULL, NULL, CFSTR("%ld"), err);
    CFStringRef errDesc = ICCF_CopyLocalizedString(errNum);

    if (errDesc == NULL || errDesc == errNum) {
        CFStringRef errDescFormat = ICCF_CopyLocalizedString(CFSTR("An unknown error occurred in %@"));
        if (errDesc != NULL) CFRelease(errDesc);
        errDesc = CFStringCreateWithFormat(NULL, NULL, errDescFormat, context);
    }

    CFStringRef errStr = CFStringCreateWithFormat(NULL, NULL, CFSTR("%@ (%d)"), errDesc, (int)err);

    if (errNum != NULL) CFRelease(errNum);
    if (errDesc != NULL) CFRelease(errDesc);
    return errStr;
}

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

BOOL ICCF_OptionKeyIsDown() {
    unsigned int modifierFlags = [[NSApp currentEvent] modifierFlags];
    return (modifierFlags & NSAlternateKeyMask) != 0;
}

void ICCF_CheckRange(NSRange range) {
    NSCAssert(range.length > 0, ICCF_LocalizedString(@"No URL is selected"));
    NSCAssert1(range.length <= ICCF_MAX_URL_LEN, ICCF_LocalizedString(@"The potential URL is longer than %lu 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, kICCFCreator);
    NSCAssert1(err == noErr, ICCF_LocalizedString(@"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;
}

ConstStringPtr ICCF_GetHint(ICInstance inst, const char *urlData, Size length, long *selStart, long *selEnd, Boolean *needsSlashes) {
    Handle h = NewHandle(0);
    OSStatus err;

    if (h == NULL) return NULL;

    // parse the URL providing a bogus protocol, to get rid of escaped forms
    err = ICParseURL(inst, "\p*", urlData, length, selStart, selEnd, h);
    if (err != noErr) return NULL;

    // scan through the parsed URL looking for characters not found in email addresses
    Size hSize = GetHandleSize(h);
    if (hSize == 0) return NULL;

    const char *urlParsed = *h;
    long i = 0;
    Boolean sawAt = false;
    if (hSize >= 2 && urlParsed[0] == '*' && urlParsed[1] == ':') {
        // this is an IC-inserted protocol; skip over it
        i = 2;
        *needsSlashes = (hSize < i + 2 || urlParsed[i] != '/' || urlParsed[i + 1] != '/');
    } else *needsSlashes = false;
    for ( ; i < hSize ; i++) {
        char c = urlParsed[i];
        if (c == '@') {
            sawAt = true;
        } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
                     (c == '+' || c == '-' || c == '_' || c == '!' || c == '.'))) {
            DisposeHandle(h);
            return "\phttp";
        }
    }
    DisposeHandle(h);
    if (sawAt) {
        *needsSlashes = false;
        return "\pmailto";
    }
    return "\phttp";
}

static const char *kICSlashes = "//";

void ICCF_AddSlashes(Handle h, ConstStringPtr hint) {
    Size sizeBefore = GetHandleSize(h);
    unsigned char hintLength = StrLength(hint);
    char *copy = (char *)malloc(sizeBefore);
    memcpy(copy, *h, sizeBefore);
    ICLog(@"ICCF_AddSlashes before: |%s|\n", *h);
    ReallocateHandle(h, sizeBefore + 2);

    // if *h begins with '<hint>:', then copy the slashes after it
    if (sizeBefore > hintLength + 1 && strncmp(&hint[1], copy, hintLength) == 0 && copy[hintLength] == ':') {
        memcpy(*h, copy, hintLength + 1);
        memcpy(*h + hintLength + 1, kICSlashes, 2);
        memcpy(*h + hintLength + 3, &copy[hintLength + 1], sizeBefore - hintLength - 1);
    } else {
        memcpy(*h, kICSlashes, 2);
        memcpy(*h + 2, copy, sizeBefore);
    }
        
    free(copy);
    ICLog(@"ICCF_AddSlashes after: |%s|\n", *h);
}

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);
        DisposeHandle(h);

        ICCF_OSErrCAssert(err, @"ICParseURL");
    
        range->length = selEnd - selStart;
        range->location += selStart;
    NS_HANDLER
        free(urlData);
        [localException raise];
    NS_ENDHANDLER
    
    free(urlData);
}

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

    Handle h = NULL;
    
    NS_DURING
        h = NewHandle(len);
        if (h == NULL)
            ICCF_OSErrCAssert(MemError(), @"NewHandle");

        if (CFStringGetBytes((CFStringRef)string, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, *h, len, NULL) != len)
            ICCF_OSErrCAssert(kTECNoConversionPathErr, @"CFStringGetBytes");

        selStart = 0; selEnd = len;

        Boolean needsSlashes;
        ConstStringPtr hint = ICCF_GetHint(ICCF_GetInst(), *h, len, &selStart, &selEnd, &needsSlashes);
        NSCAssert(hint != NULL, @"Internal error: canÕt get protocol hint for URL");

        if (needsSlashes) {
            ICCF_AddSlashes(h, hint);
            len = selEnd = GetHandleSize(h);
        }

        if (chooseApp) {
            err = ICCF_DoURLActionMenu(ICCF_GetInst(), hint, *h, selStart, selEnd);
            ICCF_OSErrCAssert(err, @"ICCF_DoURLActionMenu");
        } else {
            err = ICLaunchURL(ICCF_GetInst(), hint, *h, len, &selStart, &selEnd);
            ICCF_OSErrCAssert(err, @"ICLaunchURL");
        }
        
    NS_HANDLER
        DisposeHandle(h);
        [localException raise];
    NS_ENDHANDLER

    DisposeHandle(h);    
}

// XXX not sure what to do if there's already a selection; BBEdit and MLTE extend 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

Boolean ICCF_enabled = true;

BOOL ICCF_HandleException(NSException *e) {
    if ([e reason] == nil || [[e reason] length] == 0)
        return NO;
    
    if (ICCF_prefs.errorSoundEnabled) NSBeep();
    if (!ICCF_prefs.errorDialogEnabled) return YES;
    
    int result = NSRunAlertPanel(ICCF_LocalizedString(@"AlertTitle"), ICCF_LocalizedString(@"AlertMessage%@"), nil, nil, ICCF_LocalizedString(@"AlertDisableButton"), e);
    if (result != NSAlertDefaultReturn) {
        result = NSRunAlertPanel(ICCF_LocalizedString(@"DisableAlertTitle"), ICCF_LocalizedString(@"DisableAlertMessage"), ICCF_LocalizedString(@"DisableAlertDisableButton"), ICCF_LocalizedString(@"DisableAlertDontDisableButton"), nil);
        if (result == NSAlertDefaultReturn)
            ICCF_enabled = NO;
    }
    return YES;
}

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, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked"));
        NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));

        ICCF_StartIC();

        NSCAssert([s length] != 0, ICCF_LocalizedString(@"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);

        [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
        [self display];

        ICCF_LaunchURL([s substringWithRange: range], ICCF_OptionKeyIsDown());

        if (ICCF_prefs.textBlinkEnabled) {
            for (i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
                NSRange emptyRange = {range.location, 0};
                [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
                [self display];
                usleep(kICBlinkDelayUsecs);
                [self setInsertionPointColor: [self backgroundColor]];
                [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
                [self display];
                usleep(kICBlinkDelayUsecs);
            }
        }

    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_AddRemoveServicesMenu() {
    // needed because:
    // (a) we get called before the runloop has properly started and will crash if we don't delay on app startup
    // (b) the APE message handler calls us from another thread and nothing happens if we try to add a menu on it
    [ICeCoffEE performSelectorOnMainThread: @selector(IC_addRemoveServicesMenu) withObject: nil waitUntilDone: NO];
}

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 && ICCF_prefs.servicesInContextualMenu) {
            [contextMenu addItem: [NSMenuItem separatorItem]];
            [contextMenu addItem: ICCF_ServicesMenuItem()];
        } else if (servicesItemIndex != -1 && !ICCF_prefs.servicesInContextualMenu) {
            [contextMenu removeItemAtIndex: servicesItemIndex];
            [contextMenu removeItemAtIndex: servicesItemIndex - 1];
        }
    }
    return contextMenu;
}

@implementation ICeCoffEE

+ (void)IC_addRemoveServicesMenu;
{
    NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu];
    static NSMenuItem *servicesItem = nil;
    
    if (servicesItem == nil && ICCF_prefs.servicesInMenuBar) {
        servicesItem = [ICCF_ServicesMenuItem() retain];

        int insertLoc = [mainMenu indexOfItemWithSubmenu: [NSApp windowsMenu]];
        if (insertLoc == -1)
            insertLoc = [mainMenu numberOfItems];

        [mainMenu insertItem: servicesItem atIndex: insertLoc];
    } else if (servicesItem != nil && !ICCF_prefs.servicesInMenuBar) {
        [mainMenu removeItem: servicesItem];
        [servicesItem release];
        servicesItem = nil;
    }
}

// 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);
#endif
    // we don't actually get a mouseUp event, just wait for mouseDown to return
    [super mouseDown: e];
    if (!ICCF_enabled || !ICCF_prefs.commandClickEnabled) {
#if ICCF_DEBUG
        down = NO;
#endif
        return;
    }
    // don't want 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) <= kICHysteresisPixels && abs(downPt.y - upPt.y) <= kICHysteresisPixels) {
            ICCF_LaunchURLFromTextView(self);
        }
    }
#if ICCF_DEBUG
    down = NO;
#endif
}

@end
