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

import Foundation
import XCTest
@testable import SignalServiceKit

// In this file I present a toy example of how to use
// V2 DB components, and the code design they encourage.
// (Hereafter referred simply as DB, with "old" database
// types referred to as "SDS" types)

// As of time of writing, DB _does not_ help you test
// things like SQL statements or GRDB functions that
// compose SQL statements for you.
// Instead, its purpose is to encapsulate "storage"
// in general for classes that consume data from
// disk but whose primary business is transforming
// and doing things with that data.

// This is best demonstrated by example.

private class FooBarManager {}

// Here is a class, FooBarManager.
// It does things with Foos and Bars, like
// check their properties and perform network requests.

private struct Foo: Codable {
    let text: String
}

private struct Bar: Codable {
    let num: Int
}

extension FooBarManager {
    func doTheFooBar(foo: Foo, bar: Bar) {
        let newFoo: Foo
        if bar.num == 42 {
            newFoo = Foo(text: "The meaning of life")
        } else {
            newFoo = foo
        }
        issueNetworkRequest(foo: newFoo)
    }

    func issueNetworkRequest(foo: Foo) {
        // TODO
    }
}

// FooBarManager is primarily concerned with what you _do_
// with Foos and Bars, not how they are stored.
// But if it _did_ want to persist them, it would
// delegate that responsibility to another class.

private class FooFinder {

    init() {}

    func getFoo(transaction: SDSAnyReadTransaction) -> Foo? {
        let sql = "SELECT text FROM model_Foo LIMIT 1"
        guard let text = try? String.fetchOne(transaction.unwrapGrdbRead.database, sql: sql) else {
            return nil
        }
        return Foo(text: text)
    }
}

private class BarFinder {

    // This is an static method vs instance method for getFoo above.
    // The reason why will be obvious later on. For now, ignore it.
    static func getBar(transaction: SDSAnyReadTransaction) -> Bar? {
        let sql = "SELECT text FROM model_Foo LIMIT 1"
        guard let num = try? Int.fetchOne(transaction.unwrapGrdbRead.database, sql: sql) else {
            return nil
        }
        return Bar(num: num)
    }
}

// DB _does not_ help you test these Finder classes. Instead,
// it helps you mock out the Finder classes so you can test
// FooBarManager.

// Traditionally, FooBarManager might consume foos and bars from storage like so:

extension FooBarManager {

    // Takes a transaction to ensure foo and bar are read
    // from the same database snapshot and aren't out of sync.
    func doTheFooBar(transaction: SDSAnyReadTransaction) {
        let foo = FooFinder().getFoo(transaction: transaction)!
        let bar = BarFinder.getBar(transaction: transaction)!
        doTheFooBar(foo: foo, bar: bar)
    }
}

// That works in production code, but when you try and test you run into some problems:
private class FooBarManagerTest: SSKBaseTest {

    var fooBarManager: FooBarManager!

    func testGetFooAndBar() throws {
        throw XCTSkip("Don't actually want to run this test")

        #if false
        // This comes from SSKEnvironment, which by its very creation
        // requires setting up countless managers and running all sorts
        // of database startup tasks.
        let databaseStorage = self.databaseStorage

        // Write a bunch of SQL queries to insert Foo(s) and Bar(s)
        // into the database.

        databaseStorage.read { transaction in
            fooBarManager.doTheFooBar(transaction: transaction)
        }

        // Assert stuff...
        #endif
    }
}

// You HAD to create the entire SSKEnvironment just to run your measly test.
// That's a lot of overhead for your simple unit test, and it also makes
// things hard to reason about.

// Most importantly, FooBarManager doesn't actually _care_ about any of that. It doesn't
// even care that there is a database; thats FooFinder and BarFinder's problem.
// All it knows is it has this "transaction" object, which is a total black box, that
// it blindly passes along to its dependencies to do the real work.
// It is aware that passing a single transaction around (vs one per operation)
// guarantees transactionality, but that is all.

// NOTE: one option would be to refactor SDSDatabaseStorage to to be more flexible.
// That involves way more refactoring than was required to create DB and other V2 classes,
// so we did that instead.

// Using DB instead, we do this:

extension FooFinder {

    func getFoo(transaction: DBReadTransaction) -> Foo? {
        let sdsTx = SDSDB.shimOnlyBridge(transaction)
        return getFoo(transaction: sdsTx)
    }
}

extension BarFinder {

    static func getBar(transaction: DBReadTransaction) -> Bar? {
        let sdsTx = SDSDB.shimOnlyBridge(transaction)
        return getBar(transaction: sdsTx)
    }
}

extension FooBarManager {

    func doTheFooBar(transaction: DBReadTransaction) {
        let foo = FooFinder().getFoo(transaction: transaction)!
        let bar = BarFinder.getBar(transaction: transaction)!
        doTheFooBar(foo: foo, bar: bar)
    }
}

// The code you actually write is pretty much identical, except for the types.
// You just have to do one conversion, to unwrap the DBReadTransaction into
// an SDSAnyRead transaction you can actually use.

// Here's the test:

extension FooBarManagerTest {

