import unittest, strformat, sequtils, algorithm, sugar, json, tables, random
import os
import ormin
import ormin/db_utils
when NimVersion < "1.2.0": import ./compat


let testDir = currentSourcePath.parentDir()

when defined postgre:
  when defined(macosx):
    {.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
  from db_connector/db_postgres import exec, getValue

  const backend = DbBackend.postgre
  importModel(backend, "forum_model_postgres")
  const sqlFileName = "forum_model_postgres.sql"
  let db {.global.} = open("localhost", "test", "test", "test_ormin")
else:
  from db_connector/db_sqlite import exec, getValue

  const backend = DbBackend.sqlite
  importModel(backend, "forum_model_sqlite")
  const sqlFileName = "forum_model_sqlite.sql"
  var memoryPath = testDir & "/" & ":memory:"
  let db {.global.} = open(memoryPath, "", "", "")

var sqlFilePath = Path(testDir & "/" & sqlFileName)

type
  Person = tuple[id: int,
                name: string,
                password: string,
                email: string,
                salt: string,
                status: string]
  Thread = tuple[id: int,
                name: string,
                views: int]
  Post = tuple[id: int,
              author: int,
              ip: string,
              header: string,
              content: string,
              thread: int]
  Antibot = tuple[id: int,
                  ip: string,
                  answer: string]

const
  personcount = 5
  threadcount = 5
  postcount = 10
  antibotcount = 5
var
  persondata: seq[Person]
  threaddata: seq[Thread]
  postdata: seq[Post]
  antibotdata: seq[Antibot]   


suite &"Test ormin features of {backend}":
  discard

suite "query":
  db.dropTable(sqlFilePath)
  db.createTable(sqlFilePath)

  static:
    doAssert compiles(block:
      discard query:
        select post(author)
        crossjoin person(name)
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        outerjoin person(name) on author == id
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        leftjoin person(name) on author == id
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        leftouterjoin person(name) on author == id
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        rightjoin person(name) on author == id
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        rightouterjoin person(name) on author == id
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        fulljoin person(name) on author == id
    )
    doAssert compiles(block:
      discard query:
        select post(author)
        fullouterjoin person(name) on author == id
    )

  # prepare data to insert  
  for i in 1..personcount:
    persondata.add((id: i,
                    name: fmt"john{i}",
                    password: fmt"pass{i}",
                    email: fmt"john{i}@mail.com",
                    salt: fmt"abcd{i}",
                    status: fmt"ok{i}"))

  for i in 1..threadcount:
    threaddata.add((id: i,
                    name: fmt"thread{i}",
                    views: i))

  for i in 1..postcount:
    postdata.add((id: i,
                  author: sample({1..personcount}),
                  ip: "",
                  header: fmt"title{i}",
                  content: fmt"content{i}",
                  thread: sample({1..threadcount})))

  for i in 1..antibotcount:
    antibotdata.add((id: i,
                    ip: "",
                    answer: fmt"answer{i}"))

  # insert data into database
  let
    insertperson = sql"insert into person (id, name, password, email, salt, status) values (?, ?, ?, ?, ?, ?)"
    insertthread = sql"insert into thread (id, name, views) values (?, ?, ?)"
    insertpost = sql"insert into post (id, author, ip, header, content, thread) values (?, ?, ?, ?, ?, ?)"
    insertantibot = sql"insert into antibot (id, ip, answer) values (?, ?, ?)"

  for p in persondata:
    db.exec(insertperson, p.id, p.name, p.password, p.email, p.salt, p.status)

  for t in threaddata:
    db.exec(insertthread, t.id, t.name, t.views)

  for p in postdata:
    db.exec(insertpost, p.id, p.author, p.ip, p.header, p.content, p.thread)

  for a in antibotdata:
    db.exec(insertantibot, a.id, a.ip, a.answer)

  # check data in database
  let personexpected = db.getValue(sql"select count(*) from person")
  assert personexpected == $personcount

  let threadexpected = db.getValue(sql"select count(*) from thread")
  assert threadexpected == $threadcount

  let postexpected = db.getValue(sql"select count(*) from post")
  assert postexpected == $postcount

  let antibotexpected = db.getValue(sql"select count(*) from antibot")
  assert antibotexpected == $antibotcount

  test "table":
    let res = query:
      select person(id, name, password, email, salt, status)
    check res == persondata

  test "all_column":
    let res = query:
      select person(_)
    check res.mapIt((it.id, it.name, it.password, it.email, it.salt, it.status)) == persondata
  
  test "column_alias":
    let res = query:
      select person(id as personid, name as personname)
    check res == persondata.mapIt((personid: it.id, personname: it.name))

  test "arithmetic":
    let res = query:
      select person(id * 4 / 2 + 2 - 1 as id)
    check res == persondata.mapIt(int(it.id * 4 / 2 + 2 - 1))

  test "comparison":
    let id = 1
    let res = query:
      select person(id, name, password, email, salt, status)
      where id == ?id
    check res == [persondata[id - 1]]

  test "comparison_ne":
    let id = 1
    let res = query:
      select person(id, name, password, email, salt, status)
      where id != ?id
    check res == persondata.filterIt(it.id != id)

  test "comparison_ge":
    let id = 3
    let res = query:
      select person(id, name, password, email, salt, status)
      where id >= ?id
    check res == persondata.filterIt(it.id >= id)

  test "comparison_le":
    let id = 3
    let res = query:
      select person(id, name, password, email, salt, status)
      where id <= ?id
    check res == persondata.filterIt(it.id <= id)

  test "comparison_gt":
    let id = 3
    let res = query:
      select person(id, name, password, email, salt, status)
      where id > ?id
    check res == persondata.filterIt(it.id > id)

  test "comparison_lt":
    let id = 3
    let res = query:
      select person(id, name, password, email, salt, status)
      where id < ?id
    check res == persondata.filterIt(it.id < id)

  test "logical_not":
    let id = 3
    let res = query:
      select person(id, name, password, email, salt, status)
      where not (id > ?id)
    check res == persondata.filterIt(it.id <= id)

  test "logical_and":
    let
      id1 = 2
      id2 = 4
    let res = query:
      select person(id, name, password, email, salt, status)
      where id >= ?id1 and id <= ?id2
    check res == persondata.filterIt(it.id >= id1 and it.id <= id2)

  test "logical_or":
    let
      id1 = 2
      id2 = 4
    let res = query:
      select person(id, name, password, email, salt, status)
      where id == ?id1 or id == ?id2
    check res == persondata.filterIt(it.id == id1 or it.id == id2)

  test "logical_complex":
    let
      id1 = 2
      id2 = 3
      id3 = 4
    let res = query:
      select person(id, name, password, email, salt, status)
      where id > ?id2 and (id == ?id1 or id == ?id3)
    check res == persondata.filterIt(it.id == id3)

  test "predicate_in":
    let
      id1 = 2
      id2 = 4
    let res = query:
      select person(id, name, password, email, salt, status)
      where id in ?id1 .. ?id2
    check res == persondata.filterIt(it.id in id1..id2)

  test "predicate_not_in":
    let
      id1 = 2
      id2 = 4
    let res = query:
      select person(id, name, password, email, salt, status)
      where id notin ?id1 .. ?id2
    check res == persondata.filterIt(it.id < id1 or it.id > id2)

  test "predicate_like":
    let pattern = "john1%"
    let res = query:
      select person(id, name)
      where name `like` ?pattern
    check res == persondata.filterIt(it.name.startsWith("john1")).mapIt((it.id, it.name))

  test "predicate_ilike":
    let pattern = "JOHN1%"
    let res = query:
      select person(id, name)
      where name `ilike` ?pattern
    check res == persondata.filterIt(it.name.startsWith("john1")).mapIt((it.id, it.name))

  test "predicate_not_like":
    let pattern = "john1%"
    let res = query:
      select person(id, name)
      where not (name `like` ?pattern)
    check res == persondata.filterIt(not it.name.startsWith("john1")).mapIt((it.id, it.name))

  test "distinct":
    var res = query:
      select `distinct` post(author)
    res.sort()
    let expected = postdata.mapIt(it.author).deduplicate().sortedByIt(it)
    check res == expected

  test "count_distinct":
    let res = query:
      select post(count(distinct author))
    check res == @[postdata.mapIt(it.author).deduplicate().len]

  test "window_row_number":
    let res = query:
      select post(author, id, over(row_number(), partitionby(author), orderby(id)) as rn)
      orderby author, id
    var expected: seq[(int, int, int)] = @[]
    var counters = initCountTable[int]()
    for p in postdata.sortedByIt((it.author, it.id)):
      counters.inc(p.author)
      expected.add((p.author, p.id, counters[p.author]))
    check res == expected

  test "window_running_sum":
    let res = query:
      select post(author, id, over(sum(id), partitionby(author), orderby(id)) as running)
      orderby author, id
    var expected: seq[(int, int, int)] = @[]
    var sums = initTable[int, int]()
    for p in postdata.sortedByIt((it.author, it.id)):
      sums[p.author] = sums.getOrDefault(p.author) + p.id
      expected.add((p.author, p.id, sums[p.author]))
    check res == expected

  test "limit":
    let id = 1
    let res = query:
      select person(id, name, password, email, salt, status)
      limit 1
    static:
      echo "LIMIT TEST: ", typeof(res)
    check res == persondata[id - 1]

  test "offset":
    let res = query:
      select person(id, name)
      limit 2
      offset 2
    check res == persondata[2..3].mapIt((it.id, it.name))

  test "match_assignment":
    let id = 1
    let (name, password, email, salt, status) = query:
      select person(name, password, email, salt, status)
      where id == ?id
      limit 1
    check:
      name == persondata[id - 1].name
      password == persondata[id - 1].password
      email == persondata[id - 1].email
      salt == persondata[id - 1].salt
      status == persondata[id - 1].status

  test "groupby":
    let res = query:
      select post(author, count(id))
      groupby author
    let counttable = postdata.mapIt(it.author).toCountTable()
    for (author, c) in res:
      check counttable[author] == c

  test "groupby_aggregate":
    let author = 3
    let res = query:
      select post(count(id))
      where author == ?author
      groupby author
    let c = postdata.mapIt(it.author).toCountTable()[author]
    check res == [c]

  test "orderby":
    let res = query:
      select person(id, name)
      orderby id
    check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])

  test "orderby_asc":
    let res = query:
      select person(id, name)
      orderby asc(id)
    check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])

  test "orderby_desc":
    let res = query:
      select person(id, name)
      orderby desc(id)
    let expected = persondata.mapIt((it.id, it.name))
                            .sorted((x, y) => system.cmp(x[0], y[0]), Descending) 
    check res == expected

  test "orderby_key_select":
    let res = query:
      select person(id as personid, name)
      orderby personid
    check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])

  test "orderby_key_not_select":
    let res = query:
      select person(name)
      orderby id
    check res == persondata.sortedByIt(it.id).mapIt(it.name)

  test "orderby_mulitple":
    # test fix #30 Incorrect handling of multiple sort keys in orderby
    let res = query:
      select post(author, id)
      orderby author, desc(id)
    check res == postdata.mapIt((it.author, it.id))
                        .sorted((x, y) => system.cmp(x[1], y[1]), Descending)
                        .sortedByIt(it[0])

  test "having":
    let id = 4
    let res = query:
      select post(author, count(_) as count)
      groupby author
      having author == ?id
    let expected = collect(newSeq):
      for p in postdata.mapIt(it.author).toCountTable().pairs():
        if p[0] == id: p
    check res == expected

  test "having_aggregate":
    let countvalue = 2
    let res = query:
      select post(author, count(id) as count)
      groupby author
      having count(id) >= ?countvalue
    let expected = collect(newSeq):
      for p in postdata.mapIt(it.author).toCountTable().pairs():
        if p[1] >= countvalue: p
    check res.sortedByIt(it[0])  == expected.sortedByIt(it[0])

  test "having_complex":
    let
      authorid1 = 1
      authorid2 = 3
      countvalue = 2
    let res = query:
      select post(author, count(id) as count)
      groupby author
      having count(id) >= ?countvalue and (author == ?authorid1 or author == ?authorid2)
    let expected = collect(newSeq):
      for p in postdata.mapIt(it.author).toCountTable().pairs():
        if p[0] in [authorid1, authorid2] and p[1] >= countvalue: p
    check res == expected

  test "subquery":
    let res = query:
      select post(id)
      where author in
        (select person(id))
    check res.sortedByIt(it) == postdata.mapIt(it.id)

  test "subquery_nest2":
    let
      personid1 = 1
      personid2 = 2
      expectedpost = postdata.filterIt(it.author in [personid1, personid2])
                            .mapIt(it.id)
    let res = query:
      select post(id)
      where author in
        (select person(id) where id == ?personid1 or id == ?personid2)
    check res.sortedByIt(it) == expectedpost.sortedByIt(it)

  test "subquery_nest3":
    var res = query:
      select thread(id)
      where id in (select post(thread) where author in
        (select person(id) where id in {1, 2}))
    res.sort()
    check res == postdata.filterIt(it.author in [1, 2])
                        .mapIt(it.thread)
                        .sortedByIt(it)

  test "subquery_having":
    # feature for having in subquery
    let id = 4
    let res = query:
      select person(id)
      where id in (
        select post(author) groupby author having author == ?id)
    check res == @[id]

  test "exists":
    let authorid = postdata[0].author
    let res = query:
      select person(id)
      where exists(select post(id) where author == ?authorid)
    check res.sortedByIt(it) == persondata.mapIt(it.id)

  test "not_exists":
    let missingAuthor = personcount + postcount
    let res = query:
      select person(id)
      where not exists(select post(id) where author == ?missingAuthor)
    check res.sortedByIt(it) == persondata.mapIt(it.id)

  test "with_cte":
    let res = query:
      with recent(select post(id, author) where id <= 3)
      select recent(author)
      orderby id
    check res == postdata.filterIt(it.id <= 3).sortedByIt(it.id).mapIt(it.author)

  test "with_cte_chain":
    let res = query:
      with recent(select post(id, author) where id <= 3)
      with authors(select recent(author))
      select authors(author)
    check res.sortedByIt(it) == postdata.filterIt(it.id <= 3).mapIt(it.author).sortedByIt(it)

  test "with_cte_subquery":
    let res = query:
      with recent(select post(id, author) where id <= 3)
      select person(id)
      where id in (select recent(author))
    check res.sortedByIt(it) == postdata.filterIt(it.id <= 3).mapIt(it.author).deduplicate().sortedByIt(it)

  test "union":
    var res = query:
      select person(id) where id <= 2
      union
      select person(id) where id >= 4
    res.sort()
    check res == @[1, 2, 4, 5]

    res = query:
      union(
        select person(id) where id <= 2,
        select person(id) where id >= 4
      )
    res.sort()
    check res == @[1, 2, 4, 5]

  test "intersect":
    var res = query:
      select person(id) where id <= 3
      intersect
      select person(id) where id >= 2
    res.sort()
    check res == @[2, 3]

    res = query:
      intersect(
        select person(id) where id <= 3,
        select person(id) where id >= 2
      )
    res.sort()
    check res == @[2, 3]

  test "except":
    var res = query:
      select person(id) where id <= 4
      `except`
      select person(id) where id in {2, 3}
    res.sort()
    check res == @[1, 4]

    res = query:
      `except`(
        select person(id) where id <= 4,
        select person(id) where id in {2, 3}
      )
    res.sort()
    check res == @[1, 4]
      
  test "complex_query_subquery_having":
    let
      id1 = 2
      id2 = 3
    let res = query:
      select thread(count(_))
      where id in (
        select post(thread) where (author == ?id1 or author == ?id2) and id in (
          select post(min(id)) groupby thread having min(id) > 3))
      limit 1

    let threadinpost = postdata.mapIt(it.thread).deduplicate().sortedByIt(it)
    var postminids: seq[int]    
    for id in threadinpost:
      let group = collect(newSeq):
        for p in postdata:
          if p.thread == id: p.id
      postminids.add(group.min())
    let threadids = postdata.filterIt(it.author in [id1, id2] and
                                    it.id in postminids.filterIt(it > 3))
                            .mapIt(it.thread)
    check res == threadids.len()

  test "auto_join":
    let postid = 1
    let res = query:
      select post(author)
      join person(name)
      where id == ?postid
    let (author, name) = res[0]
    check name == persondata[author - 1].name

  test "join_on":
    # test fix #29 Cannot handle join on condition correctly
    let postid = 1
    let res = query:
      select post(author)
      join person(name) on author == id
      where id == ?postid
    let (author, name) = res[0]
    check name == persondata[author - 1].name

  test "leftjoin_on":
    let postid = 1
    let res = query:
      select post(author)
      leftjoin person(name) on author == id
      where id == ?postid
    let (author, name) = res[0]
    check name == persondata[author - 1].name

  test "leftouterjoin_on":
    let postid = 1
    let res = query:
      select post(author)
      leftouterjoin person(name) on author == id
      where id == ?postid
    let (author, name) = res[0]
    check name == persondata[author - 1].name

  test "casewhen":
    # need more test, only number
    # has problem with string or expression in condition
    let res = query:
      select person(name, (if id == 1: 10 elif id == 2: 20 else: 30) as pid)
    check res == persondata.mapIt((
                    name: it.name,
                    pid: if it.id == 1: 10 elif it.id == 2: 20 else: 30))

  test "produce_json":
    # test fix #27 Error: type mismatch: got <typeof(nil)>
    let threadjson = query:
      select thread(id, name)
      produce json
    let expected = threaddata.map do (it: tuple) -> JsonNode:
      result = newJObject()
      result["id"] = %it.id
      result["name"] = %it.name
    check threadjson == %expected

  test "produce_nim":
    let threadnim = query:
      select thread(id, name)
      produce nim
    check threadnim == threaddata.mapIt((it.id, it.name))

  test "tryquery":
    # unknow use, only not raise dbError?
    let res = tryQuery:
      select thread(id)
    check res == threaddata.mapIt(it.id)

  test "createproc":
    createProc getAllThreadIds:
      select thread(id)
    check db.getAllThreadIds() == threaddata.mapIt(it.id)

  test "createiter":
    createIter allThreadIdsIter:
      select thread(id)
    let res = collect(newSeq):
      for id in db.allThreadIdsIter:
        id
    check res.sortedByIt(it) == threaddata.mapIt(it.id)

  test "update_concat_op":
    let
      id = 3
      name = persondata[id - 1].name
      appendstr = "updated"
      nameupdated = name & appendstr
    query:
      update person(name = name & ?appendstr)
      where id == ?id
    let res = query:
      select person(name)
      where id == ?id
    check res[0] == nameupdated

  test "delete_one":
    # test fix #14: delete not return value
    let id = 1
    query:
      delete antibot
      where id == ?id
    let res = query:
      select antibot(_)
      where id == ?id
    check res == []

  test "delete_all":
    query:
      delete antibot
    let res = query:
      select antibot(_)
    check res == []

  test "insert_return_id":
    # test fix #28 returning id fail under postgresql 
      let expectedid = 6
      let id = query:
        insert antibot(id = ?expectedid, ip = "", answer = "just insert")
        returning id
      check id == expectedid

  test "insert_return_answer":
    # test returning non-id parameter
      let expectedanswer = "just insert another"
      let answer = query:
        insert antibot(id = 9, ip = "", answer = ?expectedanswer)
        returning answer
      check answer == expectedanswer

  test "insert_return_id_auto":
    # test returning id column
      when defined(postgre):
        # fix postgres sequence so next nextval returns 10
        discard db.getValue(sql"select setval('antibot_id_seq', ?, ?)", 10, false)
      let answer = query:
        insert antibot(ip = "", answer = "just auto insert")
        returning id
      check answer == 10

  test "insert_returning_uuid":
    # test returning uuid column
      let expecteduuid = "123e4567-e89b-12d3-a456-426614174000"
      let uuid = query:
        insert error(uuid = ?expecteduuid, message = "just insert")
        returning uuid
      check uuid == expecteduuid

  test "where_json":
    let
      id = 1
      p = %*{"id": id}
    let res = query:
      select person(id)
      where id == %p["id"]
    check res == [id]

  test "insert_json":
    let
      id = 7
      answer = "json answer"
      a = %*{"answer": answer}
    query:
      insert antibot(id = ?id, ip = "", answer = %a["answer"])
    let res = query:
      select antibot(answer)
      where id == ?id
    check res[0] == answer

  test "update_json":
    let
      id = 3
      name = "json"
      p = %*{"name": name}
    query:
      update person(name = %p["name"])
      where id == ?id
    let res = query:
      select person(name)
      where id == ?id
    check res[0] == name
  
  test "insert_rawsql":
    let id = 8
    query:
      insert antibot(id = ?id, ip = "", answer = !!"'raw sql'",
                    created = !!"CURRENT_TIMESTAMP")
    let res = query:
      select antibot(_)
      where id == ?id
    check res[0].answer == "raw sql"

  test "update_rawsql":
    let id = 3
    let res = query:
      select person(name)
      where id == ?id
    query:
      update person(name = !!"UPPER(name)")
      where id == ?id
    let res2 = query:
      select person(name)
      where id == ?id
    check res[0] != res2[0]
    check res[0].toUpper() == res2[0]
  test "empty_string":
    db.exec(insertthread, 6, "", 77)

    createProc getAllThreads:
      select thread(id, name, views)
      where id >= 5

    let rows = db.getAllThreads
    check rows[1].id == 6 
    check rows[1].views == 77
    check rows[1].name == ""
