/*
 Copyright (c) 2013, Oracle and/or its affiliates. All rights
 reserved.
 
 This program is free software; you can redistribute it and/or
 modify it under the terms of the GNU General Public License
 as published by the Free Software Foundation; version 2 of
 the License.
 
 This program 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 General Public License for more details.
 
 You should have received a copy of the GNU General Public License
 along with this program; if not, write to the Free Software
 Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 02110-1301  USA
 */


/* Simple Twitter-like application for mysql-js
 *
 * This depends on the schema defined in tweet.sql
 *
 * It connects to the database using the properties defined in
 * tweet.properties.js
 *
 *
 */

'use strict';

var http   = require('http'),
    assert = require('assert'),
    url    = require('url'),
    nosql  = require('../..'),
    udebug = unified_debug.getLogger("tweet.js"),
    getProperties = require("./tweet.properties.js").getProperties,
    getDefaultAdapter = require("./tweet.properties.js").getDefaultAdapter,
    adapter = getDefaultAdapter(),
    mainLoopComplete, parse_command, allOperations, operationMap;

//////////////////////////////////////////
/*  User Domain Object Constructors.

    These can be called in two different ways: first, by user code, 
    where they might take function arguments.  But secondly, when the
    mapping layer reads a row of a mapped table from the database, 
    it calls the constructor *with no arguments*.
    
    So the constructors must test whether the first argument === undefined. 
    If the constructor fails to do this, and overwrites the value just read
    from the database, the read operation will fail with error WCTOR.
*/

function Author(user_name, full_name) {
  if(user_name !== undefined) {
    this.user_name = user_name;
    this.full_name = full_name;
  }
}

function Tweet(author, message) {
  if(author !== undefined) {
    this.date_created = new Date();
    this.author = author;
    this.message = message;
  }
}

function HashtagEntry(tag, tweet) {
  if(tag !== undefined) {
    this.hashtag = tag;
    this.tweet_id = tweet.id;
  }
}

function Mention(at_user, tweet) {
  if(at_user !== undefined) {
    this.at_user = at_user;
    this.tweet_id = tweet.id;
  }
}

function Follow(follower, followed) {
  if(follower !== undefined) {
    this.follower = follower;
    this.followed = followed;
  }
}

Follow.prototype.toString = function() {
  return this.follower + " follows " + this.followed;
};

// OperationResponder is a common interface over 
// two sorts of responses (console and HTTP)

function ConsoleOperationResponder(operation) {
  udebug.log("new ConsoleOperationResponder");
  this.operation  = operation;
  this.error      = false;
}

ConsoleOperationResponder.prototype.setError = function(e) {
 this.error = e;
 udebug.log(e.stack);
};

ConsoleOperationResponder.prototype.write = function(x) {
  console.log(x);
};

ConsoleOperationResponder.prototype.close = function() {
    if(this.error) { console.log("Error", this.error); }
    else           { console.log("Success", this.operation.result); }
    this.operation.session.close(mainLoopComplete);
};


function HttpOperationResponder(operation, httpResponse) {
  udebug.log("new HttpOperationResponder");
  this.operation    = operation;
  this.httpResponse = httpResponse;
  this.statusCode   = 200;
}

HttpOperationResponder.prototype.setError = function(e) { 
  this.statusCode = 500;
  this.operation.result = e;
};

HttpOperationResponder.prototype.write = function(x) {
  this.httpResponse.write(x);
};

HttpOperationResponder.prototype.close = function() {
  this.httpResponse.statusCode = this.statusCode;
  this.write(JSON.stringify(this.operation.result));
  this.write("\n");
  this.httpResponse.end();
  this.operation.session.close();
}; 

// ======== Callbacks run at various phases of a database operation ======= //

/* mainLoopComplete() callback.  This runs after the single operation 
   given on the command line, or after the web server has shut down.
*/
function mainLoopComplete() {
  console.log("--FINISHED--");
  process.exit(0);
}

/* onOpenSession(): bridge from openSession() to operation.run()
*/
function onOpenSession(session, operation) {
  udebug.log("onOpenSession");
  assert(session);
  operation.session = session;
  operation.run(session);
}