    func testGetFooAndBar_2() throws {
        throw XCTSkip("Don't actually want to run this test")

        #if false
        // This is a dumb, simple class with no dependencies.
        // It has no overhead, no startup jobs, nothing.
        // All it does is give you things that _look_
        // like transactions, but crash if you try and
        // actually use them to read from a database.
        let db = MockDB()

        // Here goes state we set up for our test, e.g. creating
        // the Foo(s) and Bar(s) we expect to "read" from the "db".
        // We will get to how to do that later, hold tight.
        // No SQL queries, though.

        db.read { transaction in
            fooBarManager.doTheFooBar(transaction: transaction)
        }

        // Assert stuff...
        #endif
    }
}

// Our unit test is now _truly_ isolated. There are no other dependencies
// or implicit startup jobs running around. What happens is what the test
// says happens. No more flakes because an unrelated component is flaky and
// crashes on startup every once in a while.

// The only problem is that this test will crash and fail as written.
// That's because it uses FooFinder and BarFinder, and we made it so they
// would accept a DBReadTransaction, but as soon as we give them the fake
// transactions MockDB generates, they will crash when they call SDSDB.shimOnlyBridge.

// This is a good thing; the test _should_ fail because we have made a mistake:
// we have not mocked out FooFinder, BarFinder, or any other dependencies FooBarManager has.
// We _must_ define our dependencies in our test, and tell them how they should behave,
// so that we can isolate FooBarManager and test its logic independently of its dependencies.

// So how do we mock out FooFinder and BarFinder?

// FooFinder is easier; its a simple class and uses instance methods, so we can
// easily wrap it in a protocol with an alternate mock implementation.

private protocol FooFinderProtocol {

    func getFoo(transaction: DBReadTransaction) -> Foo?
}

extension FooFinder: FooFinderProtocol {}

private class FooFinderMock: FooFinderProtocol {

    init() {}

    var fooToReturn: Foo?

    func getFoo(transaction: DBReadTransaction) -> Foo? {
        // Ignore the transaction! This mock is only used
        // in tests which pass around fake transactions that
        // crash if you try and unwrap them.
        return fooToReturn
    }
}

// I took a shortcut earlier and had FooBarManager instantiate a FooFinder inline
// where it was needed. We'd now instead update the initializer to take a FooFinderProtocol:

private class FooBarManager2 {
    let fooFinder: FooFinderProtocol

    init(fooFinder: FooFinderProtocol) {
        self.fooFinder = fooFinder
    }

    func doTheFooBar(transaction: DBReadTransaction) {
        _ = fooFinder.getFoo(transaction: transaction)!
        _ = BarFinder.getBar(transaction: transaction)!
        // doTheFooBar(foo: foo, bar: bar)
    }
}

// We'd create a "real" instance like so:
func ex() {
    _ = FooBarManager2(fooFinder: FooFinder())
}

// And in tests we pass a FooFinderMock:

class FooBarManagerTest2: XCTestCase {

    private var fooFinderMock: FooFinderMock!

    private var fooBarManager: FooBarManager2!

    override func setUp() {
        fooFinderMock = FooFinderMock()
        fooBarManager = FooBarManager2(fooFinder: fooFinderMock)
    }

    func testGetFooAndBar_2() throws {
        throw XCTSkip("Don't actually want to run this test")
        #if false
        let db = MockDB()

        // Setting and returning this value doesn't go through
        // the db, doesn't rely on the schema being set up, etc.
        // FooBarManager wants a Foo and doesn't care how it was stored;
        // this test now better matches that without getting into
        // implementation details like SQL.
        fooFinderMock.fooToReturn = Foo(text: "Hello, world!")

        db.read { transaction in
            fooBarManager.doTheFooBar(transaction: transaction)
        }

        // Assert stuff...
        #endif
    }
}

// We are one step closer now, but this test will still fail.
// The FooBarManager instance in our test will still call the static method on
// BarFinder, which crashes if its given a fake MockDB-generated transaction.

// BarFinder is a little bit tricker than FooFinder. It uses class methods, not instance
// methods, so we can't create a mock instance with different behavior.
// Since this is just an example, you can imagine that BarFinder has other
// complications:
//
// * It might be enormous with tons of methods you don't want to stub out 1 by 1
//   for your little basic unit test.
// * It might get called from objc classes, or be written in objc itself,
//   and you don't want to migrate a bunch of objc code for you basic test.
// * It might be badly designed, doing ten thousand things which should
//   be broken up into smaller, more directed classes.
//
// In any case, while refactoring BarFinder would be ideal, the worst
// outcome is giving up due to the overhead and not writing a test for
// FooBarManager at all _because_ of BarFinder's ugliness.

// There is a shortcut, which is just "shimming" out the few things
// your specific class needs from BarFinder, like so:

private protocol FooBarManager_BarFinderShim {

    // BarFinder might have TONS of other methods on it that
    // FooBarManager never invokes, so we can exclude them
    // from the FooBarManager-scoped shim. No other class should
    // use this shim, so it only needs what FooBarManager uses.
    func getBar(transaction: DBReadTransaction) -> Bar?
}

