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

#import "ICeCoffEE.h"
#import <Carbon/Carbon.h>
#include <unistd.h>
#import "ICeCoffEESuper.h"
#import "ICeCoffEEServices.h"
#import "ICeCoffEETextViewTrigger.h"
#import "ICeCoffEEParser.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;
}

CFStringRef ICCF_CopyAppName() {
    ProcessSerialNumber psn = {0, kCurrentProcess};
    CFStringRef appName = NULL;
    CopyProcessName(&psn, &appName);
    if (appName == NULL) return CFSTR("(unknown)");
    return appName;
}

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

iccfURLAction ICCF_KeyboardAction(NSEvent *e) {
    unsigned int modifierFlags = [e modifierFlags];
    iccfURLAction action;
    action.presentMenu = (modifierFlags & NSAlternateKeyMask) != 0;
    action.launchInBackground = (modifierFlags & NSShiftKeyMask) != 0;
    return action;
}

// RFC-ordained max URL length, just to avoid passing IC/LS 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

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);
}

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((const char *)&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);
}

BOOL ICCF_LaunchURL(NSString *string, iccfURLAction action) {
    OSStatus err = noErr;
    long selStart, selEnd;
    NSMutableString *urlString = [[NSMutableString alloc] init];
    NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
    NSScanner *scanner = [[NSScanner alloc] initWithString: string];
    NSString *fragmentString;
    while ([scanner scanUpToCharactersFromSet: whitespace intoString: &fragmentString]) {
        [urlString appendString: fragmentString];
    }
    unsigned len = [urlString length];

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

        if (CFStringGetBytes((CFStringRef)urlString, CFRangeMake(0, len), kCFStringEncodingASCII, '\0', false, (UInt8 *)*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);
        }

        err = ICCF_DoURLAction(ICCF_GetInst(), hint, *h, selStart, selEnd, action);
        ICCF_OSErrCAssert(err, @"ICCF_DoURLAction");
    } @finally {
        DisposeHandle(h);
        [urlString release];
    }

    return (err == noErr);
}

// XXX not sure what to do if there's already a selection; BBEdit and MLTE extend it, Tex-Edit Plus doesn't.
Boolean ICCF_enabled = true;

BOOL ICCF_HandleException(NSException *e, NSEvent *event) {
    if ([e reason] == nil || [[e reason] length] == 0)
        return NO;
    
    if (ICCF_prefs.errorSoundEnabled) NSBeep();
    if (!ICCF_prefs.errorDialogEnabled) return YES;
    
    [[NSApplication sharedApplication] activateIgnoringOtherApps: YES];
    [[event window] makeKeyAndOrderFront: nil];
    
    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,
           [(NSString *)ICCF_CopyAppName() autorelease]);
        if (result == NSAlertDefaultReturn)
            ICCF_enabled = NO;
    }
    return YES;
}

