# -*- coding: utf-8 -*-
"""Copyright 2014-2018 Stephen Rigden

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, either version 3 of the License, or
(at your option) any later version.

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, see <http://www.gnu.org/licenses/>.

Warning
   Pigjar uses JSON for serialization of data. The design avoids a
   potential security vulnerability by explicitly matching names in the JSON
   stream to names of classes known to this program.

   Every pigjar class must have a json_encode method.

   The decoder function must explicitly itemize every '__class__' tag created by
   the json_encode methods.
"""
from __future__ import annotations

import decimal
import json
import logging
import os
import re
import shutil
import warnings
from typing import (List, Tuple, Any, Union, Dict, Optional, Sequence)

# noinspection PyPackageRequirements
import chrono

import account
import cashflow
import config
from error import PigjarIndelibleHistory, PigjarAccountHistoryKeyError
import event
import payee
import pig
import register
import support

control: 'control'

dumps_seen = set()  # pigjar-#414 Should be upper case
loads_seen = {}  # pigjar-#414 Should be upper case


class Database:
    """Pigjar's database.

    Attributes
        data_code_version: The version of the coded database schema. cf. data_version.
    """
    data_code_version: str = '1.0'

    def __init__(self) -> None:
        """
        Attributes
            reg: The combined register of records linking account transactions with
                pigs or other accounts.
            pigs:
            accs:
            last_account: Records the last account transaction register viewed by the user.
                This register can be opened at the beginning of the next session.
            payees:
            cashflow: The most recent cashflow.
            data_version: The version of the stored database schema. cf. data_code_version
        """
        self.reg = register.Register()
        self.pigs: List[pig.Pig] = []
        self.accs: List[account.Account] = []
        self.last_account = None
        self.payees: List[payee.Payee] = []
        self.cashflow = None
        self.data_version: str = None

    @classmethod
    def from_json(cls, **namespace: dict) -> 'Database':
        """Alternative constructor intended for use by json loads decoder.

        Args:
            **namespace (dict): Namespace of new database.
        """

        obj = cls()
        obj.__dict__ = namespace
        return obj

    def version_update(self, data_version):
        """Convert the database for use with a changed schema.

        Args:
            data_version:
        """
        # Upgrade from no version to to version '1.0'.
        if not data_version:  # pragma: no cover  (Single use code with validation)
            #  Map new keys to existing register history keys.
            reg_keys: Dict[str, str] = {}
            for old_reg_key in config.data.reg.history:
                reg_keys[old_reg_key] = support.unique_int()

            #  Map new keys to existing account history keys.
            acc_keys: Dict[str, dict] = {}
            for acc in config.data.accs:
                acc_keys[acc.inst_name]: Dict[str, str] = {}
                for old_hist_key in acc.history:
                    acc_keys[acc.inst_name][old_hist_key] = support.unique_int()

            #  Iterate register history and replace each record with updated keys
            new_history: Dict[str, support.RegisterHistoryRecord] = {}
            for old_reg_key in config.data.reg.history:
                new_reg_key = reg_keys[old_reg_key]
                new_history[new_reg_key] = config.data.reg.history[old_reg_key]

                from_acc_h_key = new_history[new_reg_key].from_acc_h_key
                from_acc = new_history[new_reg_key].from_acc
                new_history[new_reg_key].from_acc_h_key = acc_keys[
                    from_acc.inst_name][
                    from_acc_h_key]

                to_acc_h_key = new_history[new_reg_key].to_acc_h_key
                if to_acc_h_key:
                    to_acc = new_history[new_reg_key].to_pig_acc
                    new_history[new_reg_key].to_acc_h_key = acc_keys[
                        to_acc.inst_name][
                        to_acc_h_key]
            config.data.reg.history = new_history

            #  Iterate accounts history and replace each record with updated keys
            for acc in config.data.accs:
                new_history: Dict[str, support.AccountHistoryRecord] = {}
                for old_hist_key in acc.history:
                    new_h_key = acc_keys[acc.inst_name][old_hist_key]
                    new_history[new_h_key] = acc.history[old_hist_key]
                    new_reg_keys = [reg_keys[old_reg_key]
                                    for old_reg_key in acc.history[old_hist_key].reg_keys]
                    new_history[new_h_key].reg_keys = new_reg_keys
                acc.history = new_history

            # Validate integrity
            for acc in config.data.accs:
                acc.history_validate()

            # Record the data_version
            setattr(self, 'data_version', '1.0')

    @classmethod
    def open(cls) -> control.Database:
        """Alternative constructor for creation from permanent storage.

        Raises:
            FileNotFoundError
        """
        obj = loads(pjopen())
        return obj

    def __str__(self) -> str:
        return (("Pigjar database with {} Pig(s), {} Account(s), and {} "
                 "Payee(s).").
                format(len(self.pigs), len(self.accs), len(self.payees)))

    def __eq__(self, other: Any) -> bool:
        if type(other) is type(self):
            return self.__dict__ == other.__dict__
        return NotImplemented

    def json_encode(self) -> dict:
        """Create a json serialized string that can recreate self.

        Returns:
            dict: A dictionary with the keys __class__, __id__, __args__,
            and __kw__.
            '__class__' is a tag used to identify this class.
            '__id__' is used to memoize decoded objects.
            '__args__' can be used for positional arguments.
            '__kw__' can be used for keyword arguments.
            The presence of the four keys is used by the json decoder
            to identify the need for a custom handler that will be used to
            recreate this object.
        """

        dict_ = self.__dict__.copy()
        # noinspection PyTypeChecker
        return dict(__class__='control.Database',
                    __id__=id(self),
                    __args__=[],
                    __kw__=dict_)

    def advance_pig_dates(self, date: chrono.Date = None):
        """Advance all pigs to the given date.

        Args:
            date: Date to which the pigs should be advanced.

        Process any returned account transactions.
        """
        for piggy in self.pigs:
            transactions = piggy.advance_date(date)
            for acc, transaction_date, amount, payee_, memo in transactions:
                user_flag = ''
                registrations = [support.TransactionRegistration(amount, piggy,
                                                                 memo, user_flag), ]
                self.transaction_add(acc, transaction_date, registrations, memo, payee_)

    def transaction_commit(self, from_acc: 'account.Account', date: chrono.Date,
                           reg_updates: Sequence[support.TransactionRegistration],
                           memo: Optional[str] = None,
                           payee_: Optional[payee.Payee] = None,
                           from_acc_hist_key: Optional[str] = None):
        """Commit a newly entered transaction to the database atomically.

        Args:
            from_acc:
            date:
            reg_updates: A list of 'to' accounts and pigs.
            memo: For the 'from' account
            payee_:
            from_acc_hist_key: If present this method will delete the existing transaction
            prior to replacing it with the edited transaction. THe key of the deleted
            from-account record will be used for its replacement. However, to-account keys
            WILL NOT be maintained as to-accounts may have been added or removed..

        Raises:
            PigjarIndelibleHistory raised from call to transaction_delete.
            PigjarAccountHistoryKeyError raised from call to history_validate.
        """
        save_program_data()
        if from_acc_hist_key:
            self.transaction_delete(from_acc, from_acc_hist_key)
        self.transaction_add(from_acc, date, reg_updates, memo, payee_, from_acc_hist_key)

    def transaction_add(self, from_acc: 'account.Account', date: chrono.Date,
                        reg_updates: Sequence[support.TransactionRegistration],
                        memo: Optional[str] = None,
                        payee_: Optional[payee.Payee] = None,
                        from_acc_hist_key: Optional[str] = None):
        """Add a transaction maintaining database integrity.

        Args:
            from_acc: The 'from' account. (The 'one' of one to many)
            date: The date of the account debit/credit
            reg_updates: A list of support.TransactionRegistration objects.
            memo: User created memo
            payee_: Payee object
            from_acc_hist_key: If present this key will be used.

        Raises:
            PigjarAccountHistoryKeyError if the from_acc_hist_key is not unique.

        See "Database Links for Transaction.pdf" for a diagram showing the database structure
        for the account history, register history and pig amounts.
        """

        # Validate args and initialize locals
        reg_keys = []

        # Get new unique key or check provided key for uniqueness.
        if from_acc_hist_key:
            if from_acc_hist_key in from_acc.history:
                    msg = f"Proposed key '{from_acc_hist_key}' is not unique."
                    logging.critical(msg=msg)
                    raise PigjarAccountHistoryKeyError(msg)
        else:
            from_acc_hist_key = support.unique_int()

        for reg_update in reg_updates:
            reg_h_key = support.unique_int()
            reg_keys.append(reg_h_key)

            # Update 'to' pig or 'to' account.
            if isinstance(reg_update.to_pig_acc, account.Account):
                to_acc_payee = None  # An account transfer has no payee.
                to_acc_h_key = support.unique_int()
                h_rec = support.AccountHistoryRecord(date, [reg_h_key], support.UNRECONCILED,
                                                     to_acc_payee, reg_update.memo, )
                reg_update.to_pig_acc.history[to_acc_h_key] = h_rec
            else:  # It's a Pig object
                to_acc_h_key = None
                reg_update.to_pig_acc.spend -= reg_update.from_acc_debit

            # Update register for to account or pig
            self.reg.history[reg_h_key] = support.RegisterHistoryRecord(
                reg_update.from_acc_debit, from_acc, reg_update.to_pig_acc,
                reg_update.memo, reg_update.flag, from_acc_hist_key, to_acc_h_key)
            # update_balance won't work unless register history record is present.
            if isinstance(reg_update.to_pig_acc, account.Account):
                reg_update.to_pig_acc.update_balance(to_acc_h_key)

        # Update from account
        h_rec = support.AccountHistoryRecord(date, reg_keys, support.UNRECONCILED,
                                             payee_, memo, )
        from_acc.history[from_acc_hist_key] = h_rec
        from_acc.update_balance(from_acc_hist_key)

        # Validate database integrity
        for acc in config.data.accs:
            acc.history_validate()

    def transaction_import(self, from_acc: 'account.Account', date: chrono.Date,
                           registrations: Sequence[support.TransactionRegistration],
                           memo: str = None, payee_: Optional[payee.Payee] = None,
                           cleared: str = 'U'):  # pragma: no cover
        """Add an old transaction from an imported source while maintaining database
        integrity."""
        # pigjar-#462 Remove YNAB import module
        # pigjar-#420 Create issue for 'ynab' version of transaction add
        # pigjar-#420 This is believed to be nothing more than processing the cleared flag
        # pigjar-#420 This stub satisfies (inadequate?) tests by simply ignoring 'cleared'.
        # noinspection PyUnusedLocal
        unused = cleared
        msg = 'This method must be refactored for use by ynab.'
        warnings.warn(msg)
        self.transaction_add(from_acc, date, registrations, memo, payee_)

    def transaction_delete(self, from_acc: 'account.Account', acc_hist_key: str) -> None:
        """Delete a transaction maintaining database integrity.

        Args:
            from_acc: An account. (the 'from' account)
            acc_hist_key: The account history key. The 'one' of 'one to many' in the database.

        Raises:
            PigjarIndelibleHistory if the account history record is reconciled or cleared.
            TypeError if the Account.history[hist_key] does not find a dictionary
            PigjarDataError if acc_hist_key is invalid.
            PigjarRelationalError if the keys cannot locate the records.

        This method will delete the transaction(s) identified by the from account history
        key including the from account history record, the register history
        records and any to account history records.
        Account.balance, Account.diff_clear and Pig.spend will be updated.
        """

        # Is the from account history record valid and unreconciled?
        if not from_acc.history_deletable(acc_hist_key):
            cleared = from_acc.history[acc_hist_key].cleared
            msg = (f'Indelible history record.'
                   f'Account: {from_acc!r}'
                   f'History Key: {acc_hist_key}'
                   f'Cleared status: {cleared}')
            logging.critical(msg)
            raise PigjarIndelibleHistory(msg)

        # Update balances of the from-account
        from_acc.update_balance(acc_hist_key, delete_mode=True)

        # Update balances of the  to-accounts or to-pigs.
        for reg_hist_key in from_acc.history[acc_hist_key].reg_keys:
            reg_hist_rec = self.reg.history[reg_hist_key]
            if isinstance(reg_hist_rec.to_pig_acc, account.Account):
                to_acc = reg_hist_rec.to_pig_acc
                to_acc.update_balance(reg_hist_rec.to_acc_h_key, delete_mode=True)

                # Delete the transaction record for the to-account history record.
                del to_acc.history[reg_hist_rec.to_acc_h_key]

            else:
                reg_hist_rec.to_pig_acc.spend += reg_hist_rec.from_acc_debit

            # Delete the transaction record for the register history record.
            del self.reg.history[reg_hist_key]

        # Delete the transaction record for the from_account history record.
        del from_acc.history[acc_hist_key]

    def write(self) -> None:
        """Save the database to permanent storage."""

        pjwrite(dumps(self))

    def get_cashflow(self) -> None:
        """Get the current cashflow prediction.

        Returns:
            cashflow.Cashflow: cashflow object
        """

        self.cashflow = cashflow.Cashflow()
        self.cashflow.predictor(self.accs, self.pigs)