private class FooBarManager_BarFinderWrapper: FooBarManager_BarFinderShim {
    func getBar(transaction: DBReadTransaction) -> Bar? {
        // In our toy example, we already modified BarFinder to
        // take a DBReadTransaction instead of an SDSAnyReadTransaction.
        // In the real world, BarFinder would be an existing class that doesn't
        // have that, and the Wrapper is the place to do the bridging.
        // Hence the name "shimOnlyBridge".
        let sdsTx = SDSDB.shimOnlyBridge(transaction)
        return BarFinder.getBar(transaction: sdsTx)
    }
}

private class FooBarManager_BarFinderMock: FooBarManager_BarFinderShim {

    init() {}

    var barToReturn: Bar?

    func getBar(transaction: DBReadTransaction) -> Bar? {
        // Again we ignore the transaction, like in FooFinderMock.
        return barToReturn
    }
}

// Aside: Holy TeenageMutantNinjaTurtles Batman! Those are some long and ugly names!
// Some namespacing can make things easier to read. Swift doesn't allow protocols
// inside other declarations (they have to live at the top level) but we can
// use typealias to get the same result:

// enum with no cases is a great way to create a no-op name for namespace purposes only.
private enum FooBar {
    enum Shims {
        typealias BarFinder = FooBarManager_BarFinderShim
    }
    enum Wrappers {
        typealias BarFinder = FooBarManager_BarFinderWrapper
    }
    enum Mocks {
        typealias BarFinder = FooBarManager_BarFinderMock
    }
}

// Now finally we can update FooBarManager to accept a mockable dependency on
// FooFinder, like it does for FooFinder:

private class FooBarManager3 {
    let fooFinder: FooFinderProtocol
    let barFinder: FooBar.Shims.BarFinder

    init(
        fooFinder: FooFinderProtocol,
        barFinder: FooBar.Shims.BarFinder
    ) {
        self.fooFinder = fooFinder
        self.barFinder = barFinder
    }

    func doTheFooBar(transaction: DBReadTransaction) {
        _ = fooFinder.getFoo(transaction: transaction)!
        _ = barFinder.getBar(transaction: transaction)!
        // doTheFooBar(foo: foo, bar: bar)
    }
}

// We create a "real" instance like so:
func ex2() {
    _ = FooBarManager3(
        fooFinder: FooFinder(),
        barFinder: FooBar.Wrappers.BarFinder()
    )
}

// And everything is stubbed out in tests, with no dependencies
// we don't control:

class FooBarManagerTest3: XCTestCase {

    private var fooFinderMock: FooFinderMock!
    private var barFinderMock: FooBar.Mocks.BarFinder!

    private var fooBarManager: FooBarManager3!

    override func setUp() {
        fooFinderMock = FooFinderMock()
        barFinderMock = FooBar.Mocks.BarFinder()
        fooBarManager = FooBarManager3(fooFinder: fooFinderMock, barFinder: barFinderMock)
    }

    func testGetFooAndBar_2() throws {
        throw XCTSkip("Don't actually want to run this test")
        #if false
        let db = MockDB()

        fooFinderMock.fooToReturn = Foo(text: "Hello, world!")
        barFinderMock.barToReturn = Bar(num: 42)

        db.read { transaction in
            fooBarManager.doTheFooBar(transaction: transaction)
        }

        // Assert stuff...
        #endif
    }
}

// To reiterate:

// We used DB and its transaction classes instead of SDS classes,
// so that we can easily generate fake instances with the lightweight,
// dependency-free MockDB.

// We use SDSDB.shimOnlyBridge to convert between our db-agnostic code
// that talks in DB types, and dependencies that talk in SDS types.
// These dependencies can be:
//
// * New classes, like FooFinder, that are the final point of use that
//   unwrap the transaction to utilize the db connection.
//
// * Old classes, not explicitly demonstrated here, but these would
//   be things like TSAccountManager (at time of writing) which may not
//   access the db connection themselves, but which haven't been refactored
//   to use the new types yet.

// In either case, we need to mock out those classes so that we never call
// SDSDB.shimOnlyBridge in tests; doing so will cause a crash.
// There are two options for this, depending on how much effort is required
// and the time available to undergo that effort:
//
//
// The Good Way: avoid static methods, always wrap your classes in a protocol,
// andd avoid objective-C compatibility requirements.
// This lets you accept DB transaction types and create simple Mock implementations.
//
// This is how FooFinder works in this example.
//
//
// The Shortcut Way: create a {ClassName}_{DependencyName}Shim protocol and Wrapper class
// that are used only by your class and which contain only those methods your class invokes.
// This keeps the scope of changes smaller, leaving the original class entirely untouched.
//
// This is how BarFinder works in this example.
//
// The Good Way is _always_ preferred, but requires more time to refactor existing code to do it.
// Depending on the class, and how intertwined it is with objc code, it might require
// a LOT more time.
// In those cases, the Shortcut Way is at least better than giving up on testing entirely.
// Use at your discretion, and lean on code review to get feedback on the right approach.
