classdef LTPDAConnectionManager < handle

  properties(SetAccess=private)

    connections = {};
    credentials = {};

  end % private properties

  properties(Dependent=true)

    credentialsExpiry; % seconds
    cachePassword; % 0=no 1=yes 2=ask
    maxConnectionsNumber;

  end % dependent properties

  methods(Static)

    function reset()
    % RESET Resets the state of the connection manager.
    %
    % This static method removes the LTPDAConnectionManager instance data from
    % the appdata storage. Causes the reset of the credentials cache and the
    % removal of all the connections from the connection pool.

      rmappdata(0, LTPDAConnectionManager.appdataKey);
    end


    function key = appdataKey()
    % APPDATAKEY Returns the key used to store instance data in appdata.
    %
    % This is defined as static method, and not has an instance constant
    % property, to to be accessible by the reset static method.

      key = 'LTPDAConnectionManager';
    end

  end % static methods

  methods

    function cm = LTPDAConnectionManager()
    % LTPDACONNECTIONMANAGER Manages credentials and database connections.
    %
    % This constructor returns an handler to a LTPDAConnectionManager class
    % instance. Database connections can be obtained trough the obtained
    % object with the connect() method.
    %
    % The purpose of this class it to keep track of open database connections
    % and to cache database credentials. It must be used in all LTPDA toolbox
    % functions that required to obtain database connections. Its behaviour can
    % be configured via LTPDA toolbox user preferences. The object status is
    % persisted trough the appdata matlab facility.

      % load state from appdata
      acm = getappdata(0, cm.appdataKey());

      if isempty(acm)
        % take those from user preferences
        cm.credentials{end+1} = credentials('localhost', 'one');
        cm.credentials{end+1} = credentials('localhost', 'two', 'daniele');

        % store state in appdata
        setappdata(0, cm.appdataKey(), cm);

        import utils.const.*
        utils.helper.msg(msg.PROC1, 'new connection manager');
      else
        cm = acm;
      end
    end


    function val = get.credentialsExpiry(cm)
      % obtain from user preferences
      p = getappdata(0, 'LTPDApreferences');
      val = p.cm.credentialsExpiry;
    end


    function val = get.cachePassword(cm)
      % obtain from user preferences
      p = getappdata(0, 'LTPDApreferences');
      val = p.cm.cachePassword;
    end


    function val = get.maxConnectionsNumber(cm)
      % obtain from user preferences
      p = getappdata(0, 'LTPDApreferences');
      val = p.cm.maxConnectionsNumber;
    end


    function n = count(cm)
    % COUNT Returns the number of open connections in the connections pool.
    %
    % This method has the side effect of removing all closed connections from
    % the connections pool, so that the underlying objects can be garbage
    % collected.

      import utils.const.*

      % find closed connections in the pool
      mask = false(numel(cm.connections), 1);
      for kk = 1:numel(cm.connections)
        if cm.connections{kk}.isClosed()
          utils.helper.msg(msg.PROC1, 'connection id=%d closed', kk);
          mask(kk) = true;
        end
      end

      % remove them
      cm.connections(mask) = [];

      % count remainig ones
      n = numel(cm.connections);
    end

    function clear(cm)
    % CLEAR Removes all cached credentials from the connection manager.
      cm.credentials = {};
    end


    function conn = connect(cm, varargin)
    % CONNECT Uses provided credential to establish a database connection.
    %
    % CONNECT(hostname, database, username, password) Returns an object
    % implementing the java.sql.Connection interface handing a connection to
    % the specified database. Any of the parameter is optional. The user will
    % be queried for the missing information.
    %
    % The returned connection are added to a connections pool. When the number
    % of connections in the pool exceeds a configurable maximum, no more
    % connection are instantiated. Closed connections are automatically
    % removed from the pool.
    %
    % CONNECT(pl) Works as the above but the parameters are obtained from the
    % plist object PL. If the 'connection' parameter in the plist contains an
    % object implementing the java.sql.Connection interface, this object is
    % returned instead that opening a new connection. In this case the
    % connection in not added to the connection pool.

      import utils.const.*

      % save current credentials cache
      cache = cm.credentials;

      % count open connections in the pool
      count = cm.count();

      % check parameters
      if numel(varargin) == 1 && isa(varargin{1}, 'plist')

        % extract parameters from plist
        pl = varargin{1};

        % check if we have a connection parameter
        conn = find(pl, 'connection');
        if ~isempty(conn)
          % check that it implements java.sql.Connection interface
          if ~isa(conn, 'java.sql.Connection')
            error('### connection is not valid database connection');
          end
          % return this connection
          return;
        end

        % otherwise
        hostname = find(pl, 'hostname');
        database = find(pl, 'database');
        username = find(pl, 'username');
        password = find(pl, 'password');

        % if there is no hostname and database ignore other parameters
        if ~ischar(hostname) || ~ischar(database)
          varargin = {};
        end
        % password can not be null but can be an empty string
        if ~ischar(password)
          varargin = {hostname, database, username};
        else
          varargin = {hostname, database, username, password};
        end
      end

      % check number of connections
      if count > cm.maxConnectionsNumber
        error('### too many open connections');
      end

      % connect
      try
        conn = cm.getConnection(varargin{:});
      catch ex
        % restore our copy of the credentials cache
        utils.helper.msg(msg.PROC1, 'undo cache changes');
        cm.credentials = cache;

        % hide implementation details
        %ex.throwAsCaller();
        ex.rethrow()
      end
    end


    function close(cm, ids)
    % CLOSE Forces connections to be closed.
    %
    % In the case bugs in other routines working with database connections
    % produce orphan connections, this method can be used to force the close
    % of those connections.
    %
    % CLOSE(ids) Closes the connections with the corresponding IDs in the
    % connections pool. If no ID is given all connections in the pool are
    % closed.

      if nargin < 2
        ids = 1:numel(cm.connections);
      end
      cellfun(@close, cm.connections(ids));
    end


    function add(cm, c)
    % ADD Adds credentials to the credentials cache.
    %
    % This method can be used to initialize or add to the cache, credentials
    % that will be used in subsequent connections attempts. This method accepts
    % only credentials in the form of utils.jmysql.credentials objects.

      % check input arguments
      if nargin < 2 || ~isa(c, 'credentials')
        error('### invalid call');
      end

      % add to the cache
      cm.cacheCredentials(c);
    end

  end % methods

  methods(Access=private)

    function conn = getConnection(cm, varargin)
    % GETCONNECTION Where the implementation of the connect method really is.

      import utils.const.*

      % handle variable number of arguments
      switch numel(varargin)
        case 0
          [hostname, database, username] = cm.selectDatabase();
          conn = cm.getConnection(hostname, database, username);

        case 2
          conn = cm.getConnection(varargin{1}, varargin{2}, []);

        case 3
          % find credentials
          cred = cm.findCredentials(varargin{1}, varargin{2}, varargin{3});
          if isempty(cred)
            % no credentials found
            cred = credentials(varargin{1}, varargin{2}, varargin{3});
          else
            utils.helper.msg(msg.PROC1, 'use cached credentials');
          end

          cache = false;
          if numel(cred) > 1 || ~cred.complete
            % ask for which username and password to use
            [username, password, cache] = cm.inputCredentials(cred);

            % cache credentials
            cred = credentials(varargin{1}, varargin{2}, username);
            cm.cacheCredentials(cred);

            % add password to credentials
            cred.password = password;
          end

          % try to connect
          conn = cm.getConnection(cred.hostname, cred.database, cred.username, cred.password);

          % cache password
          if cache
            utils.helper.msg(msg.PROC1, 'cache password');
            cm.cacheCredentials(cred);
          end

        case 4
          try
            % connect
            conn = connect(varargin{1}, varargin{2}, varargin{3}, varargin{4});

            % cache credentials without password
            cred = credentials(varargin{1}, varargin{2}, varargin{3}, []);
            cm.cacheCredentials(cred);

          catch ex
            % look for access denied errors
            if strcmp(ex.identifier, 'utils:jmysql:connect:AccessDenied')
              % ask for new new credentials
              utils.helper.msg(msg.PROC1, ex.message);
              conn = cm.getConnection(varargin{1}, varargin{2}, varargin{3});
            else
              % error out
              throw(MException('', '### connection error').addCause(ex));
            end
          end

          % add connection to pool
          utils.helper.msg(msg.PROC1, 'add connection to pool');
          cm.connections{end+1} = conn;

        otherwise
          error('### invalid call')
      end

    end


    function ids = findCredentialsId(cm, varargin)
    % FINDCREDENTIALSID Find credentials in the cache and returns their IDs.

      import utils.const.*
      ids = [];

      for kk = 1:numel(cm.credentials)
        % invalidate expired passwords
        if expired(cm.credentials{kk})
          utils.helper.msg(msg.PROC1, 'cache entry id=%d expired', kk);
          cm.credentials{kk}.password = [];
          cm.credentials{kk}.expiry = 0;
        end

        % match input with cache
        if match(cm.credentials{kk}, varargin{:})
          ids = [ ids kk ];
        end
      end
    end


    function cred = findCredentials(cm, varargin)
    % FINDCREDENTIALS Find credentials in the cache and returns them in a list.

      % default
      cred = [];

      % search
      ids = findCredentialsId(cm, varargin{:});

      % return an array credentials
      if ~isempty(ids)
        cred = [ cm.credentials{ids} ];
      end
    end


    function cacheCredentials(cm, c)
    % CACHECREDENTIALS Adds to or updates the credentials cache.

      import utils.const.*

      % find entry to update
      id = findCredentialsId(cm, c.hostname, c.database, c.username);

      % sanity check
      if numel(id) > 1
        error('### more than one cache entry for %s', char(c, 'short'));
      end

      % set password expiry time
      if ischar(c.password)
        c.expiry = double(time()) + cm.credentialsExpiry;
      end

      if isempty(id)
        % add at the end
        utils.helper.msg(msg.PROC1, 'add cache entry %s', char(c));
        cm.credentials{end+1} = c;
      else
        % update only if the cached informations are less than the one we have
        if ~complete(cm.credentials{id})
          utils.helper.msg(msg.PROC1, 'update cache entry id=%d %s', id, char(c));
          cm.credentials{id} = c;
        else
          % always update expiry time
          cm.credentials{id}.expiry = c.expiry;
        end
      end
    end


    function [username, password, cache] = inputCredentials(cm, cred)
    % INPUTCREDENTIALS Queries the user for database username and password.

      % this is a stubb that must be replaced by a graphical interface

      % build a cell array of usernames and passwords
      users = { cred(:).username };
      passw = { cred(:).password };

      % default to the latest used username
      [e, ids] = sort([ cred(:).expiry ]);
      default = users{ids(1)};

      username = choose('Username', users, default);

      % pick the corresponding password
      ids = find(strcmp(users, username));
      if ~isempty(ids)
        default = passw{ids(1)};
      else
        default = [];
      end

      password = ask('Password', '');

      if cm.cachePassword == 2
        cache    = ask('Store credentials', 'n');
        if ~isempty(cache) && cache(1) == 'y'
          cache = true;
        else
          cache = false;
        end
      else
        cache = logical(cm.cachePassword);
      end
    end


    function [hostname, database, username] = selectDatabase(cm)
    % SELECTDATABASE Makes the user choose to which database connect to.

      % this is a stubb that must be replaced by a graphical interface

      for kk = 1:numel(cm.credentials)
        fprintf('% 2d.  %s\n', char(cm.credentials{kk}));
      end
      fprintf('%d. NEW (default)\n', numel(cm.credentials)+1);
      str = input('Select connection:  ', 's');
      if isempty(str)
        id = numel(cm.credentials)+1;
      else
        id = eval(str);
      end
      if id > numel(cm.credentials)
        hostname = input('Hostname:  ', 's');
        database = input('Database:  ', 's');
        username = [];
      else
        hostname = cm.credentials{kk}.hostname;
        database = cm.credentials{kk}.database;
        username = cm.credentials{kk}.username;
      end
    end

  end % private methods

