//
//  EditorWindowController.swift
//  Komet
//
//  Created by Mayur Pawashe on 10/17/20.
//  Copyright © 2020 zgcoder. All rights reserved.
//

import Foundation
import AppKit

private let ZGEditorWindowFrameNameKey = "ZGEditorWindowFrame"
private let APP_SUPPORT_DIRECTORY_NAME = "Komet"

private let MAX_CHARACTER_COUNT_FOR_NOT_DRAWING_BACKGROUND = 132690
private let MAX_CHARACTER_COUNT_FOR_NON_VERSION_CONTROL_COMMENT_ATTRIBUTES = 10000

enum VersionControlType {
	case git
	case hg
	case svn
}

@objc class ZGEditorWindowController: NSWindowController, UserDefaultsEditorListener, NSTextStorageDelegate, NSTextContentStorageDelegate, NSTextViewDelegate, ZGCommitViewDelegate {
	
	private let fileURL: URL
	private let temporaryDirectoryURL: URL?
	private let tutorialMode: Bool
	private var breadcrumbs: Breadcrumbs?
	
	private let initiallyContainedEmptyContent: Bool
	private let isSquashMessage: Bool
	private let commentSectionLength: Int
	private let versionControlType: VersionControlType
	private let commentVersionControlType: VersionControlType
	private let projectNameDisplay: String
	private let initialPlainText: String
	private let initialCommitTextRange: Range<String.UTF16View.Index>
	private let resumedFromSavedCommit: Bool
	
	private var style: WindowStyle
	private var preventAccidentalNewline: Bool = false
	private var effectiveAppearanceObserver: NSKeyValueObservation? = nil
	
	private var textView: ZGCommitTextView!
	private var scrollView: NSScrollView!
	
	@IBOutlet private var topBar: NSView!
	@IBOutlet private var horizontalBarDivider: NSBox!
	@IBOutlet private var scrollViewContainer: NSView!
	@IBOutlet private var contentView: NSVisualEffectView!
	@IBOutlet private var commitLabelTextField: NSTextField!
	@IBOutlet private var cancelButton: NSButtonCell!
	@IBOutlet private var commitButton: NSButton!
	@IBOutlet var checkForUpdatesProgressIndicator: NSProgressIndicator!
	
	// MARK: Static functions
	
	private static func styleTheme(defaultTheme: WindowStyleDefaultTheme, effectiveAppearance: NSAppearance) -> WindowStyleTheme {
		switch defaultTheme {
		case .automatic:
			let appearanceName = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])
			let darkMode = (appearanceName == .darkAqua)
			
