//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient
import SignalServiceKit
import SignalRingRTC
import SignalUI
import UIKit

@objc
protocol CallHeaderDelegate: AnyObject {
    func didTapBackButton()
    func didTapMembersButton()
}

class CallHeader: UIView {
    // MARK: - Views

    private lazy var dateFormatter: DateFormatter = {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH:mm:ss"
        dateFormatter.timeZone = TimeZone(identifier: "UTC")!
        dateFormatter.locale = Locale(identifier: "en_US")
        return dateFormatter
    }()

    private var callDurationTimer: Timer?

    private lazy var gradientView: UIView = {
        let gradientLayer = CAGradientLayer()
        gradientLayer.colors = [
            UIColor.ows_blackAlpha60.cgColor,
            UIColor.black.withAlphaComponent(0).cgColor
        ]
        let view = OWSLayerView(frame: .zero) { view in
            gradientLayer.frame = view.bounds
        }
        view.layer.addSublayer(gradientLayer)
        return view
    }()

    private var avatarView: UIView?

    private let callTitleLabel = MarqueeLabel()
    private let callStatusLabel = UILabel()
    private lazy var groupMembersButton = GroupMembersButton()

    private let groupCall: GroupCall
    private let ringRtcCall: SignalRingRTC.GroupCall
    private weak var delegate: CallHeaderDelegate!

    init(groupCall: GroupCall, delegate: CallHeaderDelegate) {
        self.groupCall = groupCall
        self.ringRtcCall = groupCall.ringRtcCall
        self.delegate = delegate
        super.init(frame: .zero)

        addSubview(gradientView)
        gradientView.autoPinEdgesToSuperviewEdges()

        // Back button

        let backButton = UIButton()
        backButton.setTemplateImage(UIImage(imageLiteralResourceName: "NavBarBack"), tintColor: .ows_white)
        backButton.autoSetDimensions(to: CGSize(square: 40))
        backButton.imageEdgeInsets = UIEdgeInsets(top: -12, leading: -18, bottom: 0, trailing: 0)
        backButton.addTarget(delegate, action: #selector(CallHeaderDelegate.didTapBackButton), for: .touchUpInside)
        addShadow(to: backButton)

        addSubview(backButton)
        backButton.autoPinLeadingToSuperviewMargin(withInset: 8)
        backButton.autoPinTopToSuperviewMargin()

        // Group members button

        let topRightButton: UIButton
        switch groupCall.concreteType {
        case .groupThread:
            groupMembersButton.addTarget(
                delegate,
                action: #selector(CallHeaderDelegate.didTapMembersButton),
                for: .touchUpInside
            )
            topRightButton = groupMembersButton
        case .callLink:
            // [CallLink] TODO: Add support for the Info button.
            topRightButton = UIButton(type: .infoLight)
        }

        addShadow(to: topRightButton)

        addSubview(topRightButton)
        topRightButton.autoPinTrailingToSuperviewMargin(withInset: 8)
        topRightButton.autoPinTopToSuperviewMargin()

        // vStack

        let vStack = UIStackView()
        vStack.axis = .vertical
        vStack.alignment = .center
        vStack.spacing = 8
        vStack.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 0)

        // This view doesn't contain any interactable content, put it as low as possible.
        insertSubview(vStack, aboveSubview: gradientView)
        vStack.autoPinEdgesToSuperviewMargins()

        // Avatar
        switch groupCall.concreteType {
        case .groupThread(let groupThreadCall):
            let avatarView = ConversationAvatarView(
                sizeClass: .customDiameter(96),
                localUserDisplayMode: .asLocalUser,
                badged: false
            )
            avatarView.updateWithSneakyTransactionIfNecessary {
                $0.dataSource = .thread(groupThreadCall.groupThread)
            }
            let avatarPaddingView = UIView()
            avatarPaddingView.addSubview(avatarView)
            avatarView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top)

            vStack.addArrangedSubview(avatarPaddingView)
            vStack.setCustomSpacing(16, after: avatarPaddingView)
            self.avatarView = avatarView
        case .callLink:
            break
        }