void ICCF_LaunchURLFromTextView(NSTextView *self, NSEvent *triggeringEvent) {
    BOOL isEditable = [self isEditable];

    @try {
        NSString *s = [[self textStorage] string]; // according to the class documentation, sending 'string' is guaranteed to be O(1)
        unsigned length = [s length];
        NSCAssert(s != nil, ICCF_LocalizedString(@"Sorry, ICeCoffEE is unable to locate the insertion point or selection"));
        NSCAssert(length != 0, ICCF_LocalizedString(@"No text was found"));

        ICCF_StartIC();

        NSRange range = [self selectedRange];
        NSCAssert(range.location != NSNotFound, ICCF_LocalizedString(@"There is no insertion point or selection in the text field where you clicked"));
        NSString *url = nil;
        
        if (range.location == length)
            range.location--; // Leopard text storage complains "out of range"
    
        if ([[self textStorage] attribute: NSLinkAttributeName atIndex: range.location
                           effectiveRange: NULL] != nil) {
            NSRange linkRange;
            id link = [[self textStorage] attribute: NSLinkAttributeName atIndex: range.location longestEffectiveRange: &linkRange inRange: NSMakeRange(0, length)];
            if (NSMaxRange(range) <= NSMaxRange(linkRange)) {
                // selection is entirely within link range
                url = [link isKindOfClass: [NSURL class]] ? [link absoluteString] : link;
                range = linkRange;
                [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
            }
        }
        if (url == nil) {
            if (range.length == 0) {
                if (range.location == length) range.location--;
                range.length = 1;
                range = ICCF_URLEnclosingRange(s, range);
                [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: NO];
            }
        
            url = [s substringWithRange: range];
        }

        if (ICCF_LaunchURL(url, ICCF_KeyboardAction(triggeringEvent)) && ICCF_prefs.textBlinkEnabled) {
            if (isEditable)
                [self setEditable: NO];

            for (unsigned i = 0 ; i < ICCF_prefs.textBlinkCount ; i++) {
                NSRange emptyRange = {range.location, 0};
                [self setSelectedRange: emptyRange affinity: NSSelectionAffinityDownstream stillSelecting: YES];
                [self display];
                usleep(kICBlinkDelayUsecs);
                [self setSelectedRange: range affinity: NSSelectionAffinityDownstream stillSelecting: YES];
                [self display];
                usleep(kICBlinkDelayUsecs);
            }
        }
    } @catch (NSException *e) {
        ICCF_HandleException(e, triggeringEvent);
    }

    ICCF_StopIC();
    if (isEditable)
        [self setEditable: YES];
}

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

NSMenuItem *ICCF_ServicesMenuItem() {
    NSMenuItem *servicesItem;
    NSString *servicesTitle = nil;
    NSMenu *servicesMenu = [NSApp servicesMenu];
    
    if (servicesMenu != nil) {
	servicesTitle = [servicesMenu title];
	if (servicesTitle == nil) {
	    ICLog(@"Can't get service menu title");
	    servicesTitle = @"Services";
	}
    } else {
	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:@""];
    ICCF_SetServicesMenu(servicesMenu);
    [servicesItem setSubmenu: servicesMenu];
    [servicesItem setRepresentedObject: ICCF_SERVICES_ITEM];
    [servicesMenu release];
    return [servicesItem autorelease];
}

static const unichar UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE = 0x25b8;

// returns YES if menu contains useful items, NO otherwise
static BOOL ICCF_ConsolidateServicesMenu(NSMenu *menu, NSDictionary *serviceOptions, NSDictionary *serviceInfo) {
    [menu update]; // doesn't propagate to submenus, so we need to do this first
    NSEnumerator *enumerator = [[menu itemArray] objectEnumerator];
    NSMenuItem *menuItem;
    NSMenu *submenu;
    NSDictionary *itemOptions = nil, *itemInfo = nil;
    BOOL shouldKeepItem = NO, shouldKeepMenu = NO;

    while ( (menuItem = [enumerator nextObject]) != nil) {
        if (serviceOptions != nil)
            itemOptions = [serviceOptions objectForKey: [menuItem title]];
        if (serviceInfo != nil)
            itemInfo = [serviceInfo objectForKey: [menuItem title]];
        if ([[itemOptions objectForKey: (NSString *)kICServiceHidden] boolValue]) {
            shouldKeepItem = NO;
        } else if ( (submenu = [menuItem submenu]) != nil) {
            // XXX don't rely on nil-sending working
            shouldKeepItem = ICCF_ConsolidateServicesMenu(submenu, [itemOptions objectForKey: (NSString *)kICServiceSubmenu], [itemInfo objectForKey: (NSString *)kICServiceSubmenu]);
            if (shouldKeepItem && [submenu numberOfItems] == 1) { // consolidate
                NSMenuItem *serviceItem = [[submenu itemAtIndex: 0] retain];
                [serviceItem setTitle:
                    [NSString stringWithFormat: @"%@ %@ %@", [menuItem title], [NSString stringWithCharacters: &UNICHAR_BLACK_RIGHT_POINTING_SMALL_TRIANGLE length: 1], [serviceItem title]]];
                
                int serviceIndex = [menu indexOfItem: menuItem];
                [submenu removeItemAtIndex: 0]; // can't have item in two menus
                [menu removeItemAtIndex: serviceIndex];
                [menu insertItem: serviceItem atIndex: serviceIndex];
                [serviceItem release];
                menuItem = serviceItem;
            }
        } else {
	    [menuItem setKeyEquivalent: @""];
            shouldKeepItem = [menuItem isEnabled];
        }
        if (!shouldKeepItem) {
            [menu removeItem: menuItem];
            continue;
        }
        shouldKeepMenu = YES;
        
        if (itemInfo == nil) continue;
        NSString *bundlePath = (NSString *)[itemInfo objectForKey: (NSString *)kICServiceBundlePath];
        if (bundlePath == NULL) continue;
        IconRef serviceIcon = ICCF_CopyIconRefForPath(bundlePath);
        if (serviceIcon == NULL) continue;
        [menuItem _setIconRef: serviceIcon];
        ReleaseIconRef(serviceIcon);
    }

    return shouldKeepMenu;
}

NSMenuItem *ICCF_ContextualServicesMenuItem() {
    // user key equivalents get populated in 10.4, not in 10.5
    BOOL usesUserKeyEquivalents = [NSMenuItem usesUserKeyEquivalents];
    if (usesUserKeyEquivalents) {
        ICCF_SetServicesMenu([NSApp servicesMenu]); // populate menubar menu with key equivalents
	[NSMenuItem setUsesUserKeyEquivalents: NO];
    }
    
    NSMenuItem *servicesItem = ICCF_ServicesMenuItem();

    if (usesUserKeyEquivalents)
	[NSMenuItem setUsesUserKeyEquivalents: YES];

    NSDictionary *servicesInfo = ICCF_GetServicesInfo(); // XXX cache/retain
    if (ICCF_ConsolidateServicesMenu([servicesItem submenu], (NSDictionary *)ICCF_prefs.serviceOptions, servicesInfo))
        return servicesItem;
    else
        return nil;
}

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(NSView *self, NSMenu *contextMenu, NSEvent *e) {
    if (contextMenu != nil && [e type] == NSRightMouseDown || ([e type] == NSLeftMouseDown && [e modifierFlags] & NSControlKeyMask)) {
        int servicesItemIndex = [contextMenu indexOfItemWithRepresentedObject: ICCF_SERVICES_ITEM];
        // always regenerate: make sure menu reflects context
        if (servicesItemIndex != -1) {
            [contextMenu removeItemAtIndex: servicesItemIndex];
            [contextMenu removeItemAtIndex: servicesItemIndex - 1];
        }
        if (ICCF_prefs.servicesInContextualMenu) {
            NSMenuItem *contextualServicesItem = ICCF_ContextualServicesMenuItem();
            if (contextualServicesItem != nil) {
                [contextMenu addItem: [NSMenuItem separatorItem]];
                [contextMenu addItem: contextualServicesItem];
            }
        }
    }
    return contextMenu;
}

static NSEvent *ICCF_MouseDownEventWithModifierFlags(NSEvent *e, BOOL inheritModifierFlags) {
    return [NSEvent mouseEventWithType: NSLeftMouseDown
			      location: [e locationInWindow]
			 modifierFlags: (inheritModifierFlags ? [e modifierFlags] : 0)
			     timestamp: [e timestamp]
			  windowNumber: [e windowNumber]
			       context: [e context]
			   eventNumber: [e eventNumber]
			    clickCount: 1
			      pressure: 0];
}

@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);
}