			return darkMode ? .dark : .plain
		case .theme(let theme):
			return theme
		}
	}
	
	private static func isCommentLine(_ line: String, versionControlType: VersionControlType) -> Bool {
		let prefix: String
		let suffix: String
		
		switch versionControlType {
		case .git:
			prefix = "#"
			suffix = ""
		case .hg:
			prefix = "HG:"
			suffix = ""
		case .svn:
			prefix = "--"
			suffix = "--"
		}
		
		// Note a line that is "--" could have the prefix and suffix the same, but we want to make sure it's at least "--...--" length long
		return line.hasPrefix(prefix) && line.hasSuffix(suffix) && line.count >= prefix.count + suffix.count
	}
	
	private static func isScissorLine(_ line: String, versionControlType: VersionControlType) -> Bool {
		guard versionControlType == .git else {
			return false
		}
		
		return line.hasPrefix("# --") && line.hasSuffix("--") && line.contains(">8")
	}
	
	private static func hasSingleCommentLineMarker(versionControlType: VersionControlType) -> Bool {
		switch versionControlType {
		case .git:
			return false
		case .hg:
			return false
		case .svn:
			return true
		}
	}
	
	// The comment range should begin at the line that starts with a comment string and extend to the end of the file.
	// Additionally, there should be no content lines (i.e, non comment lines) within this section
	// (exception: unless we're dealing with svn which only has a starting point for comments)
	// This should only be computed once, before the user gets a chance to edit the content
	private static func commentSectionLength(plainText: String, versionControlType: VersionControlType) -> Int {
		let plainTextEndIndex = plainText.endIndex
		var characterIndex = String.Index(utf16Offset: 0, in: plainText)
		var lineStartIndex = String.Index(utf16Offset: 0, in: plainText)
		var lineEndIndex = String.Index(utf16Offset: 0, in: plainText)
		var contentEndIndex = String.Index(utf16Offset: 0, in: plainText)
		
		var foundCommentSection: Bool = false
		var commentSectionCharacterIndex: String.Index = String.Index(utf16Offset: 0, in: plainText)
		
		while characterIndex < plainTextEndIndex {
			plainText.getLineStart(&lineStartIndex, end: &lineEndIndex, contentsEnd: &contentEndIndex, for: characterIndex ..< characterIndex)
			
			let line = String(plainText[lineStartIndex ..< contentEndIndex])
			
			let commentLine = isCommentLine(line, versionControlType: versionControlType)
			
			if !commentLine && foundCommentSection && line.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 {
				// If we found a content line that is not empty, then we have to find a better starting point for the comment section
				foundCommentSection = false
			} else if commentLine {
				if !foundCommentSection {
					foundCommentSection = true
					commentSectionCharacterIndex = characterIndex
					
					// If there's only a single comment line marker, then we're done'
					if hasSingleCommentLineMarker(versionControlType: versionControlType) {
						break
					}
				} else if isScissorLine(line, versionControlType: versionControlType) {
					// Everything below the scissor line is non-editable content which will be part of the comment section
					// Content bellow the scissor line may include lines that show a diff of a commit message and aren't prefixed by a comment character
					break
				}
			}
			
			characterIndex = lineEndIndex
		}
		
		return foundCommentSection ? (plainText.utf16.count - commentSectionCharacterIndex.utf16Offset(in: plainText)) : 0
	}
	
	// The content range should extend to before the comments, only allowing one trailing newline in between the comments and content
	// Make sure to scan from the bottom to top
	private static func commitTextRange(plainText: String, commentLength: Int) -> Range<String.UTF16View.Index> {
		let utf16View = plainText.utf16
		var bestEndCharacterIndex = utf16View.index(utf16View.endIndex, offsetBy: -commentLength)
		
		var passedNewline = false
		
		let startIndex = utf16View.startIndex
		while bestEndCharacterIndex > startIndex {
			let priorCharacterIndex = plainText.index(before: bestEndCharacterIndex)
			
			let character = plainText[priorCharacterIndex]
			if character == "\n" {
				bestEndCharacterIndex = priorCharacterIndex
				
				if passedNewline {
					break;
				} else {
					passedNewline = true
				}
			} else {
				break
			}
		}

		return startIndex ..< bestEndCharacterIndex
	}
	
	private static func lengthLimitWarningEnabled(userDefaults: UserDefaults, userDefaultKey: String) -> Bool {
		return userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey) && userDefaults.bool(forKey: userDefaultKey)
	}
	
	// MARK: Initialization
	
	static func registerDefaults() {
		let userDefaults = UserDefaults.standard
		
		ZGRegisterDefaultFont(userDefaults, ZGMessageFontNameKey, ZGMessageFontPointSizeKey)
		ZGRegisterDefaultFont(userDefaults, ZGCommentsFontNameKey, ZGCommentsFontPointSizeKey)
		
		userDefaults.register(defaults: [
			ZGEditorRecommendedSubjectLengthLimitEnabledKey: true,
			// Not using 50 because I think it may be too irritating of a default for Mac users
			// GitHub's max limit is technically 72 so we are slightly shy under it
			ZGEditorRecommendedSubjectLengthLimitKey: 69,
			// Having a recommendation limit for body lines could be irritating as a default, so we disable it by default
			ZGEditorRecommendedBodyLineLengthLimitEnabledKey: false,
			ZGEditorRecommendedBodyLineLengthLimitKey: 72,
			ZGEditorAutomaticNewlineInsertionAfterSubjectKey: true,
			ZGResumeIncompleteSessionKey: true,
			ZGResumeIncompleteSessionTimeoutIntervalKey: 60.0 * 60, // around 1 hour
			ZGWindowVibrancyKey: false,
			ZGDisableSpellCheckingAndCorrectionForSquashesKey: true,
			ZGDisableAutomaticNewlineInsertionAfterSubjectLineForSquashesKey: true,
			ZGDetectHGCommentStyleForSquashesKey: true,
			ZGAssumeVersionControlledFileKey: true
		])
		
		ZGCommitTextView.registerDefaults()
	}
	
	required init(fileURL: URL, temporaryDirectoryURL: URL?, tutorialMode: Bool) {
		self.fileURL = fileURL
		self.temporaryDirectoryURL = temporaryDirectoryURL
		self.tutorialMode = tutorialMode
		
		let userDefaults = UserDefaults.standard
		
		let processInfo = ProcessInfo.processInfo
		if let _ = processInfo.environment[ZGBreadcrumbsURLKey] {
			breadcrumbs = Breadcrumbs()
		} else {
			breadcrumbs = nil
		}
		
		style = WindowStyle.withTheme(Self.styleTheme(defaultTheme: ZGReadDefaultWindowStyleTheme(userDefaults, ZGWindowStyleThemeKey), effectiveAppearance: NSApp.effectiveAppearance))
		
		// Detect squash message
		let loadedPlainString: String
		do {
			let plainStringCandidate = try String(contentsOf: self.fileURL, encoding: .utf8)
			
			// It's unlikely we'll get content that has no line break, but if we do,
			// just insert a newline character because Komet won't be able to deal with the content otherwise
			let lineCount = plainStringCandidate.components(separatedBy: .newlines).count
			loadedPlainString = (lineCount <= 1) ? "\n" : plainStringCandidate
			
			// Detect heuristically if this is a squash/rebase in git or hg
			// Scan the entire string contents for simplicity and handle both git and hg (with histedit extension)
			// Also test if the filename contains "rebase"
			isSquashMessage = fileURL.lastPathComponent.contains("rebase") || loadedPlainString.contains("= use commit")
		} catch {
			fatalError("Failed to parse commit data: \(error)")
		}
		
		// Detect version control type
		let fileManager = FileManager()
		let parentURL = self.fileURL.deletingLastPathComponent()
		let versionControlledFile = userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey)
		
		let versionControlType: VersionControlType
		if tutorialMode || !versionControlledFile {
			versionControlType = .git
			self.projectNameDisplay = self.fileURL.lastPathComponent
		} else if parentURL.lastPathComponent == ".git" {
			// We don't *have* to detect this for git because we could look at the current working directory first,
			// but I want to rely on the current working directory as a last resort.
			
			versionControlType = .git
			self.projectNameDisplay = parentURL.deletingLastPathComponent().lastPathComponent
		} else {
			let lastPathComponent = self.fileURL.lastPathComponent
			if lastPathComponent.hasPrefix("hg-") {
				versionControlType = .hg
			} else if lastPathComponent.hasPrefix("svn-") {
				versionControlType = .svn
			} else {
				versionControlType = .git
			}
			
			if let projectNameFromEnvironment = processInfo.environment[ZGProjectNameKey] {
				self.projectNameDisplay = projectNameFromEnvironment
			} else {
				self.projectNameDisplay = URL(fileURLWithPath: fileManager.currentDirectoryPath).lastPathComponent
			}
		}
		
		self.versionControlType = versionControlType
		
		commentVersionControlType =
			(isSquashMessage && versionControlType == .hg && userDefaults.bool(forKey: ZGDetectHGCommentStyleForSquashesKey)) ?
			.git : versionControlType
		
		// Detect if there's empty content
		let loadedCommentSectionLength = !versionControlledFile ? 0 : Self.commentSectionLength(plainText: loadedPlainString, versionControlType: commentVersionControlType)
		let loadedCommitRange = Self.commitTextRange(plainText: loadedPlainString, commentLength: loadedCommentSectionLength)
		
		let loadedContent = loadedPlainString[loadedCommitRange.lowerBound ..< loadedCommitRange.upperBound]
		
		initiallyContainedEmptyContent = (loadedContent.trimmingCharacters(in: .newlines).count == 0)
		
		// Check if we have any incomplete commit message available
		// Load the incomplete commit message contents if our content is initially empty
		let lastSavedCommitMessage: String?
		if !self.tutorialMode && userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey) && userDefaults.bool(forKey: ZGResumeIncompleteSessionKey) {
			if let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
				let supportDirectory = applicationSupportURL.appendingPathComponent(APP_SUPPORT_DIRECTORY_NAME)
				
				let lastCommitURL = supportDirectory.appendingPathComponent(projectNameDisplay)
				
				do {
					let reachable = try lastCommitURL.checkResourceIsReachable()
					if reachable {
						defer {
							// Always remove the last commit file on every launch
							let _ = try? fileManager.removeItem(at: lastCommitURL)
						}
						
						let resource = try lastCommitURL.resourceValues(forKeys: [.attributeModificationDateKey])
						let lastModifiedDate = resource.attributeModificationDate
						
						// Use a timeout interval for using the last incomplete commit message
						// If too much time passes by, chances are the user may want to start anew
						let maxTimeout = 60.0 * 60.0 * 24 * 7 * 5 // around a month
						let timeoutInterval = ZGReadDefaultTimeoutInterval(userDefaults, ZGResumeIncompleteSessionTimeoutIntervalKey, maxTimeout)
						let intervalSinceLastSavedCommitMessage = lastModifiedDate.flatMap({ Date().timeIntervalSince($0) }) ?? 0.0
						
						if initiallyContainedEmptyContent && intervalSinceLastSavedCommitMessage >= 0.0 && intervalSinceLastSavedCommitMessage <= timeoutInterval {
							lastSavedCommitMessage = try String(contentsOf: lastCommitURL, encoding: .utf8)
						} else {
							lastSavedCommitMessage = nil
						}
					} else {
						lastSavedCommitMessage = nil
					}
				} catch {
					print("Failed to load last saved commit message: \(error)")
					lastSavedCommitMessage = nil
				}
			} else {
				print("Failed to find application support directory")
				lastSavedCommitMessage = nil
			}
		} else {
			lastSavedCommitMessage = nil
		}
		
		if let savedCommitMessage = lastSavedCommitMessage {
			initialPlainText = savedCommitMessage.appending(loadedPlainString)
			commentSectionLength = !versionControlledFile ? 0 : Self.commentSectionLength(plainText: initialPlainText, versionControlType: commentVersionControlType)
			initialCommitTextRange = Self.commitTextRange(plainText: initialPlainText, commentLength: commentSectionLength)
			resumedFromSavedCommit = true
		} else {
			initialPlainText = loadedPlainString
			commentSectionLength = loadedCommentSectionLength
			initialCommitTextRange = loadedCommitRange
			resumedFromSavedCommit = false
		}
		
		super.init(window: nil)
	}
	
	required init?(coder: NSCoder) {
		fatalError("init(coder:) has not been implemented")
	}
	
	deinit {
		effectiveAppearanceObserver?.invalidate()
	}
	
	override var windowNibName: String {
		return "Commit Editor"
	}
	
	private func currentPlainText() -> String {
		let textStorage = textView.textStorage
		return textStorage?.string ?? ""
	}
	
	private func updateTextViewDrawingBackground() {
		textView.drawsBackground = false
	}
	
	private func updateCurrentStyle() {
		// Style top bar
		do {
			topBar.wantsLayer = true
			topBar.layer?.backgroundColor = style.barColor.cgColor
			
			// Setting the top bar appearance will provide us a proper border for the commit button in dark and light themes
			topBar.appearance = style.appearance
		}
		
		// Style top bar buttons
		do {
			commitLabelTextField.textColor = style.barTextColor
			
			let commitTitle = NSMutableAttributedString(attributedString: commitButton.attributedTitle)
			commitTitle.addAttribute(.foregroundColor, value: style.barTextColor, range: NSMakeRange(0, commitTitle.length))
			commitButton.attributedTitle = commitTitle
			
			let cancelTitle = NSMutableAttributedString(attributedString: cancelButton.attributedTitle)
			cancelTitle.addAttribute(.foregroundColor, value: style.barTextColor, range: NSMakeRange(0, cancelTitle.length))
			cancelButton.attributedTitle = cancelTitle
		}
		
		// Horizontal line bar divider
		horizontalBarDivider.fillColor = style.dividerLineColor
		
		// Style text
		do {
			textView.wantsLayer = true
			updateTextViewDrawingBackground()
			textView.insertionPointColor = style.textColor
			
			// As fallback, use NSColor.selectedControlColor. Note NSColor.selectedTextColor does not give right results.
			let textHighlightColor = style.textHighlightColor ?? NSColor.selectedControlColor
			textView.selectedTextAttributes = [.backgroundColor: textHighlightColor, .foregroundColor: style.barTextColor]
			
			if #unavailable(macOS 13) {
				if let window = window, window.isVisible {
					// Changing NSTextView selection color doesn't quite work correctly when using TextKit2 by itself
					// So we apply an additional workaround to get NSTextView to update the selection text color for real
					// Unfortunately we will need to deselect any selected text ranges as well
					// Workaround found here: https://github.com/ChimeHQ/TextViewPlus
					// Filed FB9967570. Note this is fixed in macOS 13 and later.
					
					let selectedTextRange = textView.selectedRange()
					if selectedTextRange.length > 0 {
						textView.setSelectedRange(NSMakeRange(selectedTextRange.location, 0))
					}
					
					textView.rotate(byDegrees: 1.0)
					textView.rotate(byDegrees: -1.0)
				}
			}
		}
		
		// Style content view
		let vibrant = UserDefaults.standard.bool(forKey: ZGWindowVibrancyKey)
		do {
			contentView.state = vibrant ? .followsWindowActiveState : .inactive
			contentView.appearance = style.appearance
		}
		
		// Style scroll view
		do {
			scrollView.scrollerKnobStyle = style.scrollerKnobStyle
			if vibrant {
				scrollView.drawsBackground = false
			} else {
				scrollView.drawsBackground = true
				scrollView.backgroundColor = style.fallbackBackgroundColor
			}
		}
	}
	
	private func updateStyle(_ newStyle: WindowStyle) {
		style = newStyle
		updateCurrentStyle()
	}
	
	private func commentSectionIndex(plainUTF16Text: String.UTF16View) -> String.UTF16View.Index {
		return plainUTF16Text.index(plainUTF16Text.endIndex, offsetBy: -commentSectionLength)
	}
	
	private func commentUTF16Range(plainText: String) -> NSRange {
		let utf16View = plainText.utf16
		return convertToUTF16Range(range: commentSectionIndex(plainUTF16Text: utf16View) ..< utf16View.endIndex, in: plainText)
	}
	
	private func convertToUTF16Range(range: Range<String.Index>, in string: String) -> NSRange {
		return NSRange(range, in: string)
	}
	
	func updateFont(_ font: NSFont, utf16Range: NSRange) {
		textView.textStorage?.addAttribute(.font, value: font, range: utf16Range)
		
		// If we don't fix the font attributes, then attachments (like emoji) may become invisible and not show up
		textView.textStorage?.fixFontAttribute(in: utf16Range)
	}
		
	private func reloadTextAttributes() {
		// Replacing all the characters will force all the text attributes to be re-computed
		// I wonder if there is a better way of doing this
		if let textStorage = textView.textStorage, let attributedCopy = textStorage.copy() as? NSAttributedString {
			textStorage.setAttributedString(attributedCopy)
		}
	}
	
	private func updateEditorStyle(_ style: WindowStyle) {
		updateStyle(style)
		reloadTextAttributes()
		
		topBar.needsDisplay = true
		contentView.needsDisplay = true
	}
	
	@objc override func windowDidLoad() {
		// Following these steps to set up scroll view and text view
		// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/TextUILayer/Tasks/TextInScrollView.html#//apple_ref/doc/uid/20000938-CJBBIAAF
		scrollView = NSScrollView(frame: scrollViewContainer.frame)
		scrollView.borderType = .noBorder
		scrollView.hasVerticalScroller = true
		scrollView.hasHorizontalScroller = false
		scrollView.autoresizingMask = .init(rawValue: NSView.AutoresizingMask.width.rawValue | NSView.AutoresizingMask.height.rawValue)
		
		let scrollViewContentSize = scrollView.contentSize
		let textContainerSize = NSMakeSize(scrollViewContentSize.width, CGFloat(Float.greatestFiniteMagnitude))
		
		let userDefaults = UserDefaults.standard
		
		// Initialize NSTextView via code snippets from https://developer.apple.com/documentation/appkit/nstextview/1449347-initwithframe
		do {
			let textLayoutManager = NSTextLayoutManager()
			
			let textContainer = NSTextContainer(size: textContainerSize)
			textLayoutManager.textContainer = textContainer
			
			let textContentStorage = NSTextContentStorage()
			textContentStorage.addTextLayoutManager(textLayoutManager)
			textContentStorage.delegate = self
			
			textView = ZGCommitTextView(frame: NSMakeRect(0.0, 0.0, scrollViewContentSize.width, scrollViewContentSize.height), textContainer: textLayoutManager.textContainer)
			
#if DEBUG
			NotificationCenter.default.addObserver(forName: NSTextView.didSwitchToNSLayoutManagerNotification, object: textView, queue: OperationQueue.main) { _ in
				
				assertionFailure("TextView should not be switching to TextKit 1 layout manager")
			}
#endif
		}
		
		textView.minSize = NSMakeSize(0.0, scrollViewContentSize.height)
		textView.maxSize = NSMakeSize(CGFloat(Float.greatestFiniteMagnitude), CGFloat(Float.greatestFiniteMagnitude))
		textView.isVerticallyResizable = true
		textView.isHorizontallyResizable = false
		textView.autoresizingMask = .width
		textView.textContainer?.widthTracksTextView = true
		textView.allowsUndo = true
		textView.isRichText = false
		textView.usesRuler = false
		textView.usesFindBar = true
		textView.isIncrementalSearchingEnabled = false
		textView.isAutomaticDataDetectionEnabled = false
		// Keeping the font panel enabled can lead to a serious performance hit:
		// https://christiantietze.de/posts/2021/09/nstextview-fontpanel-slowness/
		// We don't want to use it anyway
		textView.usesFontPanel = false
		
		scrollView.documentView = textView
		scrollViewContainer.addSubview(scrollView)
		
		self.window?.setFrameUsingName(ZGEditorWindowFrameNameKey)

		self.updateCurrentStyle()
		
		// Update style when user changes the system appearance
		self.effectiveAppearanceObserver = NSApp.observe(\.effectiveAppearance, options: [.old, .new]) { [weak self] (application, change) in
			guard let self = self else {
				return
			}
			
			if change.oldValue?.name != change.newValue?.name {
				let defaultTheme = ZGReadDefaultWindowStyleTheme(userDefaults, ZGWindowStyleThemeKey)
				let theme = Self.styleTheme(defaultTheme: defaultTheme, effectiveAppearance: application.effectiveAppearance)
				
				self.updateEditorStyle(WindowStyle.withTheme(theme))
			}
		}
		
		commitLabelTextField.stringValue = projectNameDisplay
		
		// Give a little vertical padding between the text and the top of the text view container
		let textContainerInset = textView.textContainerInset
		textView.textContainerInset = NSMakeSize(textContainerInset.width, textContainerInset.height + 2)
		
		if let window = window {
			window.titlebarAppearsTransparent = true
			
			// Hide the window titlebar buttons
			// We still want the resize functionality to work even though the button is hidden
			window.standardWindowButton(.closeButton)?.isHidden = true
			window.standardWindowButton(.miniaturizeButton)?.isHidden = true
			window.standardWindowButton(.zoomButton)?.isHidden = true
			
			if #available(macOS 13, *) {
				// Make window join stage manager spaces
				window.collectionBehavior = .canJoinAllApplications;
			}
		}
		
		// Nobody ever wants these;
		// Because macOS may have some of these settings globally in System Preferences, I don't trust IB very much..
		textView.isAutomaticDashSubstitutionEnabled = false
		textView.isAutomaticQuoteSubstitutionEnabled = false
		
		// Set textview delegates
		textView.textStorage?.delegate = self
		
		textView.delegate = self
		textView.zgCommitViewDelegate = self
		
		let plainAttributedString = NSMutableAttributedString(string: initialPlainText)
		
		// I don't think we want to invoke beginEditing/endEditing, etc, events because we are setting the textview content for the first time,
		// and we don't want anything to register as user-editable yet or have undo activated yet
		textView.textStorage?.replaceCharacters(in: NSMakeRange(0, 0), with: plainAttributedString)
		
		updateTextViewDrawingBackground()
		
		// If we have a non-version controlled file, point selection at start of content
		// Otherwise if we're resuming a canceled commit message, select all the contents
		// Otherwise point the selection at the end of the message contents
		let versionControlledFile = userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey)
		if !versionControlledFile {
			textView.setSelectedRange(NSMakeRange(0, 0))
		} else {
			if resumedFromSavedCommit {
				textView.setSelectedRange(convertToUTF16Range(range: initialCommitTextRange, in: initialPlainText))
			} else {
				textView.setSelectedRange(convertToUTF16Range(range: initialCommitTextRange.upperBound ..< initialCommitTextRange.upperBound, in: initialPlainText))
			}
		}
		
		// If this is a squash, just turn off spell checking and automatic spell correction as it's more likely to annoy the user
		// Make sure to disable this after setting the text storage content because spell checking detection
		// depends on that being initially set
		if isSquashMessage && userDefaults.bool(forKey: ZGDisableSpellCheckingAndCorrectionForSquashesKey) {
			textView.zgDisableContinuousSpellingAndAutomaticSpellingCorrection()
		} else {
			textView.zgLoadDefaults()
		}
		
		breadcrumbs?.spellChecking = textView.isContinuousSpellCheckingEnabled
		
		func showBranchName() {
			let toolName: String
			let toolArguments: [String]
			
			switch versionControlType {
			case .git:
				toolName = "git"
				toolArguments = ["rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD"]
			case .hg:
				toolName = "hg"
				toolArguments = ["branch"]
			case .svn:
				return
			}
			
			if let toolURL = ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":").map({ parentDirectoryPath -> URL in
				return URL(fileURLWithPath: parentDirectoryPath).appendingPathComponent(toolName)
			}).first(where: { toolURL -> Bool in
				let reachable = try? toolURL.checkResourceIsReachable()
				return reachable ?? false
			}) {
				DispatchQueue.global(qos: .userInteractive).async {
					let process = Process()
					process.executableURL = toolURL
					process.arguments = toolArguments
					
					let pipe = Pipe()
					process.standardOutput = pipe
					
					do {
						try process.run()
						process.waitUntilExit()
						
						if process.terminationStatus == EXIT_SUCCESS {
							let data = pipe.fileHandleForReading.readDataToEndOfFile()
							if let branchName = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), branchName.count > 0 {
								DispatchQueue.main.async {
									let projectNameWithBranch = "\(self.projectNameDisplay) (\(branchName))"
									self.commitLabelTextField.stringValue = projectNameWithBranch
								}
							}
						}
					} catch {
						print("Failed to retrieve branch name: \(error)")
					}
				}
			}
		}
		
		// Show branch name if available
		if !tutorialMode && versionControlledFile {
			showBranchName()
		}
	}
	
	// MARK: Actions
	
	private func exit(status: Int32) -> Never {
		if breadcrumbs != nil {
			func retrieveLineRanges(plainText: String) -> [Range<String.Index>] {
				var lineRanges: [Range<String.Index>] = []
				var characterIndex = plainText.startIndex
				while characterIndex < plainText.endIndex {
					var lineStartIndex = String.Index(utf16Offset: 0, in: plainText)
					var lineEndIndex = String.Index(utf16Offset: 0, in: plainText)
					var contentEndIndex = String.Index(utf16Offset: 0, in: plainText)
					
					plainText.getLineStart(&lineStartIndex, end: &lineEndIndex, contentsEnd: &contentEndIndex, for: characterIndex ..< characterIndex)
					
					let lineRange = (lineStartIndex ..< contentEndIndex)
					lineRanges.append(lineRange)
					
					characterIndex = lineEndIndex
				}
				return lineRanges
			}
			
			// Update breadcrumbs
			if let textContentStorage = textView.textContentStorage {
				let currentText = currentPlainText()
				let contentLineRanges = retrieveLineRanges(plainText: currentText)
				
				for contentLineRange in contentLineRanges {
					let utf16Range = convertToUTF16Range(range: contentLineRange, in: currentText)
					let _ = newTextParagraph(textContentStorage, range: utf16Range, updateBreadcrumbs: true)
				}
			}
		}
		
		if var breadcrumbs = breadcrumbs, let breadcrumbsPath = ProcessInfo.processInfo.environment[ZGBreadcrumbsURLKey] {
			let breadcrumbsURL = URL(fileURLWithPath: breadcrumbsPath, isDirectory: false)
			breadcrumbs.exitStatus = status
			
			do {
				let jsonData = try JSONEncoder().encode(breadcrumbs)
				try jsonData.write(to: breadcrumbsURL, options: .atomic)
			} catch {
				print("Failed to save breadcrumbs: \(error)")
			}
		}
		
		Darwin.exit(status)
	}
	
	func exit(success: Bool) -> Never {
		self.window?.saveFrame(usingName: ZGEditorWindowFrameNameKey)
		
		let fileManager = FileManager.default
		if let temporaryDirectoryURL = temporaryDirectoryURL {
			let _ = try? fileManager.removeItem(at: temporaryDirectoryURL)
		}
		
		if success {
			// We should have wrote to the commit file successfully
			exit(status: EXIT_SUCCESS)
		} else {
			let userDefaults = UserDefaults.standard
			let versionControlledFile = userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey)
			if !versionControlledFile || !initiallyContainedEmptyContent {
				// If we aren't dealing with a version controlled file or are amending an existing commit for example, we should fail and not create another change
				exit(status: EXIT_FAILURE)
			} else {
				// If we initially had no content and wrote an incomplete commit message,
				// then save the commit message in case we may want to resume from it later
				if userDefaults.bool(forKey: ZGResumeIncompleteSessionKey) {
					let plainText = currentPlainText()
					let commitRange = Self.commitTextRange(plainText: plainText, commentLength: commentSectionLength)
					
					let content = plainText[commitRange.lowerBound ..< commitRange.upperBound]
					let trimmedContent = content.trimmingCharacters(in: .newlines)
					if trimmedContent.count > 0 {
						do {
							let applicationSupportURL = try fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
							
							let supportDirectory = applicationSupportURL.appendingPathComponent(APP_SUPPORT_DIRECTORY_NAME)
							
							try fileManager.createDirectory(at: supportDirectory, withIntermediateDirectories: true, attributes: nil)
							
							let lastCommitURL = supportDirectory.appendingPathComponent(projectNameDisplay)
							
							try trimmedContent.write(to: lastCommitURL, atomically: true, encoding: .utf8)
						} catch {
							print("Failed to save incomplete commit: \(error)")
						}
					}
				}
				
				// Empty commits should be treated as a success
				// Version control software will be able to handle it as an abort
				exit(status: EXIT_SUCCESS)
			}
		}
	}
	
	@IBAction @objc func commit(_ sender: Any?) {
		let plainText = currentPlainText()
		
		do {
			try plainText.write(to: fileURL, atomically: true, encoding: .utf8)
			exit(success: true)
		} catch {
			print("Failed to write file for commit: \(error)")
			
			if let window = self.window {
				let alert = NSAlert(error: error)
				alert.alertStyle = .critical
				alert.beginSheetModal(for: window) { response in
				}
			}
		}
	}
	
	@IBAction @objc func cancelCommit(_ sender: Any?) {
		exit(success: false)
	}
	
	@IBAction @objc func changeEditorTheme(_ sender: Any) {
		guard let menuItem = sender as? NSMenuItem else {
			return
		}
		
		guard let newDefaultTheme = WindowStyleDefaultTheme(tag: menuItem.tag) else {
			print("Unsupported theme with tag: \(menuItem.tag)")
			return
		}
		
		let userDefaults = UserDefaults.standard
		let currentDefaultTheme = ZGReadDefaultWindowStyleTheme(userDefaults, ZGWindowStyleThemeKey)
		if newDefaultTheme != currentDefaultTheme {
			ZGWriteDefaultStyleTheme(userDefaults, ZGWindowStyleThemeKey, newDefaultTheme)
			let newTheme = Self.styleTheme(defaultTheme: newDefaultTheme, effectiveAppearance: NSApp.effectiveAppearance)
			
			updateEditorStyle(WindowStyle.withTheme(newTheme))
		}
	}
	
	@IBAction @objc func changeVibrancy(_ sender: Any) {
		let userDefaults = UserDefaults.standard
		let vibrancy = userDefaults.bool(forKey: ZGWindowVibrancyKey)
		userDefaults.set(!vibrancy, forKey: ZGWindowVibrancyKey)
		
		updateCurrentStyle()
	}
	
	@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
		switch menuItem.action {
		case #selector(changeEditorTheme(_:)):
			let currentDefaultTheme = ZGReadDefaultWindowStyleTheme(UserDefaults.standard, ZGWindowStyleThemeKey)
			menuItem.state = (menuItem.tag == currentDefaultTheme.tag) ? .on : .off
			break
		case #selector(changeVibrancy(_:)):
			menuItem.state = UserDefaults.standard.bool(forKey: ZGWindowVibrancyKey) ? .on : .off
			break
		default:
			break
		}
		return true
	}
	
	// MARK: Text View Delegates
	
	@objc func textView(_ textView: NSTextView, shouldChangeTextInRanges affectedRanges: [NSValue], replacementStrings: [String]?) -> Bool {
		let commentRange = commentUTF16Range(plainText: currentPlainText())
		
		// Don't allow editing the comment section
		// Make sure to also check we have a comment section, otherwise we would be
		// not allowing to insert text at the end of the document for no reason
		if commentRange.length > 0 {
			for rangeValue in affectedRanges {
				let range = rangeValue.rangeValue
				if range.location + range.length >= commentRange.location {
					return false
				}
			}
		}
		
		return true
	}
	
	private func newTextParagraph(_ textContentStorage: NSTextContentStorage, range: NSRange, updateBreadcrumbs: Bool) -> NSTextParagraph? {
		guard let originalText = textContentStorage.textStorage?.attributedSubstring(from: range) else {
			return nil
		}
		
		let originalTextString = originalText.string
		
		let commentRange: NSRange
		do {
			let plainText = currentPlainText()
			commentRange = commentUTF16Range(plainText: plainText)
		}
		
		let paragraphWithDisplayAttributes: NSTextParagraph?
		let isCommentSection = (range.location >= commentRange.location)
		let isCommentLine = Self.isCommentLine(originalTextString, versionControlType: commentVersionControlType)
		let hasSingleCommentLineMarker = Self.hasSingleCommentLineMarker(versionControlType: commentVersionControlType)
		// For svn we want to test isCommentSection
		// For git, we want to test isCommentLine. Scissored content may be in the comment section but
		// we don't want to format those lines as comments
		let isCommentParagraph = isCommentLine || (hasSingleCommentLineMarker && isCommentSection)

		let userDefaults = UserDefaults.standard
		
		if !isCommentParagraph {
			let contentFont = ZGReadDefaultFont(userDefaults, ZGMessageFontNameKey, ZGMessageFontPointSizeKey)
			
			let displayAttributes: [NSAttributedString.Key: AnyObject] = [.font: contentFont, .foregroundColor: style.textColor]
			
			let textWithDisplayAttributes = NSMutableAttributedString(attributedString: originalText)
			
			let fullTextRange = NSMakeRange(0, range.length)
			textWithDisplayAttributes.addAttributes(displayAttributes, range: fullTextRange)
			
			let versionControlledFile = userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey)
			
			if versionControlledFile {
				if commentVersionControlType == .git && isCommentSection {
					// Handle highlighting diffs
					
					let diffAttributeKey = style.diffHighlightsBackground ? NSAttributedString.Key.backgroundColor : NSAttributedString.Key.foregroundColor
					
					let diffAttributeColor: NSColor?
					
					// https://git-scm.com/docs/git-diff-index documents the possible header line prefixes
					if originalTextString.hasPrefix("@@") ||
						originalTextString.hasPrefix("+++") ||
						originalTextString.hasPrefix("---") ||
						originalTextString.hasPrefix("diff ") ||
						(originalTextString.hasPrefix("index ") && originalTextString.contains("..")) ||
						originalTextString.hasPrefix("deleted file mode") ||
						originalTextString.hasPrefix("new file mode") ||
						originalTextString.hasPrefix("copy from") ||
						originalTextString.hasPrefix("copy to") ||
						originalTextString.hasPrefix("rename from") ||
						originalTextString.hasPrefix("rename to") ||
						originalTextString.hasPrefix("similarity index") ||
						originalTextString.hasPrefix("dissimilarity index") ||
						originalTextString.hasPrefix("old mode") ||
						originalTextString.hasPrefix("new mode") {
						diffAttributeColor = style.diffHeaderColor
						
						if updateBreadcrumbs && breadcrumbs != nil {
							breadcrumbs!.diffHeaderLineRanges.append(fullTextRange.location ..< NSMaxRange(fullTextRange))
						}
					} else if originalTextString.hasPrefix("+") {
						diffAttributeColor = style.diffAddColor
						
						if updateBreadcrumbs && breadcrumbs != nil {
							breadcrumbs!.diffAddLineRanges.append(fullTextRange.location ..< NSMaxRange(fullTextRange))
						}
					} else if originalTextString.hasPrefix("-") {
						diffAttributeColor = style.diffRemoveColor
						
						if updateBreadcrumbs && breadcrumbs != nil {
							breadcrumbs!.diffRemoveLineRanges.append(fullTextRange.location ..< NSMaxRange(fullTextRange))
						}
					} else {
						diffAttributeColor = nil
					}
					
					if let diffAttributeColor {
						let diffAttributes: [NSAttributedString.Key : AnyObject] = [.font: contentFont, diffAttributeKey: diffAttributeColor]
						
						textWithDisplayAttributes.addAttributes(diffAttributes, range: fullTextRange)
					}
				} else if !isSquashMessage {
					// Render text overflow highlights
					
					let lengthLimit: Int?
					if range.location == 0 {
						lengthLimit = Self.lengthLimitWarningEnabled(userDefaults: userDefaults, userDefaultKey: ZGEditorRecommendedSubjectLengthLimitEnabledKey) ? ZGReadDefaultLineLimit(userDefaults, ZGEditorRecommendedSubjectLengthLimitKey) : nil
					} else {
						lengthLimit = Self.lengthLimitWarningEnabled(userDefaults: userDefaults, userDefaultKey: ZGEditorRecommendedBodyLineLengthLimitEnabledKey) ? ZGReadDefaultLineLimit(userDefaults, ZGEditorRecommendedBodyLineLengthLimitKey) : nil
					}
					
					if let lengthLimit = lengthLimit {
						let distance = originalTextString.distance(from: originalTextString.startIndex, to: originalTextString.endIndex)
						
						if distance > lengthLimit {
							let overflowRange = originalTextString.index(originalTextString.startIndex, offsetBy: lengthLimit) ..< originalTextString.endIndex
							
							let overflowUtf16Range = convertToUTF16Range(range: overflowRange, in: originalTextString)
							
							let overflowAttributes: [NSAttributedString.Key: AnyObject] = [.font: contentFont, .backgroundColor: style.overflowColor]
							
							textWithDisplayAttributes.addAttributes(overflowAttributes, range: overflowUtf16Range)
							
							if updateBreadcrumbs && breadcrumbs != nil {
								breadcrumbs!.textOverflowRanges.append(range.location + overflowUtf16Range.location ..< range.location + NSMaxRange(overflowUtf16Range))
							}
						}
					}
				}
			}
			
			paragraphWithDisplayAttributes = NSTextParagraph(attributedString: textWithDisplayAttributes)
		} else {
			let commentFont = ZGReadDefaultFont(userDefaults, ZGCommentsFontNameKey, ZGCommentsFontPointSizeKey)
			
			let displayAttributes: [NSAttributedString.Key: AnyObject] = [.font: commentFont, .foregroundColor: style.commentColor]
			
			let textWithDisplayAttributes = NSMutableAttributedString(attributedString: originalText)
			
			textWithDisplayAttributes.addAttributes(displayAttributes, range: NSMakeRange(0, range.length))
			
			if updateBreadcrumbs && !isCommentSection && breadcrumbs != nil {
				breadcrumbs!.commentLineRanges.append(range.location ..< NSMaxRange(range))
			}
			
			paragraphWithDisplayAttributes = NSTextParagraph(attributedString: textWithDisplayAttributes)
		}
		
		return paragraphWithDisplayAttributes
	}
	
	func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? {
		return newTextParagraph(textContentStorage, range: range, updateBreadcrumbs: false)
	}
	
	@objc func textView(_ textView: NSTextView, shouldSetSpellingState value: Int, range affectedCharRange: NSRange) -> Int {
		let plainText = currentPlainText()
		
		let commentRange = commentUTF16Range(plainText: plainText)
		
		// Check if affected character range is in the comment section (which includes scissored content)
		if affectedCharRange.location >= commentRange.location {
			return 0
		}
		
		guard let affectedRange = Range(affectedCharRange, in: plainText) else {
			return value
		}
		
		var lineStartIndex = String.Index(utf16Offset: 0, in: plainText)
		var lineEndIndex = String.Index(utf16Offset: 0, in: plainText)
		var contentEndIndex = String.Index(utf16Offset: 0, in: plainText)
		
		plainText.getLineStart(&lineStartIndex, end: &lineEndIndex, contentsEnd: &contentEndIndex, for: affectedRange)
		let line = String(plainText[lineStartIndex ..< contentEndIndex])
		
		return Self.isCommentLine(line, versionControlType: commentVersionControlType) ? 0 : value
	}
	
	@objc func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
		// After the user enters a new line in the first line, we want to insert another newline due to commit'ing conventions
		
		guard commandSelector == #selector(insertNewline(_:)) else {
			return false
		}
		
		// Bail if automatic newline insertion is disabled or if we are dealing with a squash message
		let userDefaults = UserDefaults.standard
		guard userDefaults.bool(forKey: ZGAssumeVersionControlledFileKey) && userDefaults.bool(forKey: ZGEditorAutomaticNewlineInsertionAfterSubjectKey) && (!userDefaults.bool(forKey: ZGDisableAutomaticNewlineInsertionAfterSubjectLineForSquashesKey) || !isSquashMessage) else {
			return false
		}
		
		// We will have some prevention if the user performs a new line more than once consecutively
		guard !preventAccidentalNewline else {
			return true
		}
		
		let selectedUTF16Ranges = textView.selectedRanges.map({ $0.rangeValue })
		guard selectedUTF16Ranges.count == 1 else {
			return false
		}
		
		let utf16Range = selectedUTF16Ranges[0]
		
		do {
			let plainText = currentPlainText()
			guard let range = Range(utf16Range, in: plainText) else {
				return false
			}
			
			var lineStartIndex = String.Index(utf16Offset: 0, in: plainText)
			var lineEndIndex = String.Index(utf16Offset: 0, in: plainText)
			var contentEndIndex = String.Index(utf16Offset: 0, in: plainText)
			
			plainText.getLineStart(&lineStartIndex, end: &lineEndIndex, contentsEnd: &contentEndIndex, for: range)
			
			// We must be at the first (subject) line and there must be some content
			guard lineStartIndex == plainText.startIndex, contentEndIndex > lineStartIndex else {
				return false
			}
			
			// Line must be at beginning of comment section or must be newline character
			let utf16View = plainText.utf16
			guard lineEndIndex == commentSectionIndex(plainUTF16Text: utf16View) || plainText[lineEndIndex].isNewline else {
				return false
			}
		}
		
		let replacement = "\n\n"
		guard textView.shouldChangeText(in: utf16Range, replacementString: replacement), let textStorage = textView.textStorage else {
			return false
		}
		
		// We need to invoke these methods to get proper undo support
		// http://lists.apple.com/archives/cocoa-dev/2004/Jan/msg01925.html
		
		textStorage.beginEditing()
		
		textStorage.replaceCharacters(in: utf16Range, with: replacement)
		
		textStorage.endEditing()
		textView.didChangeText()
		
		preventAccidentalNewline = true
		
		DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
			self.preventAccidentalNewline = false
		}
		
		return true
	}
	
	// MARK: ZGCommitViewDelegate
	
	@objc func userDefaultsChangedMessageFont() {
		reloadTextAttributes()
	}
	
	@objc func userDefaultsChangedCommentsFont() {
		reloadTextAttributes()
	}
	
	@objc func userDefaultsChangedRecommendedLineLengthLimits() {
		reloadTextAttributes()
	}
	
	@objc func userDefaultsChangedVibrancy() {
		updateCurrentStyle()
	}
	
	func zgCommitViewSelectAll() {
		let plainText = currentPlainText()
		if commentSectionLength > 0 {
			// Select only the commit text range
			let commitRange = Self.commitTextRange(plainText: plainText, commentLength: commentSectionLength)
			textView.setSelectedRange(convertToUTF16Range(range: commitRange, in: plainText))
		} else {
			// Select everything
			textView.setSelectedRange(NSMakeRange(0, plainText.utf16.count))
		}
	}
	
	@objc func zgCommitViewTouchCommit(_ sender: Any) {
		commit(nil)
	}
	
	@objc func zgCommitViewTouchCancel(_ sender: Any) {
		cancelCommit(nil)
	}
}