        // Name Label

        callTitleLabel.type = .continuous
        // This feels pretty slow when you're initially waiting for it, but when you're overlaying video calls, anything faster is distracting.
        callTitleLabel.speed = .duration(30.0)
        callTitleLabel.animationCurve = .linear
        callTitleLabel.fadeLength = 10.0
        callTitleLabel.animationDelay = 5
        // Add trailing space after the name scrolls before it wraps around and scrolls back in.
        callTitleLabel.trailingBuffer = .scaleFromIPhone5(80)

        callTitleLabel.font = UIFont.dynamicTypeHeadline.semibold()
        callTitleLabel.textAlignment = .center
        callTitleLabel.textColor = UIColor.white
        addShadow(to: callTitleLabel)

#if TESTABLE_BUILD
        // For debugging purposes, make it easy to force the call to disconnect.
        callTitleLabel.addGestureRecognizer(
            UILongPressGestureRecognizer(target: self, action: #selector(injectDisconnect))
        )
        callTitleLabel.isUserInteractionEnabled = true
#endif

        vStack.addArrangedSubview(callTitleLabel)

        // Make the title view as wide as possible, but don't overlap either button.
        // This gets combined with the vStack's centered alignment, so we won't ever get an unbalanced title label.
        NSLayoutConstraint.autoSetPriority(.defaultHigh) {
            callTitleLabel.autoPinWidthToSuperview()
        }
        callTitleLabel.autoPinEdge(.leading, to: .trailing, of: backButton, withOffset: 13, relation: .greaterThanOrEqual)
        callTitleLabel.autoPinEdge(.trailing, to: .leading, of: topRightButton, withOffset: -13, relation: .lessThanOrEqual)

        // Status label

        callStatusLabel.font = UIFont.dynamicTypeFootnote.monospaced()
        callStatusLabel.textAlignment = .center
        callStatusLabel.textColor = UIColor.white
        callStatusLabel.numberOfLines = 0
        // Cut off the status lines before cutting off anything else.
        callStatusLabel.setContentCompressionResistancePriority(.defaultLow + 1, for: .vertical)
        // But always fit at least one line, with moderate descenders.
        callStatusLabel.autoSetDimension(.height, toSize: callStatusLabel.font.lineHeight * 1.2, relation: .greaterThanOrEqual)
        addShadow(to: callStatusLabel)

        vStack.addArrangedSubview(callStatusLabel)

        groupCall.addObserverAndSyncState(self)
    }

    override func didMoveToSuperview() {
        guard let superview = self.superview else {
            return
        }
        // The bottom of the avatar must be no more than 25% down the screen.
        // This constraint is on the avatar view container rather than the full view
        // because the label may change its number of lines,
        // and we don't want that to affect the vertical position.
        avatarView?.autoMatch(.height, to: .height, of: superview, withMultiplier: 0.25)
    }

    private func addShadow(to view: UIView) {
        view.layer.shadowOffset = .zero
        view.layer.shadowOpacity = 0.25
        view.layer.shadowRadius = 4
    }

    private func describeMembers(
        count: Int,
        names: [String],
        zeroMemberString: @autoclosure () -> String,
        oneMemberFormat: @autoclosure () -> String,
        twoMemberFormat: @autoclosure () -> String,
        manyMemberFormat: @autoclosure () -> String
    ) -> String {
        switch count {
        case 0:
            return zeroMemberString()
        case 1:
            return String(format: oneMemberFormat(), names[0])
        case 2:
            return String(format: twoMemberFormat(), names[0], names[1])
        default:
            return String.localizedStringWithFormat(manyMemberFormat(), count - 2, names[0], names[1])
        }
    }

