<?php

namespace Laminas\Db\RowGateway;

use ArrayAccess;
use Countable;
use Laminas\Db\Sql\Sql;
use Laminas\Db\Sql\TableIdentifier;
// phpcs:ignore SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse
use ReturnTypeWillChange;

use function array_key_exists;
use function count;
use function is_string;

abstract class AbstractRowGateway implements ArrayAccess, Countable, RowGatewayInterface
{
    /** @var bool */
    protected $isInitialized = false;

    /** @var string|TableIdentifier */
    protected $table;

    /** @var array */
    protected $primaryKeyColumn;

    /** @var array */
    protected $primaryKeyData;

    /** @var array */
    protected $data = [];

    /** @var Sql */
    protected $sql;

    /** @var Feature\FeatureSet */
    protected $featureSet;

    /**
     * initialize()
     */
    public function initialize()
    {
        if ($this->isInitialized) {
            return;
        }

        if (! $this->featureSet instanceof Feature\FeatureSet) {
            $this->featureSet = new Feature\FeatureSet();
        }

        $this->featureSet->setRowGateway($this);
        $this->featureSet->apply('preInitialize', []);

        if (! is_string($this->table) && ! $this->table instanceof TableIdentifier) {
            throw new Exception\RuntimeException('This row object does not have a valid table set.');
        }

        if ($this->primaryKeyColumn === null) {
            throw new Exception\RuntimeException('This row object does not have a primary key column set.');
        } elseif (is_string($this->primaryKeyColumn)) {
            $this->primaryKeyColumn = (array) $this->primaryKeyColumn;
        }

        if (! $this->sql instanceof Sql) {
            throw new Exception\RuntimeException('This row object does not have a Sql object set.');
        }

        $this->featureSet->apply('postInitialize', []);

        $this->isInitialized = true;
    }

    /**
     * Populate Data
     *
     * @param  array $rowData
     * @param  bool  $rowExistsInDatabase
     * @return $this Provides a fluent interface
     */
    public function populate(array $rowData, $rowExistsInDatabase = false)
    {
        $this->initialize();

        $this->data = $rowData;
        if ($rowExistsInDatabase === true) {
            $this->processPrimaryKeyData();
            return $this;
        }

        $this->primaryKeyData = null;

        return $this;
    }

    /**
     * @param mixed $array
     * @return AbstractRowGateway
     */
    public function exchangeArray($array)
    {
        return $this->populate($array, true);
    }

    /**
     * Save
     *
     * @return int
     */
    public function save()
    {
        $this->initialize();

        if ($this->rowExistsInDatabase()) {
            // UPDATE

            $data         = $this->data;
            $where        = [];
            $isPkModified = false;

            // primary key is always an array even if its a single column
            foreach ($this->primaryKeyColumn as $pkColumn) {
                $where[$pkColumn] = $this->primaryKeyData[$pkColumn];
                // phpcs:ignore SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator
                if ($data[$pkColumn] == $this->primaryKeyData[$pkColumn]) {
                    unset($data[$pkColumn]);
                } else {
                    $isPkModified = true;
                }
            }

            $statement    = $this->sql->prepareStatementForSqlObject($this->sql->update()->set($data)->where($where));
            $result       = $statement->execute();
            $rowsAffected = $result->getAffectedRows();
            unset($statement, $result); // cleanup

            // If one or more primary keys are modified, we update the where clause
            if ($isPkModified) {
                foreach ($this->primaryKeyColumn as $pkColumn) {
                    // phpcs:ignore SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator
                    if ($data[$pkColumn] !== $this->primaryKeyData[$pkColumn]) {
                        $where[$pkColumn] = $data[$pkColumn];
                    }
                }
            }
        } else {
            // INSERT
            $insert = $this->sql->insert();
            $insert->values($this->data);

            $statement = $this->sql->prepareStatementForSqlObject($insert);

            $result = $statement->execute();
            if (($primaryKeyValue = $result->getGeneratedValue()) && count($this->primaryKeyColumn) === 1) {
                $this->primaryKeyData = [$this->primaryKeyColumn[0] => $primaryKeyValue];
            } else {
                // make primary key data available so that $where can be complete
                $this->processPrimaryKeyData();
            }
            $rowsAffected = $result->getAffectedRows();
            unset($statement, $result); // cleanup

            $where = [];
            // primary key is always an array even if its a single column
            foreach ($this->primaryKeyColumn as $pkColumn) {
                $where[$pkColumn] = $this->primaryKeyData[$pkColumn];
            }
        }

        // refresh data
        $statement = $this->sql->prepareStatementForSqlObject($this->sql->select()->where($where));
        $result    = $statement->execute();
        $rowData   = $result->current();
        unset($statement, $result); // cleanup

        // make sure data and original data are in sync after save
        $this->populate($rowData, true);

        // return rows affected
        return $rowsAffected;
    }