/* extractTags() returns arrays of #hashtags and @mentions present in a message 
*/
function extractTags(message) {
  var words, word, tags, tag;
  tags = {"hash" : [], "at" : [] };
  words = message.split(/\s+/);
  word = words.pop();
  while(word !== undefined) {
    if(word.charAt(0) == "#") {
      tag = /#(\w+)/.exec(word)[1];
      if(tags.hash.indexOf(tag) == -1) {
        tags.hash.push(tag);
      }
    }
    else if(word.charAt(0) == "@") {
      tag = /@(\w+)/.exec(word)[1];
      if(tags.at.indexOf(tag) == -1) {
        tags.at.push(tag);
      }
    }
    word = words.pop();
  }
  return tags;
}

/* Takes query and params; builds an equal condition on each param.
*/
function buildQueryEqual(query, params) {
  var field;
  for(field in params) {
    if(params.hasOwnProperty(field) && query[field]) { 
      query.where(query[field].eq(query.param(field)));
    }
  }
}


////////////////////////////////
// BASIC OPERATIONS
//
// All actions (insert, delete, start the http server) are represented
// by instances of Operation.
// 
// Each instance has several properties defined in the Operation constructor,
// and a run() method.  
//
// A multi-step operation might have additional properties used to pass data
// from one step to the next
////////////////////////////////

function Operation() {
  this.run          = {};    // a run() method
  this.session      = {};    // This will be set in onOpenSession
  this.responder    = {};    // OperationResponder for this operation
  this.result       = {};    // Result object to be returned to the user
  this.data         = {};    // HTTP POST data
  this.isServer     = false; // True only for the start-HTTP-server operation
  this.queryClass   = null;  // Mapped class to use in creating a query
  this.queryParams  = null;  // Parameters to use for query

  /* Functions closed over self */
  var self = this;
  
  this.setResult = function(value) {
    udebug.log("Operation setResult", value);
    self.result = value;
    return value;
  };
  
  this.onComplete = function() {
    udebug.log("Operation onComplete");
    self.responder.close();
  };

  this.onError = function(error) {
    udebug.log("Operation onError");
    self.responder.setError(error);
    self.responder.close();  
  };

  this.buildQuery = buildQueryEqual; 

  this.onQueryResults = function(error, results) { 
    if(error) { self.onError(error); } 
    else      { self.setResult(results); self.onComplete(); }
  };

  this.buildQueryAndPresentResults = function(session) {
    function onCreateQuery(query) {
      self.buildQuery(query, self.queryParams);
      query.execute(self.queryParams, self.onQueryResults);
    }
    session.createQuery(self.queryClass).then(onCreateQuery, self.onError);
  };
}

// Each Operation constructor takes (params, data), where "params" is an array
// of URL or command-line parameters.  "data" is the HTTP POST data.
// In the command line version, the POST data comes from the final parameter
//
// An Operation Constructor also has a signature property, which is an array
// of [ verb, noun, parameters, data ] used in the API.  "verb" is an HTTP
// verb; noun and parameters are separated by slashes in the URL.


/* Add a user 
*/
function AddUserOperation(params, data) {
  var author = new Author(params[0], data);
  Operation.call(this);    /* inherit */

  this.run = function(session) {
    session.persist(author).
      then(function() { return author; }).
      then(this.setResult).
      then(this.onComplete, this.onError);
  };
}
AddUserOperation.signature = [ "put", "user", "<user_name>", " << Full Name >>" ];


/* Profile a user based on username.
   This calls find(), then stores the result in self.object
*/
function LookupUserOperation(params, data) {
  Operation.call(this);    /* inherit */
  
  this.run = function(session) {
    session.find(Author, params[0]).
      then(this.setResult).
      then(this.onComplete, this.onError);      
  };
}
LookupUserOperation.signature = [ "get", "user", "<user_name>" ]; 


/* Delete a user, with cascading delete of tweets, mentions, and followers
*/
function DeleteUserOperation(params, data) { 
  Operation.call(this);    /* inherit */
  var author_name = params[0];
  
  this.run = function(session) {
    session.remove(Author, author_name).
      then(function() {return {"deleted": author_name};}).
      then(this.setResult).
      then(this.onComplete, this.onError);
  };
}
DeleteUserOperation.signature = [ "delete", "user", "<user_name>" ];