    private func fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: GroupThreadCall) -> (Int, [String]) {
        let groupThread = groupThreadCall.groupThread
        return databaseStorage.read { transaction in
            // FIXME: Register for notifications so we can update if someone leaves the group while the screen is up?
            let firstTwoNames = groupThread.sortedMemberNames(
                includingBlocked: false,
                limit: 2,
                useShortNameIfAvailable: true,
                transaction: transaction
            )
            if firstTwoNames.count < 2 {
                return (firstTwoNames.count, firstTwoNames)
            }

            let count = groupThread.groupMembership.fullMembers.lazy.filter {
                !$0.isLocalAddress && !self.blockingManager.isAddressBlocked($0, transaction: transaction)
            }.count
            return (count, firstTwoNames)
        }
    }

    private func updateCallStatusLabel() {
        callStatusLabel.text = self.callStatusLabelText()
    }

    private func callStatusLabelText() -> String {
        switch ringRtcCall.localDeviceState.joinState {
        case .notJoined, .joining:
            switch groupCall.concreteType {
            case .groupThread(let groupThreadCall):
                if case .incomingRing(let caller, _) = groupThreadCall.groupCallRingState {
                    return incomingRingText(caller: caller)
                }
                if let joinedMembers = ringRtcCall.peekInfo?.joinedMembers, !joinedMembers.isEmpty {
                    return whoIsHereText(joinedMembers: joinedMembers)
                }
                if ringRtcCall.peekInfo == nil, groupThreadCall.ringRestrictions.contains(.callInProgress) {
                    // If we think there might already be a call, don't show anything until we have proper peek info.
                    return ""
                }
                if groupThreadCall.ringRestrictions.isEmpty, case .shouldRing = groupThreadCall.groupCallRingState {
                    return willRingOthersText(groupThreadCall: groupThreadCall)
                } else {
                    return willNotifyOthersText(groupThreadCall: groupThreadCall)
                }
            case .callLink:
                return whoIsHereText(joinedMembers: ringRtcCall.peekInfo?.joinedMembers ?? [])
            }
        case .pending:
            return OWSLocalizedString(
                "CALL_WAITING_TO_BE_LET_IN",
                comment: "Shown in the header below the name of the call while waiting for the host to allow you to enter the call."
            )
        case .joined:
            if ringRtcCall.localDeviceState.connectionState == .reconnecting {
                return OWSLocalizedString(
                    "GROUP_CALL_RECONNECTING",
                    comment: "Text indicating that the user has lost their connection to the call and we are reconnecting."
                )
            }
            switch groupCall.concreteType {
            case .groupThread(let groupThreadCall):
                if !ringRtcCall.remoteDeviceStates.isEmpty {
                    return callDurationText()
                }
                if case .ringing = groupThreadCall.groupCallRingState {
                    return ringingOthersText(groupThreadCall: groupThreadCall)
                }
            case .callLink:
                guard let peekInfo = ringRtcCall.peekInfo else {
                    return ""
                }
                if !peekInfo.pendingUsers.isEmpty {
                    return howManyAreWaitingText(count: peekInfo.pendingUsers.count)
                }
                if peekInfo.deviceCountExcludingPendingDevices > 0 {
                    return howManyAreHereText(count: Int(peekInfo.deviceCountExcludingPendingDevices))
                }
            }
            return noOneElseIsHereText()
        }
    }

    private func incomingRingText(caller: SignalServiceAddress) -> String {
        let callerName = databaseStorage.read { transaction in
            contactsManager.displayName(for: caller, tx: transaction).resolvedValue(useShortNameIfAvailable: true)
        }
        let formatString = OWSLocalizedString(
            "GROUP_CALL_INCOMING_RING_FORMAT",
            comment: "Text explaining that someone has sent a ring to the group. Embeds {ring sender name}"
        )
        return String(format: formatString, callerName)
    }

    private func whoIsHereText(joinedMembers: [UUID]) -> String {
        if joinedMembers.isEmpty {
            return noOneElseIsHereText()
        }
        let upToTwoKnownMemberNames: [String] = databaseStorage.read { tx -> [String] in
            joinedMembers
                .lazy
                .map { [contactsManager] in contactsManager.displayName(for: SignalServiceAddress(Aci(fromUUID: $0)), tx: tx) }
                .filter { $0.hasKnownValue }
                .prefix(2)
                .map { $0.resolvedValue(useShortNameIfAvailable: true) }
        }
        let noneOneOrBothKnownMemberNames: (String, String?)? = (
            upToTwoKnownMemberNames.first.map { ($0, upToTwoKnownMemberNames.dropFirst().first) }
        )
        let otherOrUnknownMemberCount = joinedMembers.count - upToTwoKnownMemberNames.count
        switch (noneOneOrBothKnownMemberNames, otherOrUnknownMemberCount) {
        case ((let someMember, nil)?, 0):
            // exactly one member, known
            let format = OWSLocalizedString(
                "GROUP_CALL_ONE_PERSON_HERE_FORMAT",
                comment: "Text explaining that there is one person in the group call. Embeds {member name}"
            )
            return String(format: format, someMember)
        case ((let someMember, let someOtherMember?)?, 0):
            // exactly two members, both known
            let format = OWSLocalizedString(
                "GROUP_CALL_TWO_PEOPLE_HERE_FORMAT",
                comment: "Text explaining that there are two people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2 }}"
            )
            return String(format: format, someMember, someOtherMember)
        case ((let someMember, let someOtherMember?)?, let otherCount):
            // two or more members, at least two known
            let format = OWSLocalizedString(
                "GROUP_CALL_MANY_PEOPLE_HERE_%d",
                tableName: "PluralAware",
                comment: "Text explaining that there are three or more people in the group call. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}"
            )
            return String.localizedStringWithFormat(format, otherCount, someMember, someOtherMember)
        case ((let someMember, nil)?, let unknownCount):
            // two or more members, only one known
            let format = OWSLocalizedString(
                "GROUP_CALL_ONE_KNOWN_AND_MANY_OTHERS_HERE",
                tableName: "PluralAware",
                comment: "Text explaining that there is at least one person whose name is known in the call as well as others whose names may or may not be known."
            )
            return String.localizedStringWithFormat(format, unknownCount, someMember)
        case (nil, let unknownCount):
            // no known members
            let format = OWSLocalizedString(
                "GROUP_CALL_MANY_OTHERS_HERE",
                tableName: "PluralAware",
                comment: "Text explaining that there are people in the call whose names we don't know. The argument is how many people are in the call."
            )
            return String.localizedStringWithFormat(format, unknownCount)
        }
    }

    private func willRingOthersText(groupThreadCall: GroupThreadCall) -> String {
        let (memberCount, firstTwoNames) = fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: groupThreadCall)
        return describeMembers(
            count: memberCount,
            names: firstTwoNames,
            zeroMemberString: "",
            oneMemberFormat: OWSLocalizedString(
                "GROUP_CALL_WILL_RING_ONE_PERSON_FORMAT",
                comment: "Text shown before the user starts a group call if the user has enabled ringing and there is one other person in the group. Embeds {member name}"
            ),
            twoMemberFormat: OWSLocalizedString(
                "GROUP_CALL_WILL_RING_TWO_PEOPLE_FORMAT",
                comment: "Text shown before the user starts a group call if the user has enabled ringing and there are two other people in the group. Embeds {{ %1$@ participant1, %2$@ participant2 }}"
            ),
            manyMemberFormat: OWSLocalizedString(
                "GROUP_CALL_WILL_RING_MANY_PEOPLE_%d",
                tableName: "PluralAware",
                comment: "Text shown before the user starts a group call if the user has enabled ringing and there are three or more other people in the group. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}"
            )
        )
    }

    private func willNotifyOthersText(groupThreadCall: GroupThreadCall) -> String {
        let (memberCount, firstTwoNames) = fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: groupThreadCall)
        return describeMembers(
            count: memberCount,
            names: firstTwoNames,
            zeroMemberString: "",
            oneMemberFormat: OWSLocalizedString(
                "GROUP_CALL_WILL_NOTIFY_ONE_PERSON_FORMAT",
                comment: "Text shown before the user starts a group call if the user has not enabled ringing and there is one other person in the group. Embeds {member name}"
            ),
            twoMemberFormat: OWSLocalizedString(
                "GROUP_CALL_WILL_NOTIFY_TWO_PEOPLE_FORMAT",
                comment: "Text shown before the user starts a group call if the user has not enabled ringing and there are two other people in the group. Embeds {{ %1$@ participant1, %2$@ participant2 }}"
            ),
            manyMemberFormat: OWSLocalizedString(
                "GROUP_CALL_WILL_NOTIFY_MANY_PEOPLE_%d",
                tableName: "PluralAware",
                comment: "Text shown before the user starts a group call if the user has not enabled ringing and there are three or more other people in the group. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}"
            )
        )
    }

    private func ringingOthersText(groupThreadCall: GroupThreadCall) -> String {
        let (memberCount, firstTwoNames) = fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: groupThreadCall)
        return describeMembers(
            count: memberCount,
            names: firstTwoNames,
            zeroMemberString: "",
            oneMemberFormat: OWSLocalizedString(
                "GROUP_CALL_IS_RINGING_ONE_PERSON_FORMAT",
                comment: "Text shown before the user starts a group call if the user has enabled ringing and there is one other person in the group. Embeds {member name}"
            ),
            twoMemberFormat: OWSLocalizedString(
                "GROUP_CALL_IS_RINGING_TWO_PEOPLE_FORMAT",
                comment: "Text shown before the user starts a group call if the user has enabled ringing and there are two other people in the group. Embeds {{ %1$@ participant1, %2$@ participant2 }}"
            ),
            manyMemberFormat: OWSLocalizedString(
                "GROUP_CALL_IS_RINGING_MANY_PEOPLE_%d",
                tableName: "PluralAware",
                comment: "Text shown before the user starts a group call if the user has enabled ringing and there are three or more other people in the group. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}"
            )
        )
    }

    private func callDurationText() -> String {
        let callDuration = groupCall.commonState.connectionDuration()
        let callDurationDate = Date(timeIntervalSinceReferenceDate: callDuration)
        var formattedDate = dateFormatter.string(from: callDurationDate)
        if formattedDate.hasPrefix("00:") {
            // Don't show the "hours" portion of the date format unless the
            // call duration is at least 1 hour.
            formattedDate = String(formattedDate.dropFirst(3))
        } else if formattedDate.hasPrefix("0") {
            // If showing the "hours" portion of the date format, strip any leading
            // zeroes.
            formattedDate = String(formattedDate.dropFirst(1))
        }
        return formattedDate
    }

    private func noOneElseIsHereText() -> String {
        return OWSLocalizedString(
            "GROUP_CALL_NO_ONE_HERE",
            comment: "Text explaining that you are the only person currently in the group call"
        )
    }

    private func howManyAreWaitingText(count: Int) -> String {
        let format = OWSLocalizedString(
            "CALL_PEOPLE_WAITING",
            tableName: "PluralAware",
            comment: "Text shown in the header of a call to indicate how many people have requested to join and need to be approved."
        )
        return String.localizedStringWithFormat(format, count)
    }

    private func howManyAreHereText(count: Int) -> String {
        let format = OWSLocalizedString(
            "CALL_PEOPLE_HERE",
            tableName: "PluralAware",
            comment: "Text shown in the header of a call to indicate how many people are present."
        )
        return String.localizedStringWithFormat(format, count)
    }

    private func updateCallTitleLabel() {
        callTitleLabel.text = self.callTitleLabelText()
    }

    private func callTitleLabelText() -> String {
        if
            ringRtcCall.localDeviceState.joinState == .joined,
            let firstMember = ringRtcCall.remoteDeviceStates.sortedBySpeakerTime.first,
            firstMember.presenting == true
        {
            let presentingName = databaseStorage.read { tx in
                contactsManager.displayName(for: SignalServiceAddress(Aci(fromUUID: firstMember.userId)), tx: tx).resolvedValue(useShortNameIfAvailable: true)
            }
            let formatString = OWSLocalizedString(
                "GROUP_CALL_PRESENTING_FORMAT",
                comment: "Text explaining that a member is presenting. Embeds {member name}"
            )
            return String(format: formatString, presentingName)
        }
        switch groupCall.concreteType {
        case .groupThread(let groupThreadCall):
            // FIXME: This should auto-update if the group name changes.
            return databaseStorage.read { transaction in
                contactsManager.displayName(for: groupThreadCall.groupThread, transaction: transaction)
            }
        case .callLink(let call):
            return call.localizedName
        }
    }

    private func updateGroupMembersButton() {
        guard case .groupThread = groupCall.concreteType else {
            return
        }
        let isJoined = ringRtcCall.localDeviceState.joinState == .joined
        let remoteMemberCount = isJoined ? ringRtcCall.remoteDeviceStates.count : Int(ringRtcCall.peekInfo?.deviceCountExcludingPendingDevices ?? 0)
        groupMembersButton.updateMemberCount(remoteMemberCount + (isJoined ? 1 : 0))
    }

    // For testing abnormal scenarios.
    @objc
    private func injectDisconnect() {
        ringRtcCall.disconnect()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension CallHeader: GroupCallObserver {
    func groupCallLocalDeviceStateChanged(_ call: GroupCall) {
        if case .groupThread = call.concreteType, call.joinState == .joined {
            if callDurationTimer == nil {
                let kDurationUpdateFrequencySeconds = 1 / 20.0
                callDurationTimer = WeakTimer.scheduledTimer(
                    timeInterval: TimeInterval(kDurationUpdateFrequencySeconds),
                    target: self,
                    userInfo: nil,
                    repeats: true
                ) {[weak self] _ in
                    self?.updateCallStatusLabel()
                }
            }
        } else {
            callDurationTimer?.invalidate()
            callDurationTimer = nil
        }

        if call.hasJoinedOrIsWaitingForAdminApproval {
            gradientView.isHidden = false
            avatarView?.isHidden = true // hide the container
        } else {
            gradientView.isHidden = true
            avatarView?.isHidden = false
        }

        updateCallStatusLabel()
        updateGroupMembersButton()
    }

    func groupCallPeekChanged(_ call: GroupCall) {
        updateCallStatusLabel()
        updateGroupMembersButton()
    }

    func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) {
        updateCallTitleLabel()
        updateCallStatusLabel()
        updateGroupMembersButton()
    }

    func groupCallEnded(_ call: GroupCall, reason: GroupCallEndReason) {
        callDurationTimer?.invalidate()
        callDurationTimer = nil

        updateCallStatusLabel()
        updateGroupMembersButton()
    }
}

