//
//  MultilineTextFieldSizer.swift
//  NetNewsWire
//
//  Created by Brent Simmons on 2/19/18.
//  Copyright © 2018 Ranchero Software. All rights reserved.
//

import AppKit

// Get the height of an NSTextField given a string, font, and width.
// Uses a cache. Avoids actually measuring text as much as possible.
// Main thread only.

typealias WidthHeightCache = [Int: Int] // width: height

private struct TextFieldSizerSpecifier: Hashable {

	let numberOfLines: Int
	let font: NSFont
}

struct TextFieldSizeInfo {

	let size: NSSize // Integral size (ceiled)
	let numberOfLinesUsed: Int // A two-line text field may only use one line, for instance. This would equal 1, then.
}

@MainActor final class MultilineTextFieldSizer {

	private let numberOfLines: Int
	private let font: NSFont
	private let textField:NSTextField
	private let singleLineHeightEstimate: Int
	private let doubleLineHeightEstimate: Int
	private var cache = [String: WidthHeightCache]() // Each string has a cache.
	private var attributedCache = [NSAttributedString: WidthHeightCache]()
	@MainActor private static var sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()

	private init(numberOfLines: Int, font: NSFont) {

		self.numberOfLines = numberOfLines
		self.font = font
		self.textField = MultilineTextFieldSizer.createTextField(numberOfLines, font)

		self.singleLineHeightEstimate = MultilineTextFieldSizer.calculateHeight("AqLjJ0/y", 200, self.textField)
		self.doubleLineHeightEstimate = MultilineTextFieldSizer.calculateHeight("AqLjJ0/y\nAqLjJ0/y", 200, self.textField)
	}

	static func size(for string: String, font: NSFont, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {

		return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
	}

	static func size(for attributedString: NSAttributedString, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
		guard attributedString.length > 0 else {
			return TextFieldSizeInfo(size: NSSize.zero, numberOfLinesUsed: 0)
		}
		
		// Assumes the same font family/size for the whole string
		let font = attributedString.attribute(.font, at: 0, effectiveRange: nil) as! NSFont

		return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: attributedString, width: width)
	}

	static func emptyCache() {

		sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
	}
}

// MARK: - Private

private extension MultilineTextFieldSizer {

	static func sizer(numberOfLines: Int, font: NSFont) -> MultilineTextFieldSizer {

		let specifier = TextFieldSizerSpecifier(numberOfLines: numberOfLines, font: font)
		if let cachedSizer = sizers[specifier] {
			return cachedSizer
		}

		let newSizer = MultilineTextFieldSizer(numberOfLines: numberOfLines, font: font)
		sizers[specifier] = newSizer
		return newSizer
	}

	func sizeInfo(for string: String, width: Int) -> TextFieldSizeInfo {

		let textFieldHeight = height(for: string, width: width)
		let numberOfLinesUsed = numberOfLines(for: textFieldHeight)

		let size = NSSize(width: width, height: textFieldHeight)
		let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
		return sizeInfo
	}

	func sizeInfo(for attributedString: NSAttributedString, width: Int) -> TextFieldSizeInfo {

		let textFieldHeight = height(for: attributedString, width: width)
		let numberOfLinesUsed = numberOfLines(for: textFieldHeight)

		let size = NSSize(width: width, height: textFieldHeight)
		let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
		return sizeInfo
	}

	func height(for string: String, width: Int) -> Int {

		if cache[string] == nil {
			cache[string] = WidthHeightCache()
		}

		if let height = cache[string]![width] {
			return height
		}

		if let height = heightConsideringNeighbors(cache[string]!, width) {
			return height
		}

		let height = calculateHeight(string, width)
		cache[string]![width] = height

		return height
	}

	func height(for attribtuedString: NSAttributedString, width: Int) -> Int {

		if attributedCache[attribtuedString] == nil {
			attributedCache[attribtuedString] = WidthHeightCache()
		}

		if let height = attributedCache[attribtuedString]![width] {
			return height
		}

		if let height = heightConsideringNeighbors(attributedCache[attribtuedString]!, width) {
			return height
		}

		let height = calculateHeight(attribtuedString, width)
		attributedCache[attribtuedString]![width] = height

		return height
	}

	static func createTextField(_ numberOfLines: Int, _ font: NSFont) -> NSTextField {

		let textField = NSTextField(wrappingLabelWithString: "")
		textField.usesSingleLineMode = false
		textField.maximumNumberOfLines = numberOfLines
		textField.isEditable = false
		textField.font = font
		textField.allowsDefaultTighteningForTruncation = false

		return textField
	}

	func calculateHeight(_ string: String, _ width: Int) -> Int {

		return MultilineTextFieldSizer.calculateHeight(string, width, textField)
	}

	func calculateHeight(_ attributedString: NSAttributedString, _ width: Int) -> Int {

		textField.attributedStringValue = attributedString
		textField.preferredMaxLayoutWidth = CGFloat(width)
		let size = textField.fittingSize
		return Int(ceil(size.height))
	}

	static func calculateHeight(_ string: String, _ width: Int, _ textField: NSTextField) -> Int {

		textField.stringValue = string
		textField.preferredMaxLayoutWidth = CGFloat(width)
		let size = textField.fittingSize
		return Int(ceil(size.height))
	}

	func numberOfLines(for height: Int) -> Int {

		// We’ll have to see if this really works reliably.

		let averageHeight = CGFloat(doubleLineHeightEstimate) / 2.0
		let lines = Int(round(CGFloat(height) / averageHeight))
		return lines
	}

	func heightIsProbablySingleLineHeight(_ height: Int) -> Bool {

		return heightIsProbablyEqualToEstimate(height, singleLineHeightEstimate)
	}

	func heightIsProbablyDoubleLineHeight(_ height: Int) -> Bool {

		return heightIsProbablyEqualToEstimate(height, doubleLineHeightEstimate)
	}

	func heightIsProbablyEqualToEstimate(_ height: Int, _ estimate: Int) -> Bool {

		let slop = 4
		let minimum = estimate - slop
		let maximum = estimate + slop
		return height >= minimum && height <= maximum
	}

	func heightConsideringNeighbors(_ heightCache: WidthHeightCache, _ width: Int) -> Int? {

		// Given width, if the height at width - something and width + something is equal,
		// then that height must be correct for the given width.
		// Also:
		// If a narrower neighbor’s height is single line height, then this wider width must also be single-line height.
		// If a wider neighbor’s height is double line height, and numberOfLines == 2, then this narrower width must able be double-line height.

		var smallNeighbor = (width: 0, height: 0)
		var largeNeighbor = (width: 0, height: 0)

		for (oneWidth, oneHeight) in heightCache {

			if oneWidth < width && heightIsProbablySingleLineHeight(oneHeight) {
				return oneHeight
			}
			if numberOfLines == 2 && oneWidth > width && heightIsProbablyDoubleLineHeight(oneHeight) {
				return oneHeight
			}

			if oneWidth < width && (oneWidth > smallNeighbor.width || smallNeighbor.width == 0) {
				smallNeighbor = (oneWidth, oneHeight)
			}
			else if oneWidth > width && (oneWidth < largeNeighbor.width || largeNeighbor.width == 0) {
				largeNeighbor = (oneWidth, oneHeight)
			}

			if smallNeighbor.width != 0 && smallNeighbor.height == largeNeighbor.height {
				return smallNeighbor.height
			}
		}

		return nil
	}
}