def load_program_data() -> None:
    """load the database from persistent storage.

    Raise:
        FileNotFoundError

    The user may rename this file to <user choice>.dat. This file is located at the user's
    chosen location for data files. This file is absent when the program is first used.
    Ideally this file will be rewritten whenever the data is changed but this requirement may
    be re-specified if rewriting is inadvisable for whatever reason. The location for data
    files is stored in savelocation.ini. The file prefix (<user choice> in <user choice>.dat)
    is stored in config_dynamic.ini."""
    try:
        config.data = Database.open()
    except FileNotFoundError as exc:
        msg = 'No data file: {}'.format(exc)
        logging.warning(msg=msg)
        raise FileNotFoundError(msg) from exc

    # pigjar-#414 V1.0.dev Tidy Up.
    # pigjar-#414 acc.history_validate carries out a partial check on relational links.
    # pigjar-#414 Review the need for a more rigorous approach.
    for acc in config.data.accs:
        acc.history_validate()


def save_program_data() -> None:
    """Save database to persistent storage.

    If the save location is unavailable try to save the file to the program root. There is no
    attempt to inform the user directly as it is assumed the GUI interface is no longer
    available.
    """
    # pigjar-#414 V1.0.dev Tidy Up.
    # pigjar-#414 acc.history_validate carries out a partial check on relational links.
    # pigjar-#414 Review the need for a more rigorous approach.
    # pigjar-#414 Place validation before corrupt data is written to disk
    for acc in config.data.accs:
        acc.history_validate()
    try:
        config.data.write()
    except FileNotFoundError as exc:
        logging.error(msg='Unable to save data file to save location: {}'.format(exc))
        config.savelocation_path = config.root_dir
        try:
            config.data.write()
        except FileNotFoundError as exc:
            msg = 'Unable to save data file to program root: {}'.format(exc)
            logging.critical(msg=msg)
            raise FileNotFoundError(msg)
    except AttributeError as exc:
        logging.warning(msg='There is no database: {}'.format(exc))