/* Insert a tweet.
     - Start a transaction.
     - Persist the tweet.  After persist, the tweet's auto-increment id 
       is available (and will be used by the HashtagEntry and Mention constructors).
     - Create & persist #hashtag & @user records (all in a single batch).
     - Increment the author's tweet count.
     - Then commit the transaction. 
*/
function InsertTweetOperation(params, data) {
  Operation.call(this);    /* inherit */

  var session;
  var message = data;
  var authorName = params[0];
  var tweet = new Tweet(authorName, message);

  this.run = function(session) {   /* Start here */

    function createTagEntries() {
      udebug.log("onTweetCreateTagEntries");
      var batch, tags, tag, tagEntry;

      /* Store all #hashtag and @mention entries in a single batch */

      tags = extractTags(message);
      batch = session.createBatch();

      tag = tags.hash.pop();   // # hashtags
      while(tag !== undefined) {
        tagEntry = new HashtagEntry(tag, tweet);
        batch.persist(tagEntry);
        tag = tags.hash.pop(); 
      }
      
      tag = tags.at.pop();   // @ mentions
      while(tag !== undefined) {
        tagEntry = new Mention(tag, tweet);
        batch.persist(tagEntry);
        tag = tags.at.pop();
      }

      return batch.execute();    
    }

    function incrementTweetCount(author) {
      author.tweets++;
      return session.save(author);
    }
  
    function commitOnSuccess(value) {
      return session.currentTransaction().commit(); 
    }

    function rollbackOnError(err) {
      return session.currentTransaction().rollback();
    }

    session.currentTransaction().begin();
    session.persist(tweet).
      then(function() { return session.find(Author, authorName);}).
      then(incrementTweetCount).
      then(createTagEntries).
      then(commitOnSuccess, rollbackOnError).
      then(function() {return tweet;}).
      then(this.setResult).
      then(this.onComplete, this.onError);
  };
}
InsertTweetOperation.signature = [ "post", "tweet", "<author>", " << Message >>" ];


/* Delete a tweet.
   Relies on cascading delete to remove hashtag entries.
*/
function DeleteTweetOperation(params, data) { 
  Operation.call(this);    /* inherit */
  var tweet_id = params[0];

  this.run = function(session) {
    session.remove(Tweet, tweet_id).
      then(function() { return { "deleted" : tweet_id };}).
      then(this.setResult).
      then(this.onComplete, this.onError);
  };
}
DeleteTweetOperation.signature = [ "delete", "tweet", "<tweet_id>" ];


/* Get a tweet by id.
*/
function ReadTweetOperation(params, data) {
  Operation.call(this);

  this.run = function(session) {
    session.find(Tweet, params[0]).
      then(this.setResult).
      then(this.onComplete, this.onError);
  };
}
ReadTweetOperation.signature = [ "get", "tweet", "<tweet_id>" ];


/* Make user A a follower of user B
*/
function FollowOperation(params, data) {
  Operation.call(this);
  
  this.run = function(session) {
    var record = new Follow(params[0], params[1]);
    session.persist(record).
      then(function() { return record; }).
      then(this.setResult).
      then(this.onComplete, this.onError);
  };
}
FollowOperation.signature = [ "put", "follow", "<user_follower> <user_followed>"];


/* Who follows a user?
*/
function FollowersOperation(params, data) {
  Operation.call(this);  
  this.queryClass = Follow;
  this.queryParams =  {"followed" : params[0]};
  this.run = this.buildQueryAndPresentResults;
}
FollowersOperation.signature = [ "get", "followers", "<user_name>" ];


/* Whom does a user follow?
*/
function FollowingOperation(params, data) {
  Operation.call(this);  
  this.queryClass = Follow;
  this.queryParams = { "follower" : params[0] };
  this.run = this.buildQueryAndPresentResults;
}
FollowingOperation.signature = [ "get", "following" , "<user_name>" ];


/* Get the N most recent tweets
*/
function RecentTweetsOperation(params, data) {
  Operation.call(this);
  var limit = params && params[0] ? Number(params[0]) : 20;
  this.queryClass = Tweet; // use id > 0 to coerce a descending index scan on id
  this.queryParams =  {"zero" : 0, "order" : "desc", "limit" : limit};
  this.buildQuery = function(query, params) {
    query.where(query.id.gt(query.param("zero")));
  };
  this.run = this.buildQueryAndPresentResults;
}
RecentTweetsOperation.signature = [ "get", "tweets-recent" , "<count>" ];


