/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.apache.pekko.persistence.r2dbc.journal.mysql

import scala.concurrent.ExecutionContext
import org.apache.pekko
import pekko.actor.typed.ActorSystem
import pekko.annotation.InternalApi
import pekko.persistence.r2dbc.DbTimestampMonotonicIncreasing
import pekko.persistence.r2dbc.JournalSettings
import pekko.persistence.r2dbc.UseAppTimestamp
import pekko.persistence.r2dbc.internal.Sql.DialectInterpolation
import pekko.persistence.r2dbc.journal.JournalDao
import io.r2dbc.spi.ConnectionFactory
import io.r2dbc.spi.Row
import io.r2dbc.spi.Statement

/**
 * INTERNAL API
 */
@InternalApi
private[r2dbc] object MySQLJournalDao {

  /** Serialise a set of tags to a JSON array string for MySQL JSON column storage. */
  def tagsToJson(tags: Set[String]): String =
    tags.iterator
      .map(t => "\"" + t.replace("\\", "\\\\").replace("\"", "\\\"") + "\"")
      .mkString("[", ",", "]")

  /**
   * Deserialise a JSON array string from a MySQL JSON column back to a set of tags.
   * Returns an empty set for `null` or an empty JSON array (with optional surrounding whitespace).
   * Throws [[IllegalStateException]] if the input is not a valid JSON array of strings.
   */
  def tagsFromJson(json: String): Set[String] = {
    if (json == null) return Set.empty
    val trimmed = json.trim
    if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
      throw new IllegalStateException(s"tags JSON is not a JSON array: $json")
    val inner = trimmed.substring(1, trimmed.length - 1).trim
    if (inner.isEmpty) return Set.empty
    val result = scala.collection.mutable.Set.empty[String]
    val n = trimmed.length
    var i = 1 // skip opening '['
    while (i < n - 1) { // stop before closing ']'
      val c = trimmed.charAt(i)
      if (c == '"') {
        val sb = new StringBuilder
        i += 1
        var closed = false
        while (i < n - 1 && !closed) {
          trimmed.charAt(i) match {
            case '"' =>
              closed = true
            case '\\' if i + 1 < n - 1 =>
              i += 1
              trimmed.charAt(i) match {
                case '"'  => sb.append('"')
                case '\\' => sb.append('\\')
                case 'n'  => sb.append('\n')
                case 'r'  => sb.append('\r')
                case 't'  => sb.append('\t')
                case c2   => sb.append('\\').append(c2)
              }
              i += 1
            case other =>
              sb.append(other)
              i += 1
          }
        }
        if (!closed)
          throw new IllegalStateException(s"tags JSON contains an unterminated string: $json")
        result += sb.toString()
      } else if (c != ',' && c != ' ' && c != '\t' && c != '\n' && c != '\r') {
        throw new IllegalStateException(s"tags JSON contains non-string element: $json")
      }
      i += 1
    }
    result.toSet
  }

  def settingRequirements(settings: UseAppTimestamp with DbTimestampMonotonicIncreasing): Unit = {
    // Application timestamps are used because MySQL does not have transaction_timestamp like Postgres. In future releases
    // they could be tried to be emulated, but the benefits are questionable - no matter where the timestamps are generated,
    // risk of clock skews remains.
    require(settings.useAppTimestamp,
      "use-app-timestamp config must be on for MySQL support")
    // Supporting the non-monotonic increasing timestamps by incrementing the timestamp within the insert queries based on
    // latest row in the database seems to cause deadlocks when running tests like PersistTimestampSpec. Possibly this could
    // be fixed.
    require(settings.dbTimestampMonotonicIncreasing,
      "db-timestamp-monotonic-increasing config must be on for MySQL support")
    // Also, missing RETURNING implementation makes grabbing the timestamp generated by the database less efficient - this
    // applies for both of the requirements above.
  }
}

/**
 * INTERNAL API
 */
@InternalApi
private[r2dbc] class MySQLJournalDao(
    journalSettings: JournalSettings,
    connectionFactory: ConnectionFactory)(
    implicit ec: ExecutionContext, system: ActorSystem[_]
) extends JournalDao(journalSettings, connectionFactory) {
  MySQLJournalDao.settingRequirements(journalSettings)

  override lazy val timestampSql: String = "NOW(6)"
  override lazy val statementTimestampSql: String = "NOW(6)"

  override protected def bindTagsForWrite(stmt: Statement, tags: Set[String], index: Int): Statement =
    if (tags.isEmpty) stmt.bindNull(index, classOf[String])
    else stmt.bind(index, MySQLJournalDao.tagsToJson(tags))

  override protected def tagsFromRow(row: Row): Set[String] =
    MySQLJournalDao.tagsFromJson(row.get("tags", classOf[String]))

  override val insertEventWithParameterTimestampSql: String =
    sql"INSERT INTO $journalTable " +
    "(slice, entity_type, persistence_id, seq_nr, writer, adapter_manifest, event_ser_id, event_ser_manifest, " +
    "event_payload, tags, meta_ser_id, meta_ser_manifest, meta_payload, db_timestamp) " +
    s"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
}