# noinspection PyMissingOrEmptyDocstring
def dumps(jobj) -> str:
    global dumps_seen
    dumps_seen = set()
    return json.dumps(jobj, separators=(',', ':'), default=_encoder)


def _encoder(jobj) -> Dict[str, Any]:  # pragma: no cover
    """Dispatch non standard json objects to handlers for serialization.

    Args:
        jobj: A pigjar object.

    Returns:
        Serialized json.

    Raises:
        RuntimeError: If either jobj is a valid class without a json_encode
        method or there is no specialized handler.
    """
    try:
        object_json = jobj.json_encode()
    except AttributeError as exc:
        if isinstance(jobj, chrono.Date):
            return {'chrono.Date': chrono.Date(jobj).format('$0year-$0month-$0day')}
        elif isinstance(jobj, decimal.Decimal):
            return {'decimal.Decimal': str(jobj)}
        else:
            msg = ('There is no handler for encoding the JSON object {} of type {}.'
                   .format(jobj, type(jobj)))
            logging.critical(msg=msg)
            raise AttributeError(msg) from exc
    except Exception as exc:
        msg = 'Unexpected json_encode error: {}'.format(exc)
        logging.critical(msg=msg)
        raise Exception(msg) from exc
    else:
        if id(jobj) in dumps_seen:
            object_json['__args__'] = ''
            object_json['__kw__'] = ''
            object_json['__class__'] = ''
        else:
            dumps_seen.add(id(jobj))
        return object_json