/* Last 20 tweets from a user
*/
function TweetsByUserOperation(params, data) {
  Operation.call(this);
  this.queryClass = Tweet;
  this.queryParams = {"author" : params[0] , "order" : "desc" , "limit" : 20 };
  this.run = this.buildQueryAndPresentResults;
}
TweetsByUserOperation.signature = [ "get" , "tweets-by" , "<user_name>" ];


/* Common callback for @user and #hashtag queries 
*/
function fetchTweetsInBatch(operation, scanResults) {
  var batch, r;
  var resultData = [];

  function addTweetToResults(e, tweet) {
    if(tweet && ! e) resultData.push(tweet);
  }
  
  batch = operation.session.createBatch();
  if(scanResults.length) {
    r = scanResults.shift();
    while(r) {
      batch.find(Tweet, r.tweet_id, addTweetToResults);
      r = scanResults.shift();
    }
  }
  batch.execute(function(error) {
    if(error) { operation.onError(error); }
    else      { operation.setResult(resultData); operation.onComplete(); }
  });
}

/* Call fetchTweetsInBatch() closed over operation
*/
function fetchTweets(operation) {
  return function(error, results) {
    if(error) {
      operation.onError(error);
    } else {
      fetchTweetsInBatch(operation, results);
    }
  };
}

/* Last 20 tweets @user
*/
function TweetsAtUserOperation(params, data) {
  Operation.call(this);
  var self = this;
  var tag = params[0];
  if(tag.charAt(0) == "@") {    tag = tag.substring(1);   }
  this.queryClass = Mention;
  this.queryParams = {"at_user" : tag, "order" : "desc" , "limit" : 20 };
  this.onQueryResults = fetchTweets(this);
  this.run = this.buildQueryAndPresentResults; 
}
TweetsAtUserOperation.signature = [ "get" , "tweets-at" , "<user_name>" ];


/* Last 20 tweets with hashtag
*/
function TweetsByHashtagOperation(params, data) {
  Operation.call(this);
  var self = this;
  var tag = params[0];
  if(tag.charAt(0) == "#") {    tag = tag.substring(1);   }
  this.queryClass = HashtagEntry;
  this.queryParams = {"hashtag" : tag, "order" : "desc" , "limit" : 20 };
  this.onQueryResults = fetchTweets(this);  
  this.run = this.buildQueryAndPresentResults; 
}
TweetsByHashtagOperation.signature = [ "get" , "tweets-about", "<hashtag>" ];


/* The web server Operation is just slightly different; 
   it defines isServer = true, 
   and it defines a runServer() method, which takes the SessionFactory.
*/
function RunWebServerOperation(cli_params, cli_data) {
  var port = cli_params[0];
  var sessionFactory;
  this.isServer = true;
  
  function serverRequestLoop(request, response) {
    var params, data;

    function hangup(code) { 
      response.statusCode = code;
      response.end();
    }

    function runOperation() {
      var operation = parse_command(request.method, params, data);
      if(operation && ! operation.isServer) {
        operation.responder = new HttpOperationResponder(operation, response);
        sessionFactory.openSession(null).then(function(session) {
          onOpenSession(session, operation); });
      } 
      else hangup(400);
    }

    data = "";
    params = url.parse(request.url).pathname.split("/");
    params.shift();
    
    request.setEncoding('utf8');
    function gatherData(chunk) {    data += chunk;    }
    request.on('data', gatherData);
    request.on('end', runOperation);
  }
  
  this.runServer = function(_sessionFactory) {
    sessionFactory = _sessionFactory;
    http.createServer(serverRequestLoop).listen(port);
    console.log("Server started on port", port);  
  };
}
RunWebServerOperation.signature = [ "start" , "server" , "<server_port_number>" ];


function HelpOperation(params, data) {
  Operation.call(this);
  
  this.run = function() {
    this.responder.write(operationMap.http_help);
    this.responder.close();
  };
}
HelpOperation.signature = [ "get" , "help" ];


function parse_command(method, params, data) {
  var verb, noun, opIdx, opConstructor, operation;
  verb = method.toLocaleLowerCase();
  noun = params.shift();
  operation = null;
  udebug.log("parse_command", verb, noun, params);
  if(operationMap.verbs[verb] && operationMap.verbs[verb][noun]) {
    opIdx = operationMap.verbs[verb][noun][0];
    opConstructor = allOperations[opIdx];
    if(opConstructor) {
      operation = {};
      opConstructor.call(operation, params, data);
    }
  }
  
  return operation;
}


