#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""tuptime - Report the historical and statistical real time of the system,
keeping it between restarts"""
# Copyright (C) 2011-2023 - Ricardo F.

# 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 2 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/>.

import sys, os, argparse, locale, platform, signal, logging, sqlite3, time
from datetime import datetime
# On os_bsd(): import subprocess


DB_FILE = '/var/lib/tuptime/tuptime.db'
DATETIME_FMT = '%X %x'
__version__ = '5.2.2'

# Terminate when SIGPIPE signal is received
signal.signal(signal.SIGPIPE, signal.SIG_DFL)

# Set locale to the user’s default settings (LANG env. var)
try:
    locale.setlocale(locale.LC_ALL, '')
except Exception:
    pass  # Falling back to default locale.setlocale(locale.LC_ALL, 'C')


def get_arguments():
    """Get arguments from command line"""

    parser = argparse.ArgumentParser()
    group = parser.add_mutually_exclusive_group()
    parser.add_argument(
        '-A', '--at',
        dest='at',
        default=None,
        action='store',
        metavar='STARTUP',
        type=int,
        help='limit to this startup number'
    )
    parser.add_argument(
        '-b', '--bootid',
        dest='bootid',
        action='store_true',
        default=False,
        help='show boot identifier'
    )
    parser.add_argument(
        '-c', '--csv',
        dest='csv',
        action='store_true',
        default=False,
        help='csv output'
    )
    parser.add_argument(
        '-d', '--date',
        dest='dtm_format',
        metavar='DATETIME_FMT',
        default=DATETIME_FMT,
        action='store',
        help='datetime/timestamp format output'
    )
    parser.add_argument(
        '-e', '--dec',
        dest='dec',
        default=2,
        metavar='DECIMALS',
        action='store',
        type=int,
        help='number of decimals in percentages'
    )
    parser.add_argument(
        '-E', '--exclude',
        dest='exclude',
        default=None,
        action='store',
        metavar='STARTUP',
        help='startup numbers to exclude'
    )
    parser.add_argument(
        '--decp',
        dest='decp',
        default=None,
        action='store',
        type=int,
        help=argparse.SUPPRESS
    )
    parser.add_argument(
        '-f', '--filedb',
        dest='db_file',
        default=DB_FILE,
        action='store',
        help='database file (' + DB_FILE + ')',
        metavar='FILE'
    )
    parser.add_argument(
        '-g', '--graceful',
        dest='endst',
        action='store_const',
        default=int(0),
        const=int(1),
        help='register a graceful shutdown'
    )
    parser.add_argument(
        '-i', '--invert',
        dest='invert',
        action='store_true',
        default=False,
        help='startup number in reverse count | swich between longest/shortest on default output'
    )
    parser.add_argument(
        '-k', '--kernel',
        dest='kernel',
        action='store_true',
        default=False,
        help='show kernel version'
    )
    group.add_argument(
        '-l', '--list',
        dest='list',
        default=False,
        action='store_true',
        help='enumerate system life as list'
    )
    parser.add_argument(
        '-n', '--noup',
        dest='update',
        default=True,
        action='store_false',
        help='avoid update values into DB'
    )
    parser.add_argument(
        '-o', '--order',
        dest='order',
        metavar='TYPE',
        default=False,
        action='store',
        type=str,
        choices=['u', 'r', 's', 'e', 'd', 'k'],
        help='order enumerate by [u|r|s|e|d|k]'
    )
    parser.add_argument(
        '-p', '--power',
        dest='power',
        default=False,
        action='store_true',
        help='show power states run + sleep'
    )
    parser.add_argument(
        '--pctl',
        dest='percentile',
        default=None,
        action='store',
        type=int,
        help=argparse.SUPPRESS
    )
    parser.add_argument(
        '-q', '--quiet',
        dest='quiet',
        default=False,
        action='store_true',
        help='update values into DB without output'
    )
    parser.add_argument(
        '-r', '--reverse',
        dest='reverse',
        default=False,
        action='store_true',
        help='reverse order in listings'
    )
    parser.add_argument(
        '-s', '--seconds',
        dest='seconds',
        default=False,
        action='store_true',
        help='output time in seconds and epoch'
    )
    parser.add_argument(
        '-S', '--since',
        dest='since',
        default=None,
        action='store',
        metavar='STARTUP',
        type=int,
        help='limit from this startup number'
    )
    group.add_argument(
        '-t', '--table',
        dest='table',
        default=False,
        action='store_true',
        help='enumerate system life as table'
    )
    group.add_argument(
        '--tat',
        dest='tat',
        metavar='TIMESTAMP',
        default=None,
        action='store',
        type=int,
        help='system status at epoch timestamp'
    )
    parser.add_argument(
        '--tsince',
        dest='ts',
        metavar='TIMESTAMP',
        default=None,
        action='store',
        type=int,
        help='limit from this epoch timestamp'
    )
    parser.add_argument(
        '--tuntil',
        dest='tu',
        metavar='TIMESTAMP',
        default=None,
        action='store',
        type=int,
        help='limit until this epoch timestamp'
    )
    parser.add_argument(
        '-U', '--until',
        dest='until',
        default=None,
        action='store',
        metavar='STARTUP',
        type=int,
        help='limit up until this startup number'
    )
    parser.add_argument(
        '-v', '--verbose',
        dest='verbose',
        default=False,
        action='store_true',
        help='verbose output'
    )
    parser.add_argument(
        '-V', '--version',
        action='version',
        version='tuptime version ' + (__version__),
        help='show version'
    )

    parser.add_argument(
        '-x', '--silent',
        dest='silent',
        default=False,
        action='store_true',
        help=argparse.SUPPRESS
    )
    arg = parser.parse_args()

    # Check enable verbose
    if arg.verbose:
        logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG)
        logging.info('Version = %s', (__version__))

    if (arg.power and (arg.ts or arg.tu or arg.tat)) or (arg.tat and (arg.ts or arg.tu)):
        # - power states report accumulated time across an uptime range, it isn't possible to
        # know if the state was running or sleeping between specific points inside it.
        # - tat work within startups numbers, not in narrow ranges.
        parser.error('Invalid argument combination')

    # Wrap 'at' over since and until
    if arg.at is not None:
        arg.since = arg.until = arg.at

    # Expand exclude range
    if arg.exclude is not None:
        try:
            ex_extend = [ss.split('-') for ss in arg.exclude.split(',')]
            ex_extend = [range(int(i[0]), int(i[1])+1) if len(i) == 2 else i for i in ex_extend]
            arg.exclude = sorted({int(item) for sublist in ex_extend for item in sublist})
        except:
            logging.warning('Invalid exclude argument value. Not applied')
            arg.exclude = None

    if arg.decp:
        arg.dec = arg.decp
        logging.warning('Argument \'--decp\' is deprecated in favour of \'--dec\'')
    if arg.percentile:
        logging.warning('Argument \'--pctl\' deprecated without functionality')
    if arg.silent:
        arg.quiet = arg.silent
        logging.warning('Argument \'-x\' is deprecated in favour of \'-q\'')

    logging.info('Arguments = %s', vars(arg))
    return arg


def get_os_values():
    """Get values from each type of operating system"""

    sis = {'bootid': None, 'btime': None, 'uptime': None, 'rntime': None, 'slptime': None, 'offbtime': None, 'downtime': None, 'kernel': None}

    def os_bsd(sis):
        """Get values from BSD"""

        logging.info('System = BSD')
        import subprocess

        try:
            sis['btime'] = time.clock_gettime(time.CLOCK_REALTIME) - time.clock_gettime(time.CLOCK_MONOTONIC)
        except Exception as exp:
            logging.info('Old btime assignment. %s', exp)
            sysctl_out = subprocess.run(['sysctl', '-n', 'kern.boottime'], stdout=subprocess.PIPE, text=True, check=True).stdout
            # Some BSDs report the value assigned to 'sec', others do it directly
            if 'sec' in sysctl_out:  # FreeBSD, Darwin
                sis['btime'] = sysctl_out.split(' sec = ')[1].split(',')[0]
            else:  # OpenBSD, NetBSD
                sis['btime'] = sysctl_out

        try:
            # Time since some unspecified starting point. Contains sleep time on BSDs
            sis['uptime'] = time.clock_gettime(time.CLOCK_MONOTONIC)
            if sys.platform.startswith(('darwin')):
                # OSX > 10.12 has only UPTIME_RAW. Avoid compare it with non _RAW
                # counters. Their reference here is CLOCK_REALTIME, so remove the raw diff:
                raw_diff = sis['uptime'] - time.clock_gettime(time.CLOCK_MONOTONIC_RAW)
                # Time the system has been running. Not contains sleep time on OSX
                sis['rntime'] = time.clock_gettime(time.CLOCK_UPTIME_RAW) + raw_diff
            else:
                # Time the system has been running. Not contains sleep time on BSDs
                sis['rntime'] = time.clock_gettime(time.CLOCK_UPTIME)
        except Exception as exp:
            logging.info('Old uptime/rntime assignment. %s', exp)
            logging.info('Power states disabled, values assigned from uptime')
            sis['uptime'] = time.time() - sis['btime']
            sis['rntime'] = sis['uptime']

        try:
            sysctl_out = subprocess.run(['sysctl', '-xn', 'kern.boot_id'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False).stdout
            if 'Dump' in sysctl_out:
                sis['bootid'] = sysctl_out.split('Dump:')[-1].rstrip()
            else:
                raise ValueError
        except Exception:
            logging.info('BSD boot_id not assigned')

        return sis

    def os_linux(sis):
        """Get values from Linux"""

        logging.info('System = Linux')

        try:
            sis['btime'] = time.clock_gettime(time.CLOCK_REALTIME) - time.clock_gettime(time.CLOCK_BOOTTIME)
        except Exception as exp:
            logging.info('Old btime assignment. %s', exp)
            with open('/proc/stat', encoding='utf-8') as fl2:
                for line in fl2:
                    if line.startswith('btime'): sis['btime'] = line.split()[1]

        try:  # uptime and rntime must be together to avoid time mismatch between them
            # Time since some unspecified starting point. Contains sleep time on linux
            sis['uptime'] = time.clock_gettime(time.CLOCK_BOOTTIME)
            # Time since some unspecified starting point. Not contains sleep time on linux
            sis['rntime'] = time.clock_gettime(time.CLOCK_MONOTONIC)
        except Exception as exp:
            logging.info('Old uptime/rntime assignment. %s', exp)
            logging.info('Power states disabled, values assigned from uptime')
            with open('/proc/uptime', encoding='utf-8') as fl1:
                sis['uptime'] = fl1.readline().split()[0]
            sis['rntime'] = sis['uptime']

        try:
            with open('/proc/sys/kernel/random/boot_id', encoding='utf-8') as fl3:
                sis['bootid'] = fl3.readline().split()[0]
        except Exception:
            logging.info('Linux boot_id not assigned')

        return sis

    # Linux
    if sys.platform.startswith('linux'):
        sis = os_linux(sis)
    # BSD and related
    elif sys.platform.startswith(('freebsd', 'darwin', 'dragonfly', 'openbsd', 'netbsd', 'sunos')):
        sis = os_bsd(sis)
    # elif:
    #     other_os()
    else:
        logging.error('System = %s not supported', sys.platform)
        sys.exit(1)

    # Check right allocation of core variables before continue
    for key, value in sis.items():
        if key in ('btime', 'uptime', 'rntime') and value is None:
            logging.error('"%s" value unallocate from system. Execution aborted', key)
            sys.exit(1)
        if key in ('uptime', 'rntime') and float(value) < 0:
            logging.warning('Reset invalid "%s" value "%s"', key, value)
            if key == 'uptime': sis['uptime'] = 1
            if key == 'rntime': sis['rntime'] = 1

    # Set number OS values as integer
    for key in ('btime', 'uptime', 'rntime'):
        sis[key] = int(round(float(sis[key])))

    # Avoid mismatch with elapsed time between getting counters and/or rounded values,
    # with less than 1 seconds, values are equal
    if (sis['uptime'] - 1) <= sis['rntime'] <= (sis['uptime'] + 1):
        sis['rntime'] = sis['uptime']

    # Get sleep time from runtime
    sis['slptime'] = sis['uptime'] - sis['rntime']

    # Set text OS values
    sis['bootid'] = str(sis['bootid'])
    sis['kernel'] = str(platform.platform())

    logging.info('Python = %s', platform.python_version())
    try:
        logging.info('Current locale = %s', locale.getlocale())
    except Exception:
        logging.info('Current locale = None')
    logging.info('Sys values = %s', sis)
    logging.info('Execution user = %s', os.getuid())

    # Avoid executing when OS clock is too out of phase
    if sis['btime'] < 946684800:   # 01/01/2000 00:00
        logging.error('Epoch boot time value is too old \'%s\'. Check system clock sync', sis['btime'])
        logging.error('Tuptime execution aborted')
        sys.exit(1)

    return sis


def gain_db(sis, arg):
    """Assure DB state and get DB connection"""

    # If db_file keeps default value, check for DB environment variable
    if arg.db_file == DB_FILE:
        if os.environ.get('TUPTIME_DBF'):
            arg.db_file = os.environ.get('TUPTIME_DBF')
            logging.info('DB environ var = %s', arg.db_file)

    # Test path
    arg.db_file = os.path.abspath(arg.db_file)  # Get absolute or relative path
    try:
        if os.makedirs(os.path.dirname(arg.db_file), exist_ok=True):
            logging.info('Making path = %s', os.path.dirname(arg.db_file))
    except Exception as exp_path:
        logging.error('Check DB path "%s": %s', os.path.dirname(arg.db_file), exp_path)
        sys.exit(1)

    # Test and create DB with the initial values
    try:
        if os.path.isfile(arg.db_file):
            logging.info('DB file exists = %s', arg.db_file)
        else:
            logging.info('Making DB file = %s', arg.db_file)
            db_conn = sqlite3.connect(arg.db_file)
            db_conn.set_trace_callback(logging.debug)
            conn = db_conn.cursor()
            conn.execute('BEGIN deferred')
            conn.execute('create table tuptime'
                         ' (bootid text, btime integer, uptime integer, rntime integer, slptime integer,'
                         ' offbtime integer, endst integer, downtime integer, kernel text)')
            conn.execute('insert into tuptime values (?,?,?,?,?,?,?,?,?) ',
                         (sis['bootid'], sis['btime'], sis['uptime'], sis['rntime'],
                          sis['slptime'], None, arg.endst, None, sis['kernel']))
            conn.execute('PRAGMA user_version = {}'.format(__version__.partition('.')[0]))
            db_conn.commit()
            db_conn.close()
    except Exception as exp_file:
        logging.error('Check DB file "%s": %s', arg.db_file, exp_file)
        sys.exit(1)

    # Get DB connection and begin transaction
    try:
        logging.info('Getting DB connection')
        db_conn = sqlite3.connect(arg.db_file)
        db_conn.row_factory = sqlite3.Row
        db_conn.set_trace_callback(logging.debug)
        conn = db_conn.cursor()
        conn.execute('BEGIN deferred')
    except Exception as exp_conn:
        logging.error('DB connection failed: %s', exp_conn)
        sys.exit(1)

    # Check if DB has the old format
    user_version = conn.execute('PRAGMA user_version').fetchone()[0]
    if arg.verbose:
        logging.info('DB user_version: %s', user_version)
    if user_version < 5:
        logging.warning('DB format outdated')
        upgrade_db(db_conn, conn, arg)

    return db_conn, conn


def upgrade_db(db_conn, conn, arg):
    """Upgrade DB to current format"""

    if not os.access(arg.db_file, os.W_OK):
        logging.error('"%s" file not writable by execution user', arg.db_file)
        sys.exit(1)
    logging.warning('Upgrading DB file = %s', arg.db_file)

    try:
        columns = [i[1] for i in conn.execute('PRAGMA table_info(tuptime)')]

        if 'rntime' not in columns or 'slptime' not in columns:  # new in tuptime v4
            logging.warning('Upgrading DB with power states')
            conn.execute('create table if not exists tuptimeNew'
                         ' (btime integer, uptime integer, rntime integer, slptime integer,'
                         ' offbtime integer, endst integer, downtime integer, kernel text)')
            conn.execute('update tuptime set uptime = cast(round(uptime) as int)')
            conn.execute('update tuptime set offbtime = cast(round(offbtime) as int)')
            conn.execute('update tuptime set downtime = cast(round(downtime) as int)')
            conn.execute('insert into tuptimeNew'
                         ' (btime, uptime, offbtime, endst, downtime, kernel)'
                         ' SELECT btime, uptime, offbtime, endst, downtime, kernel'
                         ' FROM tuptime')
            conn.execute('update tuptimeNew set rntime = uptime')
            conn.execute('update tuptimeNew set slptime = 0')
            conn.execute('drop table tuptime')
            conn.execute('alter table tuptimeNew RENAME TO tuptime')
            conn.execute('PRAGMA user_version = 4')
            db_conn.commit()

        if 'bootid' not in columns:  # new in tuptime v5
            logging.warning('Upgrading DB with boot ID')
            conn.execute('create table if not exists tuptimeNew'
                         ' (bootid text, btime integer, uptime integer, rntime integer, slptime integer,'
                         ' offbtime integer, endst integer, downtime integer, kernel text)')
            conn.execute('insert into tuptimeNew'
                         ' (btime, uptime, rntime, slptime, offbtime, endst, downtime, kernel)'
                         ' SELECT btime, uptime, rntime, slptime, offbtime, endst, downtime, kernel'
                         ' FROM tuptime')
            conn.execute('update tuptimeNew set bootid = "None"')
            conn.execute('update tuptimeNew set kernel = "None" where kernel = ""')
            conn.execute('drop table tuptime')
            conn.execute('alter table tuptimeNew RENAME TO tuptime')
            conn.execute('PRAGMA user_version = 5')
            db_conn.commit()

        logging.warning('Set DB user_version')
        conn.execute('PRAGMA user_version = 5')
        db_conn.commit()

    except Exception as exp_db:
        logging.error('Upgrading DB format failed. "%s"', exp_db)
        sys.exit(1)

    logging.warning('Upgraded')


def control_drift(prev, sis):
    """Check time drift due inconsistencies with system clock"""

    offset = sis['btime'] - prev['btime']  # Calculate time offset
    logging.info('Drift over btime = %s', offset)

    if offset:
        logging.info('Fixing drift...')

        # Apply offset to btime, uptime and related
        if (sis['uptime'] + offset) > 0:
            logging.info('System timestamp = %s', sis['btime'] + sis['uptime'])

            sis['uptime'] = sis['uptime'] + offset
            sis['btime'] = sis['btime'] - offset

            # Apply offset to rntime if it has room for it, else, to slptime too
            sis['rntime'] = sis['rntime'] + offset
            if sis['rntime'] < 1:
                sis['slptime'] = sis['slptime'] + sis['rntime'] - 1
                if sis['slptime'] < 0:
                    logging.info('Drift decrease slptime under 0. Impossible')
                    sis['slptime'] = 0
                logging.info('Drift decrease rntime under 1. Impossible')
                sis['rntime'] = 1  # Always keep almost 1 second

            # Fixed timestamp must be equal to system timestamp after drift values
            # Fixed btime must be equal to last btime from DB
            logging.info('Fixed timestamp = %s', sis['btime'] + sis['uptime'])
            logging.info('Fixed sys values = %s', sis)

        else:
            # Keep btime from DB with current uptime until it can be fixed
            sis['btime'] = prev['btime']
            logging.info('Drift decreases uptime under 1. Skipping fix')
            logging.info('Unfixed btime = %s', sis['btime'])

    return sis


def time_conv(secs):
    """Convert seconds to human readable style"""

    dtm = {'yr': 0, 'd': 0, 'h': 0, 'm': 0, 's': 0}
    line = ''

    # Get human values from seconds
    dtm['m'], dtm['s'] = divmod(secs, 60)
    dtm['h'], dtm['m'] = divmod(dtm['m'], 60)
    dtm['d'], dtm['h'] = divmod(dtm['h'], 24)
    dtm['yr'], dtm['d'] = divmod(dtm['d'], 365)

    # Build datetime sentence with this order
    for key in ('yr', 'd', 'h', 'm', 's'):

        # Avoid print empty values at the beginning, except seconds
        if (dtm[key] == 0) and (line == '') and (key != 's'):
            continue
        else:
            line += str(dtm[key]) + key + ' '

    # Return without last space char
    return str(line[:-1])


def trim_rows(db_rows, sis, last_st, arg):
    """Report rows since or until a given startup number or timestamp

    Conventions:
        - Keep startup number, boot ID and kernel
        - Empty values are False
    """

    def tuntil(db_rows, arg):
        for row in (*db_rows,):  # Parse rows trying to look for the rightmost (older) value

            if arg.tu > row['offbtime'] and arg.tu <= (row['offbtime'] + row['downtime']):
                row['downtime'] = arg.tu - row['offbtime']

            elif arg.tu > row['btime'] and arg.tu <= (row['btime'] + row['uptime']):
                row['uptime'] = arg.tu - row['btime']
                for key in ('rntime', 'slptime', 'offbtime', 'endst', 'downtime'):
                    row[key] = False

            elif arg.tu <= row['btime']:
                db_rows.remove(row)

            else:
                continue
        return db_rows

    def tsince(db_rows, arg):
        for row in (*db_rows,):  # Parse rows trying to look for the leftmost (newer) value

            if arg.ts <= row['btime']:
                continue

            elif arg.ts > row['btime'] and arg.ts < (row['btime'] + row['uptime']):
                row['uptime'] = row['btime'] + row['uptime'] - arg.ts
                for key in ('btime', 'rntime', 'slptime'):
                    row[key] = False

            elif arg.ts == row['offbtime']:
                for key in ('btime', 'uptime', 'rntime', 'slptime'):
                    row[key] = False

            elif arg.ts > row['offbtime'] and arg.ts < (row['offbtime'] + row['downtime']):
                row['downtime'] = row['offbtime'] + row['downtime'] - arg.ts
                for key in ('btime', 'uptime', 'rntime', 'slptime', 'offbtime', 'endst'):
                    row[key] = False

            else:
                db_rows.remove(row)
        return db_rows

    # Filter based on argument
    if arg.until is not None:
        if arg.until <= 0:  # Negative value start from bottom
            arg.until = last_st + arg.until
        db_rows = [row for row in db_rows if arg.until >= row['startup']]

    if arg.since is not None:
        if arg.since <= 0:  # Negative value start from bottom
            arg.since = last_st + arg.since
        db_rows = [row for row in db_rows if arg.since <= row['startup']]

    if arg.tu is not None:
        if arg.tu < 0:  # Negative value decrease actual timestamp
            arg.tu = sis['btime'] + sis['uptime'] + arg.tu
        db_rows = tuntil(db_rows, arg)

    if arg.ts is not None:
        if arg.ts < 0:  # Negative value decrease actual timestamp
            arg.ts = sis['btime'] + sis['uptime'] + arg.ts
        db_rows = tsince(db_rows, arg)

    if arg.exclude is not None:
        db_rows = [row for row in db_rows if row['startup'] not in arg.exclude]

    return db_rows, arg


def reorder(db_rows, arg, last_st):
    """Order and/or revert and/or invert output"""

    if db_rows:

        if arg.order:
            match_value = {'u': 'uptime', 'r': 'rntime', 's': 'slptime', 'e': 'endst', 'd': 'downtime', 'k': 'kernel'}
            db_rows = sorted(db_rows, key=lambda x: (x[match_value[arg.order]]))

        if arg.reverse:
            db_rows = list(reversed(db_rows))

        if arg.invert:
            for ind, _ in enumerate(db_rows):
                db_rows[ind]['startup'] = db_rows[ind]['startup'] - last_st

    return db_rows


def format_output(db_rows, arg):
    """Set the right output format"""

    for row in db_rows:

        for key in ('bootid', 'kernel'):
            if row[key] is False:
                row[key] = ''

        if row['uptime'] is False:
            row['uptime'] = row['rntime'] = row['slptime'] = ''
        else:
            if not arg.seconds:
                for key in ('uptime', 'rntime', 'slptime'):
                    row[key] = time_conv(row[key])

        if row['endst'] is False:
            row['endst'] = ''
        else:
            if row['offbtime'] is False or row['downtime'] is False:
                row['endst'] = ''
            else:
                if row['endst'] == 1:
                    row['endst'] = 'OK'
                elif row['endst'] == 0:
                    row['endst'] = 'BAD'

        for key in ('btime', 'offbtime', 'downtime'):
            if row[key] is False:
                row[key] = ''
            else:
                if not arg.seconds:
                    if key == 'downtime':
                        row[key] = time_conv(row[key])
                    else:
                        row[key] = datetime.fromtimestamp(row[key]).strftime(arg.dtm_format)

    return db_rows


def print_list(db_rows, arg):
    """Print values as list"""

    if arg.csv:  # Set content/spaces between values
        sp0, sp5 = '"', ''
        sp1 = sp2 = '","'
    else:
        sp0, sp1, sp2, sp5 = '', '  ', ': ', ' '

    for row_dict in format_output(db_rows, arg):

        print(sp0 + 'Startup' + sp2 + sp5 + str(row_dict['startup']), end='')
        if row_dict['btime']:
            print(sp1 + 'at' + sp1 + str(row_dict['btime']), end='')
        print(sp0)

        if arg.bootid and row_dict['bootid']:
            print(sp0 + 'Boot ID' + sp2 + sp5 + str(row_dict['bootid']) + sp0)

        if row_dict['uptime']:
            print(sp0 + 'Uptime' + sp2 + (sp5 * 2) + str(row_dict['uptime']) + sp0)

            if arg.power:
                print(sp0 + 'Running' + sp2 + sp5 + str(row_dict['rntime']) + sp0)
                print(sp0 + 'Sleeping' + sp2 + str(row_dict['slptime']) + sp0)

        if row_dict['offbtime']:
            print(sp0 + 'Shutdown' + sp2 + str(row_dict['endst']) + sp1 + 'at' + sp1 + str(row_dict['offbtime']) + sp0)

        if row_dict['downtime']:
            print(sp0 + 'Downtime' + sp2 + str(row_dict['downtime']) + sp0)

        if arg.kernel:
            print(sp0 + 'Kernel' + sp2 + (sp5 * 2) + str(row_dict['kernel']) + sp0)

        if not arg.csv: print('')


def print_table(db_rows, arg):
    """Print values as a table"""

    tops = {'startup': 'No.', 'bootid': 'Boot ID', 'btime': 'Startup T.', 'uptime': 'Uptime', 'rntime': 'Running', 'slptime': 'Sleeping',
            'offbtime': 'Shutdown T.', 'endst': 'End', 'downtime': 'Downtime', 'kernel': 'Kernel'}
    side_spaces = 3

    # Remove unused optional values
    if not arg.bootid:
        tops.pop('bootid')

    if not arg.power:
        tops.pop('rntime')
        tops.pop('slptime')

    if not arg.kernel:
        tops.pop('kernel')

    # Assign remaining values for print the header
    tbl = [tuple(tops.values())]

    # Add empty brake up line if csv is not used
    if not arg.csv:
        tbl.append(tuple(' ') * len(tbl[0]))

    # Assign table values for print
    for row_dict in format_output(db_rows, arg):
        rowd = tuple(row_dict[i] for i in tops)
        tbl.append(rowd)

    # Print table values
    if arg.csv:
        for row in tbl:
            for key, value in enumerate(row):
                sys.stdout.write('"' + str(value) + '"')
                if (key + 1) != len(row):
                    sys.stdout.write(',')
            print('')

    else:
        # Get index position of elements left aligned
        align_left = [tbl[0].index(i) for i in ('End', 'Kernel') if i in tbl[0]]

        # Get the maximum width of the given column index
        colpad = [max([len(str(row[i])) for row in tbl]) for i in range(len(tbl[0]))]

        # Print cols by row
        for row in tbl:

            # First in raw and next ones with side spaces
            sys.stdout.write(str(row[0]).rjust(colpad[0]))
            for i in range(1, len(row)):
                if i in align_left:
                    col = (side_spaces * ' ') + str(row[i]).ljust(colpad[i])
                else:
                    col = str(row[i]).rjust(colpad[i] + side_spaces)
                sys.stdout.write(str('' + col))
            print('')


def print_tat(db_rows, sis, last_st, arg):
    """Report system status at specific timestamp"""

    # Negative value decrease actual timestamp
    if arg.tat < 0:
        arg.tat = sis['btime'] + sis['uptime'] + arg.tat

    report = {'at': arg.tat, 'status': None}

    for row in db_rows:
        for key in ('startup', 'bootid', 'kernel'):
            report[key] = row[key]

        # Report UP if tat fall into btime + uptime range
        if (arg.tat >= row['btime']) and (arg.tat < (row['btime'] + row['uptime'])):
            report['status'] = 'UP'
            report['time'] = arg.tat - row['btime']
            report['time_fwd'] = row['uptime'] - report['time']
            report['time_total'] = row['uptime']
            break

        # Report DOWN if tat fall into offbtime + downtime range
        elif (arg.tat >= row['offbtime']) and (arg.tat < (row['offbtime'] + row['downtime'])):
            report['time'] = arg.tat - row['offbtime']
            report['time_fwd'] = row['downtime'] - report['time']
            if row['endst'] == 1:
                report['status'] = 'DOWN-OK'
            elif row['endst'] == 0:
                report['status'] = 'DOWN-BAD'
            report['time_total'] = row['downtime']
            break

    # If status keep their default value, no match, clean all other variables. Also, cover unexpected division by zero
    if report['status'] is None or report['time_total'] == 0:
        report['startup'] = report['time'] = report['time_fwd'] = 0
        report['bootid'] = report['kernel'] = 'None'
        perctg_1 = perctg_2 = 0.0
    else:
        perctg_1 = round(report['time'] * 100 / report['time_total'], arg.dec)
        perctg_2 = round(report['time_fwd'] * 100 / report['time_total'], arg.dec)

        if arg.invert:
            report['startup'] = report['startup'] - last_st

    if not arg.seconds:
        report['at'] = datetime.fromtimestamp(report['at']).strftime(arg.dtm_format)
        report['time'] = time_conv(report['time'])
        report['time_fwd'] = time_conv(report['time_fwd'])

    if arg.csv:  # Set content/spaces between values
        sp0, sp5 = '"', ''
        sp2 = sp3 = '","'
    else:
        sp0, sp2 = '', ':\t\t'
        sp5, sp3 = ' ', '  '

    print(sp0 + 'System status' + sp2 + str(report['status']) + sp3 + 'at' + sp3 + str(report['at']) + sp3 + 'on' + sp3 + str(report['startup']) + sp0)
    if arg.bootid:
        print(sp0 + (sp5 * 3) + '...boot ID' + sp2 + str(report['bootid']) + sp0)
    if arg.kernel:
        print(sp0 + (sp5 * 4) + '...kernel' + sp2 + str(report['kernel']) + sp0)
    print(sp0 + (sp5 * 3) + 'elapsed in' + sp2 + str(perctg_1) + '%' + sp3 + '=' + sp3 + str(report['time']) + sp0)
    print(sp0 + (sp5 * 1) + 'remaining in' + sp2 + str(perctg_2) + '%' + sp3 + '=' + sp3 + str(report['time_fwd']) + sp0)


def print_default(db_rows, sis, arg):
    """Print values with default output"""

    def parse_rows(db_rows, updown, cnt, lmt):
        """Loop along all DB rows"""

        for row in db_rows:

            # Sum counters
            if row['btime'] is not False:
                updown['startups'] += 1
            if row['offbtime'] is not False:
                if row['endst'] == 0:
                    updown['bad'] += 1
                elif row['endst'] == 1:
                    updown['ok'] += 1
                updown['shutdowns'] += 1

            # Get lists with all values
            for key in cnt:
                if row[key] is not False:
                    cnt[key].append(row[key])

            # Get limits for uptime and downtime values
            if row['uptime']:
                if lmt['max-up']['bootid'] is None or lmt['max-up']['uptime'] <= row['uptime']:
                    lmt['max-up'] = row.copy()
                if lmt['min-up']['bootid'] is None or lmt['min-up']['uptime'] >= row['uptime']:
                    lmt['min-up'] = row.copy()
            if row['downtime']:
                if lmt['max-down']['bootid'] is None or lmt['max-down']['downtime'] <= row['downtime']:
                    lmt['max-down'] = row.copy()
                if lmt['min-down']['bootid'] is None or lmt['min-down']['downtime'] >= row['downtime']:
                    lmt['min-down'] = row.copy()

        return updown, cnt, lmt

    # Set default values
    updown = {'startups': 0, 'shutdowns': 0, 'ok': 0, 'bad': 0}
    tstamp = {'min': arg.ts, 'max': arg.tu}  # Args as init values
    cnt = {'bootid': [], 'uptime': [], 'rntime': [], 'slptime': [], 'downtime': [], 'kernel': []}
    range_trim = False
    sys_life = 0

    def1 = {'uptime': 0, 'rntime': 0, 'slptime': 0, 'downtime': 0}
    def2 = {'bootid': None, 'btime': False, 'offbtime': False, 'kernel': None, 'startup': None}
    def3 = {**def1, **def2}

    cal = {'tot': def1.copy(), 'ave': def1.copy()}
    lmt = {'max-up': def3.copy(), 'max-down': def3.copy(), 'min-up': def3.copy(), 'min-down': def3.copy()}
    rate = {k: float(v) for k, v in def1.items()}  # Float values of def1

    # Get values from all DB rows
    updown, cnt, lmt = parse_rows(db_rows, updown, cnt, lmt)

    # Max timestamp - until datetime
    # Get rightmost (older) value from last row if arg.tu is not used
    if db_rows and tstamp['max'] is None:
        if db_rows[-1]['offbtime'] is not False:
            tstamp['max'] = db_rows[-1]['offbtime'] + db_rows[-1]['downtime']
        elif db_rows[-1]['btime'] is not False:
            tstamp['max'] = db_rows[-1]['btime'] + db_rows[-1]['uptime']

    # Min timestamp - since datetime
    # Get leftmost (newer) value, always btime, from first row value if arg.ts is not used
    if db_rows and tstamp['min'] is None:
        tstamp['min'] = db_rows[0]['btime']

    # Check if range is trimmed
    if db_rows and arg.exclude:
        if db_rows[-1]['startup'] - db_rows[0]['startup'] != len(db_rows) - 1:
            range_trim = True

    # Get totals and system life
    for key in cal['tot']:
        cal['tot'][key] += sum(cnt[key])
    sys_life = cal['tot']['uptime'] + cal['tot']['downtime']

    # Get rates and average uptime / downtime
    if sys_life > 0:
        for key in rate:
            rate[key] = round((cal['tot'][key] * 100) / sys_life, arg.dec)

        if cnt['uptime']:
            for key in ('uptime', 'rntime', 'slptime'):
                cal['ave'][key] = int(round(cal['tot'][key] / len(cnt['uptime'])))

        if cnt['downtime']:
            cal['ave']['downtime'] = int(round(cal['tot']['downtime'] / len(cnt['downtime'])))

    # Output style: Apply human printable values or keep seconds
    if not arg.seconds:
        sys_life = time_conv(sys_life)

        for key, val in tstamp.items():
            if val is not None:
                tstamp[key] = datetime.fromtimestamp(val).strftime(arg.dtm_format)

        for key in ('uptime', 'rntime', 'slptime', 'downtime'):
            if sis[key] is not None:
                sis[key] = time_conv(sis[key])
            for val in cal.values():
                if val[key] is not False:
                    val[key] = time_conv(val[key])
            for val in lmt.values():
                if val[key] is not False:
                    val[key] = time_conv(val[key])

        for key in ('btime', 'offbtime'):
            if sis[key] is not None:
                sis[key] = datetime.fromtimestamp(sis[key]).strftime(arg.dtm_format)
            for val in lmt.values():
                if val[key] is not False:
                    val[key] = datetime.fromtimestamp(val[key]).strftime(arg.dtm_format)

    # Prepare values for print
    if arg.csv:  # Set content/spaces between values
        sp0, sp5 = '"', ''
        sp1 = sp4 = sp3 = '","'
    else:
        sp0, sp1, sp3 = '', ': \t', '  '
        sp4 = sp5 = ' '

    uptime = {'average': str(cal['ave']['uptime']), 'long': str(lmt['max-up']['uptime']), 'short': str(lmt['min-up']['uptime']),
              'sys_time': str(cal['tot']['uptime']), 'sys_rate': str(rate['uptime']) + '%', 'current': str(sis['uptime'])}

    downtime = {'average': str(cal['ave']['downtime']), 'long': str(lmt['max-down']['downtime']), 'short': str(lmt['min-down']['downtime']),
                'sys_time': str(cal['tot']['downtime']), 'sys_rate': str(rate['downtime']) + '%'}

    if arg.power:
        uptime['average'] += sp4 + '(rn: ' + str(cal['ave']['rntime']) + ' + slp: ' + str(cal['ave']['slptime']) + ')'
        uptime['long'] += sp4 + '(rn: ' + str(lmt['max-up']['rntime']) + ' + slp: ' + str(lmt['max-up']['slptime']) + ')'
        uptime['short'] += sp4 + '(rn: ' + str(lmt['min-up']['rntime']) + ' + slp: ' + str(lmt['min-up']['slptime']) + ')'
        uptime['sys_time'] += sp4 + '(rn: ' + str(cal['tot']['rntime']) + ' + slp: ' + str(cal['tot']['slptime']) + ')'
        uptime['sys_rate'] += sp4 + '(rn: ' + str(rate['rntime']) + '% + slp: ' + str(rate['slptime']) + '%)'
        uptime['current'] += sp4 + '(rn: ' + str(sis['rntime']) + ' + slp: ' + str(sis['slptime']) + ')'

    if arg.invert:
        est = ['Shortest', 'short', 'min']
    else:
        est = ['Longest', 'long', 'max']

    # System block print
    print(sp0 + 'System startups' + sp1 + str(updown['startups']) + sp3 + 'since' + sp3 + str(tstamp['min']), end='')
    if arg.tu or arg.until:
        print(sp3 + 'until' + sp3 + str(tstamp['max']), end='')
    if range_trim:
        print(sp3 + 'trimmed', end='')
    print(sp0)

    print(sp0 + 'System shutdowns' + sp1 + str(updown['ok']) + sp4 + 'ok' + sp3 + '+' + sp3 + str(updown['bad']) + sp4 + 'bad' + sp0)
    print(sp0 + 'System life' + sp1 + (sp5 * 8) + str(sys_life) + sp0)
    if arg.bootid:
        print(sp0 + 'System boot IDs' + sp1 + str(len(set(cnt['bootid']))) + sp0)
    if arg.kernel:
        print(sp0 + 'System kernels' + sp1 + str(len(set(cnt['kernel']))) + sp0)
    if not arg.csv: print('')

    # Uptime block print
    key = est[2] + '-up'
    if lmt[key]['btime'] is not False:
        print(sp0 + est[0] + ' uptime' + sp1 + uptime[est[1]] + sp3 + 'from' + sp3 + str(lmt[key]['btime']) + sp0)
    else:
        print(sp0 + est[0] + ' uptime' + sp1 + uptime[est[1]] + sp0)
    if arg.bootid:
        print(sp0 + (sp5 * 4) + '...boot ID' + sp1 + str(lmt[key]['bootid']) + sp0)
    if arg.kernel:
        print(sp0 + (sp5 * 5) + '...kernel' + sp1 + str(lmt[key]['kernel']) + sp0)
    print(sp0 + 'Average uptime' + sp1 + uptime['average'] + sp0)
    print(sp0 + 'System uptime' + sp1 + (sp5 * 8) + uptime['sys_rate'] + sp3 + '=' + sp3 + uptime['sys_time'] + sp0)
    if not arg.csv: print('')

    # Downtime block print
    key = est[2] + '-down'
    if lmt[key]['offbtime'] is not False:
        print(sp0 + est[0] + ' downtime' + sp1 + downtime[est[1]] + sp3 + 'from' + sp3 + str(lmt[key]['offbtime']) + sp0)
    else:
        print(sp0 + est[0] + ' downtime' + sp1 + downtime[est[1]] + sp0)
    if arg.bootid:
        print(sp0 + (sp5 * 6) + '...boot ID' + sp1 + str(lmt[key]['bootid']) + sp0)
    if arg.kernel:
        print(sp0 + (sp5 * 7) + '...kernel' + sp1 + str(lmt[key]['kernel']) + sp0)
    print(sp0 + 'Average downtime' + sp1 + downtime['average'] + sp0)
    print(sp0 + 'System downtime' + sp1 + downtime['sys_rate'] + sp3 + '=' + sp3 + downtime['sys_time'] + sp0)

    # Current block print
    if arg.update:
        if not arg.csv: print('')
        print(sp0 + 'Current uptime' + sp1 + uptime['current'] + sp3 + 'since' + sp3 + str(sis['btime']) + sp0)
        if arg.bootid:
            print(sp0 + (sp5 * 4) + '...boot ID' + sp1 + str(sis['bootid']) + sp0)
        if arg.kernel:
            print(sp0 + (sp5 * 5) + '...kernel' + sp1 + str(sis['kernel']) + sp0)


def output_hub(db_rows, sis, arg):
    """Manage values for print"""

    last_st = db_rows[-1]['startup']
    if len(db_rows) != last_st:
        logging.info('Real startups are not equal to enumerate startups. Deleted rows in DB')

    if arg.update:
        # If the user can only read DB, the select query over DB return outdated numbers in last row
        # because the DB was not updated previously. The following snippet update them in memory
        # If the user wrote into DB, the values are the same
        for key in ('uptime', 'rntime', 'slptime', 'kernel'):
            db_rows[-1][key] = sis[key]
        db_rows[-1]['endst'] = arg.endst
        logging.info('Refresh last row values = %s', db_rows[-1])

    # Convert last line None sqlite registers to False
    for key, value in db_rows[-1].items():
        if value is None:
            db_rows[-1][key] = False

    # Get narrow range of rows if it applies
    if not (None is arg.exclude is arg.until is arg.since is arg.tu is arg.ts):
        db_rows, arg = trim_rows(db_rows, sis, last_st, arg)

    # Print values with the chosen output
    if arg.list:
        print_list(reorder(db_rows, arg, last_st), arg)
    elif arg.table:
        print_table(reorder(db_rows, arg, last_st), arg)
    elif arg.tat is not None:
        print_tat(db_rows, sis, last_st, arg)
    else:
        print_default(db_rows, sis, arg)


def check_new_boot(prev, sis):
    """Test if system has new boot"""

    # How tuptime does it:
    #
    #    If boot id exists (only on Linux and FreeBSD), checking if its value has changed
    #
    #    If not exists, checking if the value resultant from previous btime plus previous uptime (both
    #    saved into DB) is lower than current btime with at least 1 second of diference.
    #
    # Checking boot id is the most secure way to detect a new boot. Working with time values is not 100% relialible.
    # In some particular cases the btime value from /proc/stat or from the system clock functions may change.
    # When tuptime doesn't register a new boot, only an update of the records, it tries to fix the drift.
    #
    # To avoid lost an uptime record, please be sure that the system has time sync enabled, the init/systemd
    # script and the cron task works as expected.

    if prev['bootid'] != 'None' and sis['bootid'] != 'None':
        if prev['bootid'] != sis['bootid']:
            logging.info('System restarted = True from bootid')
            return True
        else:
            logging.info('System restarted = False from bootid')
            return False

    elif prev['buptime'] < sis['btime']:
        logging.info('System restarted = True from btime')
        return True

    else:
        logging.info('System restarted = False')
        return False


def main():
    """Main entry point, core logic"""

    arg = get_arguments()
    sis = get_os_values()
    db_conn, conn = gain_db(sis, arg)

    conn.execute('select rowid, bootid, btime, uptime, endst from tuptime where rowid = (select max(rowid) from tuptime)')
    prev = dict(zip(['rowid', 'bootid', 'btime', 'uptime', 'endst'], conn.fetchone()))
    prev['buptime'] = prev['btime'] + prev['uptime']
    logging.info('Last DB values = %s', prev)

    # Check if system was restarted
    if arg.update and check_new_boot(prev, sis):

        sis['offbtime'] = prev['buptime']
        if sis['offbtime'] > sis['btime']:  # Assure btime. Never lower than shutdown
            sis['btime'] = sis['offbtime']
        sis['downtime'] = sis['btime'] - prev['buptime']

        try:
            # Save downtimes for previous boot
            conn.execute('update tuptime set offbtime =?, downtime =? where rowid =? ',
                         (sis['offbtime'], sis['downtime'], prev['rowid']))

            # Create a new boot register
            conn.execute('insert into tuptime values (?,?,?,?,?,?,?,?,?)',
                         (sis['bootid'], sis['btime'], sis['uptime'], sis['rntime'],
                          sis['slptime'], None, arg.endst, None, sis['kernel']))
            db_conn.commit()
            logging.info('DB info = insert ok')

        except Exception as exp:
            # If you see this error, maybe the systemd script isn't executed at startup
            # or the DB file (DB_FILE) has wrong permissions
            logging.error('Detected a new system startup but the values have not been saved into DB')
            logging.error('Tuptime execution user failed to write into DB file: %s', arg.db_file)
            logging.error('%s', exp)
            sys.exit(1)

    elif arg.update:
        # Adjust time drift. Check only when system wasn't restarted
        sis = control_drift(prev, sis)

        # If a graceful shutdown was just registered before, let 5 seconds to next update to avoid being overlapped
        # with regular schedule execution (it can happen at shutdown)
        if prev['endst'] and (prev['uptime'] + 5 > sis['uptime']) and not arg.endst:
            logging.info('DB info = graceful pass')
        else:
            try:
                # Update current boot records
                conn.execute('update tuptime set uptime =?, rntime =?, slptime =?, endst =?, kernel =? where rowid =?',
                             (sis['uptime'], sis['rntime'], sis['slptime'], arg.endst, sis['kernel'], prev['rowid']))
                db_conn.commit()
                logging.info('DB info = update ok')

            except sqlite3.OperationalError:
                logging.info('DB info = update skip')

    else:
        logging.info('DB info = skip by arg.update')

    if arg.quiet:
        db_conn.close()
        logging.info('Quiet mode')

    else:
        # Get all rows to determine print values. Convert from sqlite row object to dict to allow item allocation
        conn.execute('select rowid as startup, * from tuptime')
        db_rows = [dict(row) for row in conn.fetchall()]
        db_conn.close()

        output_hub(db_rows, sis, arg)


if __name__ == "__main__":
    main()