# noinspection PyMissingOrEmptyDocstring
def loads(jstr: str) -> control.Database:
    global loads_seen
    loads_seen = {}
    return json.loads(jstr, object_hook=_decoder)


def _decoder(jobj: dict) -> Union[dict, chrono.Date]:  # pragma: no cover
    """Intercept and reconstitute non standard json objects."""
    if set(jobj.keys()) == {'__class__', '__id__', '__args__', '__kw__'}:
        if jobj['__id__'] not in loads_seen:
            class_ = ({'account.Account': account.Account,
                       'event.Event1': event.Event1,
                       'event.Eventm': event.Eventm,
                       'pig.Pig': pig.Pig,
                       'payee.Payee': payee.Payee,
                       'support.AccountHistoryRecord': support.AccountHistoryRecord,
                       'support.RegisterHistoryRecord': support.RegisterHistoryRecord,
                       'register.Register': register.Register,
                       'control.Database': Database}
                      [jobj['__class__']])
            loads_seen[jobj['__id__']] = class_.from_json(
                *jobj['__args__'], **jobj['__kw__'])
        return loads_seen[jobj['__id__']]

    elif 'chrono.Date' in jobj:
        return chrono.Date(jobj['chrono.Date'])
    elif 'decimal.Decimal' in jobj:
        return decimal.Decimal(jobj['decimal.Decimal'])
    else:
        return jobj


