<?php namespace HashOver;

// Copyright (C) 2010-2019 Jacob Barkdull
// This file is part of HashOver.
//
// HashOver is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// HashOver is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with HashOver. If not, see <http://www.gnu.org/licenses/>.


class Database extends Secrets
{
protected $setup;
protected $database;

// Initial comment data
protected $commentsTable = array (
'domain' => '',
'thread' => '',
'comment' => '',
'body' => '',
'status' => '',
'date' => '',
'name' => '',
'password' => '',
'login_id' => '',
'email' => '',
'encryption' => '',
'email_hash' => '',
'notifications' => '',
'website' => '',
'ipaddr' => '',
'likes' => 0,
'dislikes' => 0
);

public function __construct (Setup $setup)
{
// Store parameters as properties
$this->setup = $setup;

// Check if database type is SQLite
if ($this->databaseType === 'sqlite') {
// If so, construct SQLite file name
$file = sprintf ('%s/%s.sqlite',
$setup->commentsRoot, $this->databaseName
);

// Instantiate an SQLite data object
$this->database = new \PDO ('sqlite:' . $file);

// And change file permissions
@chmod ($file, 0600);
} else {
// If not, create SQL server connection statement
$connection = implode (';', array (
'host=' . $this->databaseHost,
'port=' . $this->databasePort,
'dbname=' . $this->databaseName,
'charset=' . $this->databaseCharset
));

// And create SQL server data object
$this->database = new \PDO (
// PDO driver and connection details
$this->databaseType . ':' . $connection,

// Database user as configured
$this->databaseUser,

// Database password as configured
$this->databasePassword,

// We want the number of found (matched) rows,
// not the number of changed rows
array (
\PDO::MYSQL_ATTR_FOUND_ROWS => true
)
);
}
}

// Prepares and executes an SQL statement
protected function executeStatement ($statement, $data = null)
{
// Prepare statement
$prepare = $this->database->prepare ($statement);

// Check if prepare was successful
if ($prepare !== false) {
// If so, attempt to execute statement
$execute = $prepare->execute ($data);

// And return statement object if execute was successful
if ($execute !== false) {
return $prepare;
}
}

return false;
}

// Returns a given thread or thread from setup
protected function getCommentThread ($thread)
{
// Return thread from setup if thread is auto
if ($thread === 'auto') {
return $this->setup->threadName;
}

// Otherwise, return given thread
return $thread;
}

// Creates table creation statement from array
protected function creationArray (array $columns)
{
// Initial statement
$statement = array ();

// Create a statement using specific columns
foreach ($columns as $name => $value) {
// Decide type based on value type
$type = is_numeric ($value) ? 'INTEGER' : 'TEXT';

// And add column to statement
$statement[] = sprintf ('`%s` %s', $name, $type);
}

return $statement;
}

// Reads and returns specific metadata from database
public function readMeta ($name, $thread = 'auto', $global = false)
{
// Get thread
$thread = $this->getCommentThread ($thread);

// Prepared data for statement execution
$prepared = array (
'domain' => $this->setup->website
);

// Choose statement for supported metadata
switch ($name) {
// Latest comments
case 'latest-comments': {
// Initial statement
$statements = array (
'SELECT * FROM `comments`',
'WHERE (status IS NULL OR status="approved")',
'AND domain=:domain'
);

// Check if we are getting metadata from multiple threads
if ($global === false) {
// If so, add thread condition to statement
$statements[] = 'AND thread=:thread';

// And add thread to prepared data
$prepared['thread'] = $thread;
}

// Sort comments by date
$statements[] = 'ORDER BY `date` DESC';

// Limit comments to configured maximum
$statements[] = 'LIMIT ' . $this->setup->latestMax;

break;
}

// All others, just try to read as-is
default: {
// Initial statement
$statements = array (
sprintf ('SELECT * FROM `%s`', $name),
'WHERE domain=:domain'
);

// Check if we are getting metadata from multiple threads
if ($global === false) {
// Add thread condition to statement
$statements[] = 'AND thread=:thread';

// And add thread to prepared data
$prepared['thread'] = $thread;
}

break;
}
}

// Convert statements array into string
$statement = implode (' ', $statements);

// Query statement
$results = $this->executeStatement ($statement, $prepared);

// Check if the query was successful
if ($results !== false) {
// If so, attempt to get all metadata
$fetch_all = $results->fetchAll (\PDO::FETCH_ASSOC);

// Check if metadata read successfully
if (!empty ($fetch_all)) {
// If so, return first for if metadata is page info
if ($name === 'page-info') {
return $fetch_all[0];
}

// Otherwise, return all metadata
return $fetch_all;
}
}

return false;
}

// Creates comment table if it doesn't exist
protected function createTable ($name, array $columns)
{
// Statement for creating an initial table
$statement = sprintf (
'CREATE TABLE IF NOT EXISTS `%s` (%s)',
$name, implode (', ', $columns)
);

// Execute statement
$created = $this->executeStatement ($statement);

// Throw exception on failure
if ($created === false) {
throw new \Exception (sprintf (
'Failed to create "%s" table!', $name
));
}
}

// Get formatted string of array keys
protected function formatKeys (array $data, $format, $glue = ', ')
{
// Initial formatted output
$formatted = array ();

// Add each formatted array key
foreach (array_keys ($data) as $key) {
$formatted[] = str_replace ('%s', $key, $format);
}

// And convert formatted array to string
$statement = implode ($glue, $formatted);

return $statement;
}

// Saves metadata to specific metadata JSON file
public function saveMeta ($name, array $data, $thread = 'auto')
{
// Get thread
$thread = $this->getCommentThread ($thread);

// Add website domain and thread to data
$data = array_merge (array (
'domain' => $this->setup->website,
'thread' => $thread
), $data);

// Get metadata table creation statements
$creation_statement = $this->creationArray ($data);

// Attempt to create metadata table
$this->createTable ($name, $creation_statement);

// Update data in specific columns
$save = implode (' ', array (
sprintf ('UPDATE `%s`', $name),
'SET ' . $this->formatKeys ($data, '%s=:%s'),
'WHERE domain=:domain',
'AND thread=:thread'
));

// Execute statement
$saved = $this->executeStatement ($save, $data);

// Check if we failed to update any rows
if ($saved !== false and $saved->rowCount () === 0) {
// If so, create metadata column names list
$column_names = $this->formatKeys ($data, '`%s`');

// Create metadata column values list
$column_values = $this->formatKeys ($data, ':%s');

// Insert data into specific columns
$save = sprintf (
'INSERT INTO `%s` (%s) VALUES (%s)',
$name, $column_names, $column_values
);

// Execute statement
$saved = $this->executeStatement ($save, $data);
}

// Throw exception on failure
if ($saved === false) {
throw new \Exception (
'Failed to save metadata!'
);
}
}

// Writes new or changed content to database
public function write ($action, $thread, array $data, $alt = false)
{
// Get thread
$thread = $this->getCommentThread ($thread);

// Add website domain and thread to data
$data = array_merge ($data, array (
'domain' => $this->setup->website,
'thread' => $thread
));

// Decide on an action
switch ($action) {
// Action for posting a comment
case 'insert': {
// Construct SQL statement
$query = sprintf (
// Insertion statement
'INSERT INTO `comments` VALUES (%s)',

// Get list of table columns
$this->formatKeys ($this->commentsTable, ':%s')
);

break;
}

// Action for editing a comment
case 'update': {
// Columns to query
$columns = implode (', ', array (
'body=:body',
'status=:status',
'name=:name',
'password=:password',
'email=:email',
'encryption=:encryption',
'email_hash=:email_hash',
'notifications=:notifications',
'website=:website',
'likes=:likes',
'dislikes=:dislikes'
));

// Construct SQL statement
$query = implode (' ', array (
'UPDATE `comments`',
'SET ' . $columns,
'WHERE domain=:domain',
'AND thread=:thread',
'AND comment=:comment'
));

break;
}

// Action for deleting a comment
case 'delete': {
// Check if we're actually deleting the comment
if ($alt === true) {
// If so, use delete statement
$query = implode (' ', array (
'DELETE FROM `comments`',
'WHERE domain=:domain',
'AND thread=:thread',
'AND comment=:comment'
));
} else {
// If not, use status update statement
$query = implode (' ', array (
'UPDATE `comments`',
'SET status=:status',
'WHERE domain=:domain',
'AND thread=:thread',
'AND comment=:comment'
));
}

break;
}
}

// Execute statement
$queried = $this->executeStatement ($query, $data);

// Throw exception on failure
if ($queried === false) {
throw new \Exception (
'Failed to write to database!'
);
}

return true;
}

// Check if comments table exists
public function checkThread ()
{
// Create comments table creation statements
$statement = $this->creationArray ($this->commentsTable);

// Create initial comments if it doesn't exist
$this->createTable ('comments', $statement);
}

// Queries unique rows as of a specific column an array
protected function queryColumn ($column)
{
// Select unique thread names
$results = $this->executeStatement (sprintf (
'SELECT DISTINCT `%s` FROM `comments`', $column
));

// Check if query was successful
if ($results !== false) {
// If so, fetch all rows in column
$fetch_all = $results->fetchAll (\PDO::FETCH_ASSOC);

// Return column as array
return array_column ($fetch_all, $column);
}

// Otherwise, return an empty array
return array ();
}

// Queries an array of websites
public function queryWebsites ()
{
return $this->queryColumn ('domain');
}

// Queries an array of comment threads
public function queryThreads ()
{
return $this->queryColumn ('thread');
}

// These methods are not necessary in SQL
public function addLatestComment () {}
public function removeFromLatest () {}
}