private class GroupMembersButton: UIButton {
    private let iconImageView = UIImageView()
    private let countLabel = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)

        autoSetDimension(.height, toSize: 40)

        iconImageView.contentMode = .scaleAspectFit
        iconImageView.setTemplateImage(#imageLiteral(resourceName: "group-fill"), tintColor: .ows_white)
        addSubview(iconImageView)
        iconImageView.autoPinEdge(toSuperviewEdge: .leading)
        iconImageView.autoSetDimensions(to: CGSize(square: 22))
        iconImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 2)

        countLabel.font = UIFont.dynamicTypeFootnoteClamped.monospaced()
        countLabel.textColor = .ows_white
        addSubview(countLabel)
        countLabel.autoPinEdge(.leading, to: .trailing, of: iconImageView, withOffset: 5)
        countLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: 5)
        countLabel.autoAlignAxis(.horizontal, toSameAxisOf: iconImageView)
        countLabel.setContentHuggingHorizontalHigh()
        countLabel.setCompressionResistanceHorizontalHigh()
    }

    func updateMemberCount(_ count: Int) {
        countLabel.text = String(OWSFormat.formatInt(count))
        self.isHidden = count == 0
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var isHighlighted: Bool {
        didSet {
            alpha = isHighlighted ? 0.5 : 1
        }
    }
}