end % classdef


function conn = connect(hostname, database, username, password)
% CONNECT Opens a connection to the given database.
%
% This function returns a Java object implementing the java.sql.Connection
% interface connected to the given database using the provided credentials.
% If the connection fails because the given username and password pair is not
% accepted by the server an utils:jmysql:connect:AccessDenied error is thrown.

  % this should become utils.jmysql.connect

  % informative message
  import utils.const.*
  utils.helper.msg(msg.PROC1, 'connection to mysql://%s/%s username=%s', hostname, database, username);

  % connection credential
  uri = sprintf('jdbc:mysql://%s/%s', hostname, database);
  db = javaObject('com.mysql.jdbc.Driver');
  pl = javaObject('java.util.Properties');
  pl.setProperty(db.USER_PROPERTY_KEY, username);
  pl.setProperty(db.PASSWORD_PROPERTY_KEY, password);

  try
    % connect
    conn = db.connect(uri, pl);
  catch ex
    % haven't decided yet if this code should be here or higher in the stack
    if strcmp(ex.identifier, 'MATLAB:Java:GenericException')
      % exceptions handling in matlab sucks
      if ~isempty(strfind(ex.message, 'java.sql.SQLException: Access denied'))
        throw(MException('utils:jmysql:connect:AccessDenied', '### access denied').addCause(ex));
      end
    end
    rethrow(ex);
  end
end


function str = ask(msg, default)
  str = input(sprintf('%s (default: %s):  ', msg, default), 's');
  if isempty(str)
    str = default;
  end
  if ~ischar(str)
    str = char(str);
  end
end

function str = choose(msg, choices, default)
  options = sprintf('%s, ', choices{:});
  options = options(1:end-2);
  str = input(sprintf('%s (options: %s, default: %s):  ', msg, options, default), 's');
  if isempty(str)
    str = default;
  end
  if ~ischar(str)
    str = char(str);
  end
end