static BOOL ICCF_inMouseDown;

- (void)clickedOnLink:(id)link atIndex:(unsigned)charIndex;
{
    if (!ICCF_inMouseDown)
        [super clickedOnLink: link atIndex: charIndex];
}

- (void)mouseDown:(NSEvent *)downEvent;
{
#if ICCF_DEBUG
    static BOOL down = NO;
    if (down) {
        ICLog(@"recursive invocation!");
        return;
    }
    down = YES;
    ICLog(@"ICeCoffEE down: %@", downEvent);
#endif
    [ICeCoffEETrigger cancel];

    if (ICCF_enabled && ICCF_prefs.commandClickEnabled && ICCF_EventIsCommandMouseDown(downEvent)) {
        ICCF_inMouseDown = YES;
        @try {
            [super mouseDown: ICCF_MouseDownEventWithModifierFlags(downEvent, YES)];
        } @finally {
            ICCF_inMouseDown = NO;
        }
        // we don't actually get a mouseUp event, just wait for mouseDown to return
        NSEvent *upEvent = [[self window] currentEvent];
        NSPoint downPt = [downEvent 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) {
            // make sure we don't have a Command-double-click
            [ICeCoffEETextViewTrigger setTriggerForEvent: downEvent onTarget: self]; // gets stored in ICCF_sharedTrigger; the reason for this weird calling pattern is that we don't want to add methods to NSTextView, and we don't want to add a method call on every mouseDown
        }
    } else {
        [super mouseDown: downEvent];
    }
#if ICCF_DEBUG
    down = NO;
#endif
}

@end
