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

import XCTest
import SignalCoreKit
import SignalServiceKit
import SignalUI

private extension LinkPreviewFetcher.State {
    var isNone: Bool {
        if case .none = self {
            return true
        }
        return false
    }
    var isLoaded: Bool {
        if case .loaded = self {
            return true
        }
        return false
    }
    var isFailed: Bool {
        if case .failed = self {
            return true
        }
        return false
    }
    var isLoading: Bool {
        if case .loading = self {
            return true
        }
        return false
    }
}

class LinkPreviewFetcherTest: XCTestCase {

    private var mockLinkPreviewManager: MockLinkPreviewManager!
    private var testScheduler: TestScheduler!
    private var mockDB: MockDB!

    override func setUp() {
        super.setUp()

        mockDB = MockDB()
        mockLinkPreviewManager = MockLinkPreviewManager()
        testScheduler = TestScheduler()
        testScheduler.start()
    }

    func testUpdateLoaded() throws {
        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler)
        )

        // Non-URL text shouldn't issue any fetches.
        for textValue in ["a", "ab", "abc"] {
            linkPreviewFetcher.update(.init(text: textValue, ranges: .empty))
            XCTAssert(linkPreviewFetcher.currentState.isNone)
            XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
            XCTAssertNil(linkPreviewFetcher.currentUrl)
            XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])
        }

        // A valid URL should fetch only once, even if the text is modified.
        let validURL = try XCTUnwrap(URL(string: "https://signal.org"))
        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            XCTAssertEqual(fetchedURL, validURL)
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Website Title"))
        }
        for textValue in ["Check ou https://signal.org", "Check out https://signal.org"] {
            linkPreviewFetcher.update(.init(text: textValue, ranges: .empty))
            XCTAssert(linkPreviewFetcher.currentState.isLoaded)
            XCTAssertEqual(linkPreviewFetcher.linkPreviewDraftIfLoaded?.url, validURL)
            XCTAssertEqual(linkPreviewFetcher.currentUrl, validURL)
            XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [validURL])
        }

        // An invalid URL should fetch only once, even if the text is modified.
        let invalidURL = try XCTUnwrap(URL(string: "https://signal.org/not_found"))
        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            XCTAssertEqual(fetchedURL, invalidURL)
            return Promise(error: OWSGenericError("Not found."))
        }
        for textValue in ["Check ou https://signal.org/not_found", "Check out https://signal.org/not_found"] {
            linkPreviewFetcher.update(.init(text: textValue, ranges: .empty))
            XCTAssert(linkPreviewFetcher.currentState.isFailed)
            XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
            XCTAssertEqual(linkPreviewFetcher.currentUrl, invalidURL)
            XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [validURL, invalidURL])
        }

        // Removing the URL should clear the link preview.
        for textValue in ["Check out", "Check ou"] {
            linkPreviewFetcher.update(.init(text: textValue, ranges: .empty))
            XCTAssert(linkPreviewFetcher.currentState.isNone)
            XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
            XCTAssertNil(linkPreviewFetcher.currentUrl)
            XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [validURL, invalidURL])
        }
    }

    func testUpdateLoading() throws {
        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler)
        )

        let validURL = try XCTUnwrap(URL(string: "https://signal.org"))
        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Website Title"))
        }

        testScheduler.stop()

        linkPreviewFetcher.update(.init(text: "https://signal.org is a grea", ranges: .empty))
        XCTAssert(linkPreviewFetcher.currentState.isLoading)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [validURL])

        // If there's a request in flight, we shouldn't send a new request.
        linkPreviewFetcher.update(.init(text: "https://signal.org is a great", ranges: .empty))
        XCTAssert(linkPreviewFetcher.currentState.isLoading)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [validURL])

        testScheduler.start()

        XCTAssert(linkPreviewFetcher.currentState.isLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [validURL])
    }

    func testUpdateObsolete() throws {
        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler)
        )

        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Website Title"))
        }

        testScheduler.stop()

        let url1 = try XCTUnwrap(URL(string: "https://signal.org/one"))
        linkPreviewFetcher.update(.init(text: "https://signal.org/one", ranges: .empty))
        XCTAssert(linkPreviewFetcher.currentState.isLoading)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [url1])

        // If there's a request in flight & we change the URL, drop the original request.
        let url2 = try XCTUnwrap(URL(string: "https://signal.org/two"))
        linkPreviewFetcher.update(.init(text: "https://signal.org/two", ranges: .empty))
        XCTAssert(linkPreviewFetcher.currentState.isLoading)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [url1, url2])

        testScheduler.start()

        XCTAssert(linkPreviewFetcher.currentState.isLoaded)
        XCTAssertEqual(linkPreviewFetcher.linkPreviewDraftIfLoaded?.url, url2)
        XCTAssertEqual(linkPreviewFetcher.currentUrl, url2)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [url1, url2])
    }

    func testUpdatePrependScheme() {
        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler)
        )

        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Signal"))
        }
        linkPreviewFetcher.update(.init(text: "signal.org", ranges: .empty), prependSchemeIfNeeded: false)
        XCTAssert(linkPreviewFetcher.currentState.isNone)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // If we should prepend a scheme, prepend "https://".
        linkPreviewFetcher.update(.init(text: "signal.org", ranges: .empty), prependSchemeIfNeeded: true)
        XCTAssert(linkPreviewFetcher.currentState.isLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [URL(string: "https://signal.org")!])
        mockLinkPreviewManager.fetchedURLs.removeAll()

        // If there's already a scheme, we don't add "https://". (We require
        // "https://", so specify anything other scheme disables link previews.
        linkPreviewFetcher.update(.init(text: "http://signal.org", ranges: .empty), prependSchemeIfNeeded: true)
        XCTAssert(linkPreviewFetcher.currentState.isNone)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])
    }

    func testOnStateChange() throws {
        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler)
        )

        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Signal"))
        }

        var onStateChangeCount = 0
        linkPreviewFetcher.onStateChange = { onStateChangeCount += 1 }

        linkPreviewFetcher.update(.init(text: "", ranges: .empty))
        XCTAssertEqual(onStateChangeCount, 0)

        // Redundant updates generally don't result in state updates.
        linkPreviewFetcher.update(.init(text: "a", ranges: .empty))
        XCTAssertEqual(onStateChangeCount, 0)

        // Fetching a URL should update the state twice: to loading & to loaded.
        linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
        XCTAssertEqual(onStateChangeCount, 2)

        linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
        XCTAssertEqual(onStateChangeCount, 2)

        // Clearing the text should update the link preview.
        linkPreviewFetcher.update(.init(text: "", ranges: .empty))
        XCTAssertEqual(onStateChangeCount, 3)

        // Assigning the URL again should fetch it again.
        linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
        XCTAssertEqual(onStateChangeCount, 5)

        // Disabling the link preview should trigger an update.
        linkPreviewFetcher.disable()
        XCTAssertEqual(onStateChangeCount, 6)
    }

    func testDisable() throws {
        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler)
        )

        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Signal"))
        }

        // Fetch the original preview.
        let url = try XCTUnwrap(URL(string: "https://signal.org"))
        linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
        XCTAssertEqual(linkPreviewFetcher.linkPreviewDraftIfLoaded?.url, url)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [url])
        mockLinkPreviewManager.fetchedURLs.removeAll()

        // Dismiss the preview; make sure it goes away.
        linkPreviewFetcher.disable()
        XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // Assign the same URL again; make sure it doesn't come back.
        linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
        XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // Clear the URL; make sure it stays away.
        linkPreviewFetcher.update(.init(text: "", ranges: .empty))
        XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // Enter a different URL; make sure we don't fetch it.
        linkPreviewFetcher.update(.init(text: "https://signal.org/one", ranges: .empty))
        XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // Ensure "enableIfEmpty" doesn't enable when the text isn't empty.
        linkPreviewFetcher.update(.init(text: "https://signal.org/one", ranges: .empty), enableIfEmpty: true)
        XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // Clear the text with "enableIfEmpty" to re-enable link previews.
        linkPreviewFetcher.update(.init(text: "", ranges: .empty), enableIfEmpty: true)
        XCTAssertNil(linkPreviewFetcher.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [])

        // Set a URL and make sure we fetch it.
        let url2 = try XCTUnwrap(URL(string: "https://signal.org/two"))
        linkPreviewFetcher.update(.init(text: "https://signal.org/two", ranges: .empty), enableIfEmpty: true)
        XCTAssertEqual(linkPreviewFetcher.linkPreviewDraftIfLoaded?.url, url2)
        XCTAssertEqual(mockLinkPreviewManager.fetchedURLs, [url2])
    }

    func testOnlyParseIfEnabled() throws {
        mockLinkPreviewManager.areLinkPreviewsEnabledMock = false

        do {
            let linkPreviewFetcher = LinkPreviewFetcher(
                db: mockDB,
                linkPreviewManager: mockLinkPreviewManager,
                schedulers: TestSchedulers(scheduler: testScheduler),
                onlyParseIfEnabled: true
            )

            linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
            XCTAssertNil(linkPreviewFetcher.currentUrl)
        }
        do {
            let linkPreviewFetcher = LinkPreviewFetcher(
                db: mockDB,
                linkPreviewManager: mockLinkPreviewManager,
                schedulers: TestSchedulers(scheduler: testScheduler),
                onlyParseIfEnabled: false
            )

            // If link previews are disabled, we may still want to parse URLs in the
            // text so that they can be attached (without a preview) to text stories.
            mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
                return Promise(error: OWSGenericError("Not found."))
            }
            linkPreviewFetcher.update(.init(text: "https://signal.org", ranges: .empty))
            XCTAssertEqual(linkPreviewFetcher.currentUrl, try XCTUnwrap(URL(string: "https://signal.org")))
        }
    }

    func testDontParseInSpoilers() throws {
        mockLinkPreviewManager.fetchLinkPreviewBlock = { fetchedURL in
            return .value(OWSLinkPreviewDraft(url: fetchedURL, title: "Signal"))
        }

        let linkPreviewFetcher = LinkPreviewFetcher(
            db: mockDB,
            linkPreviewManager: mockLinkPreviewManager,
            schedulers: TestSchedulers(scheduler: testScheduler),
            onlyParseIfEnabled: true
        )

        // Bold should have no effect
        linkPreviewFetcher.update(.init(
            text: "https://signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.bold, range: NSRange(location: 0, length: 18))]
            )
        ))
        XCTAssertNotNil(linkPreviewFetcher.currentUrl)

        // Spoiler should mean we don't match.
        linkPreviewFetcher.update(.init(
            text: "https://signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.spoiler, range: NSRange(location: 0, length: 18))]
            )
        ))
        XCTAssertNil(linkPreviewFetcher.currentUrl)

        // Even if only partially covering.
        linkPreviewFetcher.update(.init(
            text: "https://signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.spoiler, range: NSRange(location: 3, length: 5))]
            )
        ))
        XCTAssertNil(linkPreviewFetcher.currentUrl)

        // Including if we prepend a prefix.
        linkPreviewFetcher.update(.init(
            text: "signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.spoiler, range: NSRange(location: 5, length: 5))]
            )
        ))
        XCTAssertNil(linkPreviewFetcher.currentUrl)
    }
}