def pjwrite(jstr: str) -> None:
    """Save pigjar's data to file with backup.

        Args:
            jstr (str): Data to save.

        Raises: FileNotFoundError
    """
    fn = "{}.dat".format(config.program_name)
    q_name = os.path.join(config.savelocation_path, fn)
    q_name = os.path.normpath(q_name)
    try:
        with open(q_name, 'w') as f:
            f.write(jstr)
    except FileNotFoundError as exc:
        msg = 'A directory in the path has been renamed or moved: {}'.format(exc)
        logging.error(msg=msg)
        raise FileNotFoundError(msg) from exc
    _backup(q_name)


def pjopen() -> str:
    """Read pigjar's data from file.

    Raises:
        FileNotFoundError

    Returns:
        JSON string.
    """
    fn = "{}.dat".format(config.program_name)
    q_name = os.path.join(config.savelocation_path, fn)
    q_name = os.path.normpath(q_name)
    try:
        with open(q_name, ) as f:
            jstr = f.read()
    except FileNotFoundError as exc:
        msg = 'Failed to open data file: {}'.format(exc)
        logging.error(msg=msg)
        raise FileNotFoundError(msg) from exc
    return jstr


def _backup(save_fn: str) -> None:
    """Create a backup of the data file.

    Args:
        save_fn (str): Path and name of data file.
    """
    backup_path, backup_prefix, backup_newest = (
        _prep_backup_dir(config.savelocation_path, config.program_name))
    backup_filename = _get_backup_filename(backup_prefix, backup_newest)
    shutil.copy2(save_fn,
                 os.path.normpath(os.path.join(backup_path, backup_filename)))


def _prep_backup_dir(backup_dir: str, backup_prefix: str) -> Tuple[str, str, Union[str, None]]:
    """Create backup directory or delete old backups if backup file count >
    max backups.

    Args:
        backup_dir: Full pathname of backup directory.
        backup_prefix: Prefix of backup filename.

    Returns:
        tuple: Full pathname of backup directory, prefix of backup filename,
            filename of newest backup file or None if there are no matching
            backup files.
    """

    try:
        file_list = os.listdir(backup_dir)
    except FileNotFoundError:  # Directory not present
        os.mkdir(backup_dir)
        backup_newest = None
    else:
        pattern = re.compile(backup_prefix + '\d{14}\.bak')
        backup_list = [fn for fn in file_list if re.match(pattern, fn)]
        # pigjar-#414 The code which reduces backup files to less than config.max_backups
        # pigjar-#414 should be in a while loop because planned future changes will
        # pigjar-#414 mean that more than one file may need to be deleted at this time.
        # pigjar-#414 The test and deletion should be in a separate function to facilitate
        # pigjar-#414 changes to the old backup retirement method.
        if len(backup_list) >= config.max_backups:
            os.remove(os.path.normpath(
                os.path.join(backup_dir, min(backup_list))))
        try:
            backup_newest = max(backup_list)
        except ValueError:  # Empty list.
            backup_newest = None
    return backup_dir, backup_prefix, backup_newest  # pigjar-#414 Just return backup_newest


def _get_backup_filename(backup_prefix: str, backup_newest: str) -> str:
    """Create backup filename and resolve backup filename collision.

    Args:
        backup_prefix: Prefix of backup filename.
        backup_newest: Filename of newest backup file or None if there
            are no matching backup files.

    Returns:
        str: Unique backup filename.
    """

    backup_filename = _make_backup_fn(backup_prefix)
    while backup_filename == backup_newest:
        backup_filename = _make_backup_fn(backup_prefix)
    return backup_filename


def _make_backup_fn(backup_prefix: str) -> str:
    """Create a backup filename..

    Args:
        backup_prefix (str): Backup prefix.

    Returns:
        str: Backup filename with the format backup_prefixYYYYMMDDHHMMSS.bak.
    """

    format_ = '$0year$0month$0day$0hour$0minute$0second'
    return backup_prefix + chrono.DateTime(True).format(format_) + '.bak'