    /**
     * Delete
     *
     * @return int
     */
    public function delete()
    {
        $this->initialize();

        $where = [];
        // primary key is always an array even if its a single column
        foreach ($this->primaryKeyColumn as $pkColumn) {
            $where[$pkColumn] = $this->primaryKeyData[$pkColumn] ?? null;
        }

        // @todo determine if we need to do a select to ensure 1 row will be affected

        $statement = $this->sql->prepareStatementForSqlObject($this->sql->delete()->where($where));
        $result    = $statement->execute();

        $affectedRows = $result->getAffectedRows();
        if ($affectedRows === 1) {
            // detach from database
            $this->primaryKeyData = null;
        }

        return $affectedRows;
    }

    /**
     * Offset Exists
     *
     * @param  string $offset
     * @return bool
     */
    #[ReturnTypeWillChange]
    public function offsetExists($offset)
    {
        return array_key_exists($offset, $this->data);
    }

    /**
     * Offset get
     *
     * @param  string $offset
     * @return mixed
     */
    #[ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        return $this->data[$offset];
    }

    /**
     * Offset set
     *
     * @param  string $offset
     * @param  mixed $value
     * @return $this Provides a fluent interface
     */
    #[ReturnTypeWillChange]
    public function offsetSet($offset, $value)
    {
        $this->data[$offset] = $value;
        return $this;
    }

    /**
     * Offset unset
     *
     * @param  string $offset
     * @return $this Provides a fluent interface
     */
    #[ReturnTypeWillChange]
    public function offsetUnset($offset)
    {
        $this->data[$offset] = null;
        return $this;
    }

    /**
     * @return int
     */
    #[ReturnTypeWillChange]
    public function count()
    {
        return count($this->data);
    }

    /**
     * To array
     *
     * @return array
     */
    public function toArray()
    {
        return $this->data;
    }

    /**
     * __get
     *
     * @param  string $name
     * @throws Exception\InvalidArgumentException
     * @return mixed
     */
    public function __get($name)
    {
        if (array_key_exists($name, $this->data)) {
            return $this->data[$name];
        } else {
            throw new Exception\InvalidArgumentException('Not a valid column in this row: ' . $name);
        }
    }

    /**
     * __set
     *
     * @param  string $name
     * @param  mixed $value
     * @return void
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    /**
     * __isset
     *
     * @param  string $name
     * @return bool
     */
    public function __isset($name)
    {
        return $this->offsetExists($name);
    }

    /**
     * __unset
     *
     * @param  string $name
     * @return void
     */
    public function __unset($name)
    {
        $this->offsetUnset($name);
    }

    /**
     * @return bool
     */
    public function rowExistsInDatabase()
    {
        return $this->primaryKeyData !== null;
    }

    /**
     * @throws Exception\RuntimeException
     */
    protected function processPrimaryKeyData()
    {
        $this->primaryKeyData = [];
        foreach ($this->primaryKeyColumn as $column) {
            if (! isset($this->data[$column])) {
                throw new Exception\RuntimeException(
                    'While processing primary key data, a known key ' . $column . ' was not found in the data array'
                );
            }
            $this->primaryKeyData[$column] = $this->data[$column];
        }
    }
}