function get_cmdline_args() { 
  var i, k, spaces, val, verb, operation;
  var cmdList = [];
  var usageMessage = 
    "Usage: node tweet {options} {command} {command arguments}\n" +
    "         -a <adapter>:  run using the named adapter (default: "+adapter+")\n"+
    "         -h or --help: print this message\n" +
    "         -d or --debug: set the debug flag\n" +
    "               --detail: set the detail debug flag\n" +
    "               -df <file>: enable debug output from <file>\n" +
    "\n" +
    "  COMMANDS:\n" + operationMap.cli_help;
  
  for(i = 2; i < process.argv.length ; i++) {
    val = process.argv[i];
    switch (val) {
      case '-a':
        adapter = process.argv[++i];
        break;
      case '--debug':
      case '-d':
        unified_debug.on();
        unified_debug.level_debug();
        break;
      case '--detail':
        unified_debug.on();
        unified_debug.level_detail();
        break;
      case '-df':
        unified_debug.set_file_level(process.argv[++i], 5);
        break;
      case '--help':
      case '-h':
        break;
      default:
        cmdList.push(val);
    }
  }

  if(cmdList.length) {
    verb = cmdList.shift();
    /* Use final command line argument as "data" */    
    operation = parse_command(verb, cmdList, cmdList[cmdList.length-1]);
  }

  if(operation) {
    return operation;
  }
   
  console.log(usageMessage);
  process.exit(1);
}


/* Run a single operation specified on the command line
*/
function runCmdlineOperation(sessionFactory, operation) {
  udebug.log("runCmdlineOperation");
  if(operation.isServer) {
    operation.runServer(sessionFactory);
  }
  else {
    operation.responder = new ConsoleOperationResponder(operation);
    sessionFactory.openSession(null).then(function(session) {
      onOpenSession(session, operation); 
    });
  }
}

var allOperations = [ 
  AddUserOperation,
  LookupUserOperation,
  DeleteUserOperation,
  InsertTweetOperation,
  ReadTweetOperation,
  DeleteTweetOperation,
  FollowOperation,
  FollowersOperation, 
  FollowingOperation,
  TweetsByUserOperation,
  TweetsAtUserOperation,
  TweetsByHashtagOperation,
  RecentTweetsOperation,
  RunWebServerOperation,
  HelpOperation
];

function prepareOperationMap() {
  var idx, sig, verb, data;
  operationMap = {};
  operationMap.cli_help = "";
  operationMap.http_help = "";
  operationMap.verbs = { "get":{}, "post":{}, "put":{}, "delete":{} , "start":{} };
  
  for(idx in allOperations) {
    if(allOperations.hasOwnProperty(idx)) {
      sig = allOperations[idx].signature;
      operationMap.verbs[sig[0]][sig[1]] = [ idx, sig[2], sig[3] ];
      operationMap.cli_help += "         " + sig.join(" ") + "\n";
      verb = sig.shift().toLocaleUpperCase();
      data = sig[2];
      operationMap.http_help += verb +" /"+ sig[0] +"/"+ sig[1];
      operationMap.http_help += data ? data + "\n" : "\n";
    }
  }
}

// *** Main program starts here ***

/* Global Variable Declarations */
var mappings, dbProperties, operation;

prepareOperationMap();

/* This may have the side effect of changing the adapter */
operation = get_cmdline_args();

/* Connection Properties */
dbProperties = getProperties(adapter);

// Map SQL Tables to JS Constructors using default mappings
mappings = [];
mappings.push(new nosql.TableMapping('tweet').applyToClass(Tweet));
mappings.push(new nosql.TableMapping('author').applyToClass(Author));
mappings.push(new nosql.TableMapping('hashtag').applyToClass(HashtagEntry));
mappings.push(new nosql.TableMapping('follow').applyToClass(Follow));
mappings.push(new nosql.TableMapping('mention').applyToClass(Mention));

function run(sessionFactory) {
  runCmdlineOperation(sessionFactory, operation);
}

nosql.connect(dbProperties, mappings).then(run);

