#!python
#
# Developer: Robert Umbehant
#

import time
import os
import re
import sys
import curses
import select
import unicodedata
import argparse
import threading
import webbrowser

from datetime import datetime

try:
    import sparen
    Log = sparen.log
except Exception as e:
    Log = print

import levv
import levv.web

#-------------------------------------------------------------------
# Globals

# Curses
stdscr = 0


#-------------------------------------------------------------------
# Timeline

def initTimeline(w, h):

    # At least 500 rows
    rows = h
    if 500 > rows:
        rows = 500

    return {'w': w, 'h': h, 'x': 0, 'y': 0, 'start': 0, 'end': 0, \
            'max_disp': 0, 'max_slot': 0, 'slots': [[0 for x in range(rows)] for x in range(w)] }


def clearTimeSlots(ti, w, h):

    # At least 500 rows
    rows = h
    if 500 > rows:
        rows = 500

    ti['slots'] = [[0 for x in range(rows)] for x in range(w)]

def getTimeStr(t, fmt):

    fsec = float(t) - int(t)
    f = fmt.replace("%u", ("%.3f" % fsec)[2:])
    return datetime.fromtimestamp(t).strftime(f)


def drawSplitTime(y, x1, x2, tw, t1, t2, color, fmt):

    # Is there enough room for more time?
    r = x2 - x1
    if r < (tw * 4):
        return

    # Time halfway point
    th = t2 - ((t2 - t1) / 2)

    # Line halfway point
    xh = int(x2 - ((x2 - x1) / 2))

    # Draw timestamp at the half way point
    stdscr.addstr(y, int(xh - (tw / 2)), getTimeStr(th, fmt), color)
    stdscr.addstr(y + 1, xh, "|", color)

    # Draw more markers
    drawSplitTime(y, x1, xh, tw, t1, th, color, fmt)
    drawSplitTime(y, xh, x2, tw, th, t2, color, fmt)


def drawTimeline(ti, x, y, start, end, color):

    # Can it be drawn?
    if end <= start:
        return False

    # The current time
    now = time.time()

    # Range
    r = end - start
    if abs(now - start) > r:
        r = abs(now - start)
    if abs(now - end) > r:
        r = abs(now - end)

    # Choose a time format
    if 30 > r:
        tw = 12
        fmt = "%H:%M:%S.%u"
    elif (12 * 60 * 60) > r:
        tw = 8
        fmt = "%H:%M:%S"
    elif (3 * 24 * 60 * 60) > r:
        tw = 10
        fmt = "%a %H:%M"
    elif (20 * 24 * 60 * 60) > r:
        tw = 12
        fmt = "%b %-d %H:%M"
    elif (100 * 24 * 60 * 60) > r:
        tw = 6
        fmt = "%b %-d"
    else:
        tw = 12
        fmt = "%b %-d, %Y"

    # Update timeline data
    ti['x'] = x
    ti['y'] = y
    ti['start'] = start
    ti['end'] = end

    # Get width / height
    w = ti['w'] - x - 2
    h = ti['h']

    # Draw the timeline
    for i in range(x, x + w):
        stdscr.addstr(y + 1, i, "-", color)

    # Draw start time
    fsec = float(start) - int(start)
    stdscr.addstr(y, x, getTimeStr(start, fmt), color)
    stdscr.addstr(y + 1, x, "|", color)

    # Draw end time
    fsec = float(end) - int(end)
    stdscr.addstr(y, x + w - tw, getTimeStr(end, fmt), color)
    stdscr.addstr(y + 1, x + w - 1, "|", color)

    # Draw time markers in between
    drawSplitTime(y, x, x + w, tw, start, end, color, fmt)

    # Draw "now" marker
    nowmrk = "|"
    if start < now and end > now:
        xn = int((now - start) * w / (end - start))
    elif start > now:
        xn = 0
        nowmrk = "<"
    else:
        xn = w - 1
        nowmrk = ">"

    stdscr.addstr(y + 1, x + xn, nowmrk, curses.color_pair(4))

    return True


def showTimelineRowNums(ti, pg, lines):

    y = 4
    x = 0
    maxdisp = ti['max_disp']

    n = pg
    for i in range(0, ti['max_disp']):
        n += 1
        stdscr.addstr(y + (i * lines), x, "%02d" % n, curses.color_pair(1))


def showTimelineItem(ti, pg, lines, inf, tmin, tmax):

    y = 4
    x = 4

    tr = tmax - tmin
    w = ti['w'] - x - 2
    h = ti['h']

    # Event time
    t = getVal(inf, 'time', 0)

    # Start position
    p = int((float(inf['time']) - tmin) * w / tr)

    # Number of displayable slots
    maxdisp = int((h - y - 2) / lines)
    ti['max_disp'] = maxdisp

    # Must fall onto screen
    if p < 0:
        p = 0
    elif p >= w:
        p = w - 1

    # Display message
    msg = inf['msg']

    # String size
    if 1 < lines:
        maxtxt = len(msg) + 1
    else:
        maxtxt = 1

    # Clip to screen
    if maxtxt > (w - p):
        maxtxt = w - p

    # Anything to do?
    if 0 >= maxtxt:
        return

    # Item color
    col = 10
    sev = int(getVal(inf, 'sev', 6))
    if 1 > sev:
        sev = 1
    if sev < 6:
        col = 16 - sev

    # Log the in-use point
    if 0 >= ti['inuse'][p] or col > ti['inuse'][p]:
        ti['inuse'][p] = col

    # Does the item already have a preferred slot?
    pslot = getVal(inf, 'slot', 0)
    slot = -1
    slots = ti['slots']
    maxslots = len(slots)

    # Find a slot
    for s in range(0, maxslots):

        # Is there room for text in this slot?
        for i in range(0, maxtxt):

            # Break if someone else is in this slot
            if slots[s][p + i]:
                break

        # Is the slot clear?
        if not slots[s][p + i]:

            # Preferred slot?
            if s >= pslot:
                slot = s
                break

            # Move up if no other labels are too close
            preclear = (1 >= p) or (not slots[s][p - 1] and not slots[s][p - 2])
            postclear = ((p + i + 2) > w) or (not slots[s][p + i + 1] and not slots[s][p + i + 2])

            # Prevent slot aliasing
            if preclear and postclear:
                slot = s
                break

    # Mark preferred slot (even if we didn't get a slot)
    inf['slot'] = s if 0 > slot else slot

    # Track max slot
    if ti['max_slot'] < slot:
        ti['max_slot'] = slot

    # Did we find a slot?
    if 0 > slot:
        return False

    # Claim the slot
    for i in range(p, p + maxtxt):
        slots[slot][i] = 1

    # Show what we have room for
    if 0 < maxtxt and slot >= pg and (slot - pg) < maxdisp:

        # Only one line per slot, just show |
        if 1 == lines:
            stdscr.addstr(y + slot - pg, x + p, "|", curses.color_pair(col + 10))

        # More than one line per slot, show time and description
        elif 1 < lines:

            # Is there room for time string?
            if 14 <= (w - p):
                fsec = float(t) - int(t)
                tt = datetime.fromtimestamp(t).strftime("%H:%M:%S") + ("%.3f" % fsec)[1:]
                stdscr.addstr(y + ((slot - pg) * lines), x + p, "| " + tt, curses.color_pair(col + 10))
            else:
                stdscr.addstr(y + ((slot - pg) * lines), x + p, "|", curses.color_pair(col + 10))

            # Show the description
            stdscr.addstr(y + ((slot - pg) * lines) + 1, x + p, msg[:maxtxt], curses.color_pair(col))

    # Something worked
    return True


#-------------------------------------------------------------------
# Functions

# Per-file-handle line buffers (keyed by id(fh)) for custom separators
_inbufs = {}

def getLine(fh, sep=''):

    fhid = id(fh)
    buf = _inbufs.get(fhid, '')

    # Return any separator-delimited record already in the buffer
    if sep:
        pos = buf.find(sep)
        if 0 <= pos:
            _inbufs[fhid] = buf[pos + len(sep):]
            return buf[:pos]

    # Make sure the file is ready to read
    while fh in select.select([fh], [], [], 0)[0]:

        # Try to read a line
        s = fh.readline()

        # Empty read means EOF
        if not s:
            break

        if not isinstance(s, str):
            s = s.decode()

        # Filter control characters
        s = "".join(ch for ch in s if unicodedata.category(ch)[0] != "C")

        # No custom separator — return immediately
        if not sep:
            return s

        # Buffer and scan for separator
        buf += s
        _inbufs[fhid] = buf
        pos = buf.find(sep)
        if 0 <= pos:
            _inbufs[fhid] = buf[pos + len(sep):]
            return buf[:pos]

    return ''


def getVal(a, k, d=0):

    if type(a) is list:
        if len(a) > k:
            return a[k]

    elif type(a) is dict:
        if k in a:
            return a[k]

    return d


def setFilePtr(fh, max):

    sz = os.fstat(fh.fileno()).st_size
    if 0 >= sz:
        return 0

    if 0 <= max:

        if max > sz:
            max = sz

        return fh.seek(max, os.SEEK_SET)

    max = -max
    if max > sz:
        max = sz

    return fh.seek(-max, os.SEEK_END)


def makeDisplayPath(path, max, sep='/', rm='...'):

    if len(path) <= max:
        return path

    p = path.split(sep)
    if not len(p):
        return path[0:max - len(rm)] + rm

    ml = len(p)
    while len(path) > max:

        ml -= 1
        if 0 >= ml or 2 >= len(p):
            return path[0:max - len(rm)] + rm

        else:
            p = p[0:len(p) - 2] + [p[-1]]
            path = sep.join(p[0:len(p) - 2] + [rm, p[-1]])

    return path


def drawHelp(ti):
    """Draw a help legend overlay in the centre of the screen."""

    lines = [
        ("Keys", ""),
        ("h",            "Toggle this help"),
        ("q / Esc",      "Quit"),
        ("Up / Down",    "Zoom in / out"),
        ("Left / Right", "Scroll timeline"),
        ("PgUp / PgDn",  "Scroll rows up / down"),
        ("s",            "Resume auto-scroll"),
        ("l",            "Cycle lines per item (1/2/3)"),
        ("1-9",          "Filter to file N  (multi-file)"),
        ("0",            "Show all files    (multi-file)"),
        ("1-9",          "Set scroll %      (single file)"),
    ]

    key_w   = max(len(k) for k, _ in lines)
    desc_w  = max(len(d) for _, d in lines)
    box_w   = key_w + desc_w + 7   # 2 border + 2 pad each side + 1 sep
    box_h   = len(lines) + 2       # 2 border rows

    scr_h = ti['h']
    scr_w = ti['w']
    y0 = max(0, (scr_h - box_h) // 2)
    x0 = max(0, (scr_w - box_w) // 2)

    col_box  = curses.color_pair(2)
    col_key  = curses.color_pair(1)
    col_desc = curses.color_pair(0)
    col_hdr  = curses.color_pair(2)

    # Top border
    try:
        stdscr.addstr(y0, x0, "+" + "-" * (box_w - 2) + "+", col_box)
    except curses.error:
        pass

    for i, (key, desc) in enumerate(lines):
        row = y0 + 1 + i
        try:
            stdscr.addstr(row, x0, "|", col_box)
            if not key and not desc:
                # section header
                pass
            elif not desc:
                # header label centred
                label = key.center(box_w - 2)
                stdscr.addstr(row, x0 + 1, label, col_hdr)
            else:
                pad_key  = key.ljust(key_w)
                pad_desc = desc.ljust(desc_w)
                stdscr.addstr(row, x0 + 2, pad_key,  col_key)
                stdscr.addstr(row, x0 + 2 + key_w, "  ", col_box)
                stdscr.addstr(row, x0 + 2 + key_w + 2, pad_desc, col_desc)
            stdscr.addstr(row, x0 + box_w - 1, "|", col_box)
        except curses.error:
            pass

    # Bottom border
    try:
        stdscr.addstr(y0 + box_h - 1, x0, "+" + "-" * (box_w - 2) + "+", col_box)
    except curses.error:
        pass


def drawScreen(p, ti, msgs=[]):
    global stdscr

    # Erase screen
    stdscr.erase()

    # Current time
    t = p['time']
    lines = p['lines']

    # Screen width / height
    w = ti['w']
    h = ti['h']

    # Time range we will be displaying
    time_range = getVal(p, 'timerange', 60)
    tmin = t
    tmax = t + time_range
    tr = tmax - tmin

    # Show timeline
    drawTimeline(ti, 4, 1, tmin, tmax, curses.color_pair(1))

    # Clear slots
    clearTimeSlots(ti, w, h)
    ti['inuse'] = [0] * w
    ti['max_slot'] = 0

    # Active file filter (0 = show all)
    file_filter = getVal(p, 'file_filter', 0)

    # For each severity level
    for k in range(1, 5):

        # For each message
        for i in msgs:

            # Apply file filter
            if file_filter and getVal(i, 'file_idx', 1) != file_filter:
                continue

            # Get severity
            sev = int(getVal(i, 'sev', 6))

            # Order by severity
            if k == sev or (4 == k and (0 >= sev or 4 <= sev)):

                if 'time' in i:

                    try:
                        tm = float(i['time'])
                    except Exception:
                        continue

                    # Is it in the range of the display?
                    if tm > tmin and tm < tmax:

                        showTimelineItem(ti, p['page'], lines, i, tmin, tmax)

    # Draw row numbers
    showTimelineRowNums(ti, p['page'], lines)

    # Draw the in-use line
    for i in range(0, len(ti['inuse'])):
        if 0 < ti['inuse'][i]:
            stdscr.addstr(3, 4 + i, '^', curses.color_pair(ti['inuse'][i] + 10))

    # Update max page
    if ti['max_disp'] < ti['max_slot']:
        p['max_page'] = ti['max_slot'] - ti['max_disp']
    else:
        p['max_page'] = 0


def initCurses():
    global stdscr

    # Init curses
    stdscr = curses.initscr()
    stdscr.keypad(1)
    stdscr.nodelay(1)

    curses.start_color()
    curses.cbreak()
    curses.noecho()
    curses.curs_set(0)

    # Screen colors
    curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
    curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
    curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_RED)
    curses.init_pair(4, curses.COLOR_RED, curses.COLOR_BLACK)

    # Inverted timeline colors
    curses.init_pair(10, curses.COLOR_BLACK, curses.COLOR_GREEN)
    curses.init_pair(11, curses.COLOR_BLACK, curses.COLOR_CYAN)
    curses.init_pair(12, curses.COLOR_BLACK, curses.COLOR_BLUE)
    curses.init_pair(13, curses.COLOR_BLACK, curses.COLOR_MAGENTA)
    curses.init_pair(14, curses.COLOR_BLACK, curses.COLOR_YELLOW)
    curses.init_pair(15, curses.COLOR_BLACK, curses.COLOR_RED)

    # Non inverted timeline colors
    curses.init_pair(20, curses.COLOR_GREEN, curses.COLOR_BLACK)
    curses.init_pair(21, curses.COLOR_CYAN, curses.COLOR_BLACK)
    curses.init_pair(22, curses.COLOR_BLUE, curses.COLOR_BLACK)
    curses.init_pair(23, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
    curses.init_pair(24, curses.COLOR_YELLOW, curses.COLOR_BLACK)
    curses.init_pair(25, curses.COLOR_RED, curses.COLOR_BLACK)


#-------------------------------------------------------------------
# Web mode

def run_web_mode(p):
    """Run levv as an HTTP server instead of a curses UI."""

    port = p['web']
    files = p['files']
    usestdin = any(fi['stdin'] for fi in files)
    multi = len(files) > 1

    # Pre-open stdin
    for fi in files:
        if fi['stdin']:
            fi['fh'] = sys.stdin

    # Output file handle
    fo = None
    if p.get('outputfile'):
        fo = open(p['outputfile'], 'a')

    # Shared state for the web server
    lock = threading.Lock()
    msgs = []
    msgs_time = 0

    # Serialisable file info for the API
    files_info = [
        {'idx': fi['idx'], 'path': fi['path'], 'stdin': fi['stdin'], 'fmt': fi['fmt']}
        for fi in files
    ]

    p['autoscroll_active'] = True
    p['view_time'] = time.time() - p['timerange'] * (p['autoscroll'] / 100)

    shared = {
        'p':          p,
        'msgs':       msgs,
        'lock':       lock,
        'files_info': files_info,
    }

    # Start the web server
    server = levv.web.start(port, shared)
    url = f'http://localhost:{port}/'
    print(f'levv web interface running at {url}')
    print('Press Ctrl+C to stop.')

    # Open browser after a short delay so the server is ready
    def _open():
        time.sleep(0.4)
        webbrowser.open(url)
    threading.Thread(target=_open, daemon=True).start()

    # Turn off refresh when all input is stdin
    if usestdin and not any(not fi['stdin'] for fi in files):
        p['refresh'] = 0

    try:
        while True:
            now = time.time()

            need_refresh = (
                usestdin
                or 0 >= len(msgs)
                or 0 > p['refresh']
                or (p['refresh'] and now > msgs_time)
            )

            if need_refresh:
                new_msgs = []

                for fi in files:
                    # Open file on first access
                    if not fi['fh'] and not fi['stdin']:
                        fi['fh'] = open(fi['path'], 'rb')
                        if 0 != p['maxfileread']:
                            setFilePtr(fi['fh'], -p['maxfileread'])
                            getLine(fi['fh'], p['separator'])

                        if fi['fmt'] == 'auto' and not fi['template']:
                            try:
                                _spos = fi['fh'].tell()
                                _seek = True
                            except OSError:
                                _spos = None
                                _seek = False
                            _sample = []
                            for _ in range(20):
                                _l = getLine(fi['fh'], p['separator'])
                                if _l:
                                    _sample.append(_l)
                            _det = levv.detect_format(_sample, fi['path'])
                            if _det != 'auto':
                                fi['fmt'] = _det
                                # Update files_info too
                                for info in files_info:
                                    if info['idx'] == fi['idx']:
                                        info['fmt'] = fi['fmt']
                            if _seek:
                                try:
                                    fi['fh'].seek(_spos)
                                except OSError:
                                    pass

                    fh = fi['fh']
                    if not fh:
                        continue

                    while True:
                        s = getLine(fh, p['separator'])
                        if not s:
                            break

                        if p['filter'] and not re.search(p['filter'], s):
                            continue
                        if p['exclude'] and re.search(p['exclude'], s):
                            continue

                        if fi['template']:
                            r, s = levv.filterLine(fi['template'], s)
                            if not s:
                                continue
                            if not r:
                                r = levv.parse_line(fi['fmt'], s)
                        else:
                            r = levv.parse_line(fi['fmt'], s)

                        if not r:
                            continue

                        r['file_idx'] = fi['idx']
                        if multi and 'msg' in r:
                            r['msg'] = f"[{fi['idx']}] {r['msg']}"

                        new_msgs.append(r)

                        if fo and 'msg' in r and 'time' in r:
                            fo.write(f"{r['time']} {r.get('sev', 6)} {r['msg']}\n")

                with lock:
                    msgs.extend(new_msgs)
                    if len(msgs) > p['maxmsgbuf']:
                        del msgs[:len(msgs) - p['maxmsgbuf']]
                    shared['msgs'] = msgs

                if not p['refresh']:
                    for fi in files:
                        if fi['fh'] and not fi['stdin']:
                            fi['fh'].close()
                            fi['fh'] = None

                msgs_time = now + p['refresh']

            # Update server-side view_time when autoscrolling
            with lock:
                if p.get('autoscroll_active', True):
                    pct = p.get('autoscroll', 75)
                    p['view_time'] = now - (p['timerange'] * pct / 100)

            time.sleep(0.25)

    except KeyboardInterrupt:
        print()

    if fo:
        fo.close()
    server.shutdown()


#-------------------------------------------------------------------
# Main

def main():
    global stdscr, msgs

    #---------------------------------------------------------------
    # Parse command line arguments

    ap = argparse.ArgumentParser(description='Event monitor.')

    ap.add_argument('inputfile_pos', nargs='*', default=[], metavar='FILE',
                    help='Log file(s) to parse (positional, space or comma-separated)')
    ap.add_argument('--inputfile', '-i', default='', type=str,
                    help='Log file(s) to parse, comma-separated')
    ap.add_argument('--inputformat', '-I', default='auto', type=str,
                    help='Input format(s), comma-separated (one per file or one for all)')
    ap.add_argument('--inputbreak', '-b', default='', type=str, help='Line break')
    ap.add_argument('--inputtemplate', '-T', default='', type=str,
                    help='Regex template(s), comma-separated (one per file or one for all)')
    ap.add_argument('--outputfile', '-o', default='', type=str, help='Append logs to output file')
    ap.add_argument('--outputformat', '-O', default='', type=str, help='Output data format')
    ap.add_argument('--separator', '-s', default='', type=str,
                    help='Input data separator, default is CR/LF')
    ap.add_argument('--filter', '-f', default='', type=str,
                    help='Show only lines matching the Regex expression')
    ap.add_argument('--exclude', '-x', default='', type=str,
                    help='Filter out lines matching the Regex expression')

    ap.add_argument('--timerange', '-r', default=600.0, type=float, help='Time range')
    ap.add_argument('--time', '-t', default=0, type=str, help='Time start')

    ap.add_argument('--refresh', '-R', default=3, type=float,
                    help='Data refresh interval in seconds, 0 for no refresh')
    ap.add_argument('--autoscroll', '-a', default=75, type=int,
                    help='1-100, percentage of screen for auto scroll position, 0 = Do not auto scroll')
    ap.add_argument('--lines', '-l', default=2, type=int,
                    help='Number of lines per timeline item, can be 1, 2, or 3')
    ap.add_argument('--maxmsgbuf', '-m', default=5000, type=int,
                    help='Maximum number of messages to queue')
    ap.add_argument('--maxfileread', '-M', default=10000, type=int,
                    help='Maximum number of bytes to read from a file, 0 for all')
    ap.add_argument('--keyboard', '-k', action='store_true',
                    help='Kept for compatibility; keyboard input is now always enabled')
    ap.add_argument('--debug', '-D', action='store_true', help='Show debug information')
    ap.add_argument('--listformats', action='store_true',
                    help='List supported input formats and exit')

    # Pre-scan sys.argv for -w/--web before argparse sees it.
    # nargs='?' would greedily consume the filename as the port value when the
    # flag appears before the file (e.g. levv -w /dev/kmsg), so we extract it
    # manually: the value is only treated as a port when it is a plain integer.
    _web_port = None
    _argv = []
    _i = 0
    _raw = sys.argv[1:]
    while _i < len(_raw):
        _a = _raw[_i]
        if _a in ('-w', '--web'):
            _next = _raw[_i + 1] if _i + 1 < len(_raw) else ''
            if _next.isdigit():
                _web_port = int(_next)
                _i += 2
            else:
                _web_port = 8000
                _i += 1
        elif _a.startswith('--web='):
            _web_port = int(_a.split('=', 1)[1])
            _i += 1
        elif len(_a) > 2 and _a[:2] == '-w' and _a[2:].isdigit():
            _web_port = int(_a[2:])
            _i += 1
        else:
            _argv.append(_a)
            _i += 1

    # Get arguments
    p = vars(ap.parse_args(_argv))
    p['web'] = _web_port

    # Positional FILE(s) override -i only when -i was not given explicitly
    if p['inputfile_pos'] and not p['inputfile']:
        p['inputfile'] = ','.join(p['inputfile_pos'])
    del p['inputfile_pos']

    # Print format list and exit if requested
    if p['listformats']:
        for name, desc in levv.list_formats():
            print(f'  {name:<16} {desc}')
        return

    # Decode escape sequences so e.g. \n and \t work on the command line
    if p['separator']:
        p['separator'] = p['separator'].encode('raw_unicode_escape').decode('unicode_escape')

    #---------------------------------------------------------------
    # Build per-file info list

    # Split comma-separated inputs
    raw_paths     = [f.strip() for f in p['inputfile'].split(',') if f.strip()]
    raw_formats   = [f.strip() for f in p['inputformat'].split(',') if f.strip()]
    raw_templates = [t.strip() for t in p['inputtemplate'].split(',') if t.strip()]

    # Default to syslog when nothing specified
    if not raw_paths:
        raw_paths = ['/var/log/syslog']

    def _fmt_for(i):
        if i < len(raw_formats):
            return raw_formats[i]
        return raw_formats[0] if raw_formats else 'auto'

    def _tmpl_for(i):
        if i < len(raw_templates):
            return raw_templates[i]
        return raw_templates[0] if raw_templates else ''

    files = []
    for idx, path in enumerate(raw_paths):
        fmt  = _fmt_for(idx)
        tmpl = _tmpl_for(idx)
        # Resolve named template from format name if no explicit template given
        if fmt and not tmpl:
            tmpl = levv.getLogTemplate(fmt) or ''
        files.append({
            'idx':      idx + 1,       # 1-based display number
            'path':     path,
            'stdin':    path == '-',
            'fmt':      fmt,
            'template': tmpl,
            'fh':       None,
        })

    p['files'] = files
    p['file_filter'] = 0   # 0 = show all; 1-9 = show only that file index

    multi = len(files) > 1

    # Any file using stdin?
    usestdin = any(fi['stdin'] for fi in files)

    #---------------------------------------------------------------
    # Permission checks — prompt before curses starts

    for fi in files:
        if fi['stdin']:
            continue
        if not os.access(fi['path'], os.R_OK) and os.path.exists(fi['path']):
            print(f"Permission denied: {fi['path']}")
            print("Elevating permissions with sudo is required to read this file.")
            try:
                answer = input("Continue with sudo? [y/N] ").strip().lower()
            except (EOFError, KeyboardInterrupt):
                print()
                return
            if answer not in ('y', 'yes'):
                return
            os.execvp('sudo', ['sudo'] + sys.argv)

    #---------------------------------------------------------------
    # Validate regex patterns

    for flag, val in [('--inputtemplate', p['inputtemplate']),
                      ('--filter',        p['filter']),
                      ('--exclude',       p['exclude'])]:
        if val:
            for part in val.split(','):
                part = part.strip()
                if part:
                    try:
                        re.compile(part)
                    except re.error as e:
                        ap.error("%s: invalid regular expression: %s" % (flag, e))

    #---------------------------------------------------------------
    # Web mode

    if p['web'] is not None:
        run_web_mode(p)
        return

    #---------------------------------------------------------------
    # Initialize

    # Initialize scroll mode
    scroll = p['autoscroll']
    if 0 == p['autoscroll']:
        p['autoscroll'] = 75

    # Data buffer
    msgs = []
    msgs_time = 0

    # Pre-open stdin file handle.
    # When stdin is a pipe we redirect fd 0 to /dev/tty so that curses can
    # read keyboard events from the terminal while log data continues to flow
    # through a separate file descriptor.  This makes -k unnecessary.
    for fi in files:
        if fi['stdin']:
            if not sys.stdin.isatty():
                try:
                    pipe_fd = os.dup(sys.stdin.fileno())
                    tty = open('/dev/tty', 'r')
                    os.dup2(tty.fileno(), 0)
                    tty.close()
                    fi['fh'] = os.fdopen(pipe_fd, 'r')
                except OSError:
                    fi['fh'] = sys.stdin   # fallback: /dev/tty unavailable
            else:
                fi['fh'] = sys.stdin

    # Output file handle
    fo = 0

    # Page offset
    p['page'] = 0
    p['max_page'] = 0
    p['show_help'] = False

    #---------------------------------------------------------------
    # Catch exceptions so we can restore the screen
    try:

        # Init Curses
        initCurses()

        # Screen width / height
        h, w = stdscr.getmaxyx()

        # Initialize timeline
        ti = initTimeline(w, h)

        # Draw initial screen
        drawScreen(p, ti)

        # Turn off refresh interval when all input is stdin
        if usestdin and not any(not fi['stdin'] for fi in files):
            p['refresh'] = 0

        # Open output file if we need one
        if len(p['outputfile']):
            fo = open(p['outputfile'], 'a')

        run = 1

        #-----------------------------------------------------------
        # Run the loop
        while run:

            # Current time
            now = time.time()

            #-------------------------------------------------------
            # Process key presses

            waskey = 0

            key = stdscr.getch()

            while 0 < key:

                waskey = key

                # Screen resize
                if curses.KEY_RESIZE == key:
                    w = 0
                    h = 0

                # Zoom in
                elif curses.KEY_UP == key:
                    if 0.001 < p['timerange']:
                        p['time'] += p['timerange'] / 20
                        p['timerange'] /= 1.1

                # Zoom out
                elif curses.KEY_DOWN == key:
                    if 1000000000 > p['timerange']:
                        p['time'] -= p['timerange'] / 20
                        p['timerange'] *= 1.1

                # Scroll right
                elif curses.KEY_RIGHT == key:
                    scroll = 0
                    p['time'] += float(p['timerange']) / 20

                # Scroll left
                elif curses.KEY_LEFT == key:
                    scroll = 0
                    if 0 < (p['time'] - float(p['timerange']) / 20):
                        p['time'] -= float(p['timerange']) / 20

                # Page up
                elif curses.KEY_PPAGE == key:
                    if 0 < p['page']:
                        p['page'] -= 1

                # Page down
                elif curses.KEY_NPAGE == key:
                    if p['max_page'] > p['page']:
                        p['page'] += 1

                # Lines per timeline item
                elif 'l' == chr(key).lower():
                    p['lines'] += 1
                    if 3 < p['lines']:
                        p['lines'] = 1

                # Auto scrolling resume
                elif 's' == chr(key).lower():
                    scroll = p['autoscroll']

                # Digit keys: file filter (multi-file) or autoscroll position (single file)
                elif chr(key) in '0123456789':
                    n = int(chr(key))
                    if multi:
                        # 0 = show all; 1-9 = filter to that file (toggle off if already selected)
                        if n == 0:
                            p['file_filter'] = 0
                        elif n <= len(files):
                            p['file_filter'] = n if p['file_filter'] != n else 0
                    else:
                        # Single file — keep original autoscroll behaviour
                        if n == 0:
                            pass  # 0 not bound in single-file mode
                        else:
                            scroll = n * 10

                # Help legend toggle
                elif 'h' == chr(key).lower():
                    p['show_help'] = not p['show_help']

                # Quit on Escape or 'q'
                elif 27 == key or 'q' == chr(key).lower():
                    run = 0
                    break

                key = stdscr.getch()

            # Ensure reasonable page
            if p['max_page'] < p['page']:
                p['page'] = p['max_page']

            #-------------------------------------------------------
            # Get Data

            need_refresh = (
                usestdin
                or 0 >= len(msgs)
                or 0 > p['refresh']
                or (p['refresh'] and now > msgs_time)
            )

            if need_refresh:

                for fi in files:

                    # Open file handle on first access
                    if not fi['fh'] and not fi['stdin']:

                        fi['fh'] = open(fi['path'], 'rb')

                        # Don't read too much data
                        if 0 != p['maxfileread']:
                            setFilePtr(fi['fh'], -p['maxfileread'])
                            getLine(fi['fh'], p['separator'])

                        # Auto-detect format from a sample of lines.
                        # Skip on non-seekable files (character devices, pipes).
                        if fi['fmt'] == 'auto' and not fi['template']:
                            try:
                                _sample_pos = fi['fh'].tell()
                                _seekable = True
                            except OSError:
                                _sample_pos = None
                                _seekable = False
                            _sample = []
                            for _ in range(20):
                                _l = getLine(fi['fh'], p['separator'])
                                if _l:
                                    _sample.append(_l)
                            _detected = levv.detect_format(_sample, fi['path'])
                            if _detected != 'auto':
                                fi['fmt'] = _detected
                            if _seekable:
                                try:
                                    fi['fh'].seek(_sample_pos)
                                except OSError:
                                    pass

                    fh = fi['fh']
                    if not fh:
                        continue

                    # Read lines from this file
                    while True:

                        s = getLine(fh, p['separator'])
                        if not s:
                            break

                        # Filtering
                        if p['filter'] and not re.search(p['filter'], s):
                            continue
                        if p['exclude'] and re.search(p['exclude'], s):
                            continue

                        # Apply template or format parser
                        if fi['template']:
                            r, s = levv.filterLine(fi['template'], s)
                            if not s:
                                continue
                            if not r:
                                r = levv.parse_line(fi['fmt'], s)
                        else:
                            r = levv.parse_line(fi['fmt'], s)

                        if not r:
                            continue

                        # Tag with file index
                        r['file_idx'] = fi['idx']

                        # Prepend file number to message when monitoring multiple files
                        if multi and 'msg' in r:
                            r['msg'] = f"[{fi['idx']}] {r['msg']}"

                        msgs.append(r)

                        # Append to output file
                        if fo and 'msg' in r and 'time' in r:
                            sev = r.get('sev', 6)
                            fo.write(f"{r['time']} {sev} {r['msg']}\n")

                # Prune excess messages
                if len(msgs) > p['maxmsgbuf']:
                    msgs = msgs[len(msgs) - p['maxmsgbuf']:]

                # Close file handles when not refreshing
                if not p['refresh']:
                    for fi in files:
                        if fi['fh'] and not fi['stdin']:
                            fi['fh'].close()
                            fi['fh'] = None

                msgs_time = now + p['refresh']

            #-------------------------------------------------------
            # Update the terminal

            # Auto scroll?
            if scroll:
                p['time'] = now - (p['timerange'] * scroll / 100)

            # Check for terminal resize
            _h, _w = stdscr.getmaxyx()
            if _h != h or _w != w:
                h = _h
                w = _w
                ti = initTimeline(w, h)

            # Update the screen
            try:
                drawScreen(p, ti, msgs)
            except curses.error:
                pass

            # Help overlay
            if p['show_help']:
                try:
                    drawHelp(ti)
                except curses.error:
                    pass

            # Debug info
            if p['debug']:
                stdscr.addstr(0, 1, str(len(msgs)) + " : " + str(p))

            #-------------------------------------------------------
            # Status bar (row 0)

            # Format label: show format of filtered file, or 'multi' when formats differ
            if not multi:
                fmt_label = '[fmt:' + files[0]['fmt'] + ']'
            elif p['file_filter']:
                active = next((f for f in files if f['idx'] == p['file_filter']), files[0])
                fmt_label = '[fmt:' + active['fmt'] + ']'
            else:
                fmts = list(dict.fromkeys(f['fmt'] for f in files))
                fmt_label = '[fmt:' + (fmts[0] if len(fmts) == 1 else 'multi') + ']'

            try:
                stdscr.addstr(0, w - len(fmt_label) - 1, fmt_label, curses.color_pair(1))
            except curses.error:
                pass

            right_margin = len(fmt_label) + 2

            if not multi:
                # Single file — show path
                fi = files[0]
                fname = '[stdin]' if fi['stdin'] else makeDisplayPath(fi['path'], w - right_margin - 2)
                try:
                    stdscr.addstr(0, 2, fname)
                except curses.error:
                    pass
            else:
                # Multiple files — show numbered list; highlight the active filter
                x = 2
                for fi in files:
                    label = f"[{fi['idx']}]"
                    name  = os.path.basename(fi['path']) if not fi['stdin'] else 'stdin'
                    entry = label + name + ' '
                    if x + len(entry) >= w - right_margin:
                        break
                    active = (p['file_filter'] == fi['idx'])
                    col = curses.color_pair(2) if active else curses.color_pair(1)
                    try:
                        stdscr.addstr(0, x, entry, col)
                    except curses.error:
                        pass
                    x += len(entry)

            # Home the cursor
            try:
                stdscr.addstr(0, 0, ">")
            except curses.error:
                pass

            # Refresh the terminal
            stdscr.refresh()

            # Pause if no keys being pressed
            if 0 >= waskey:
                time.sleep(.25)

    # CTRL+C
    except KeyboardInterrupt:
        pass

    # Show other exceptions
    except Exception:
        if fo:
            fo.close()
        curses.endwin()
        Log("...EXCEPTION...")
        raise

    if fo:
        fo.close()

    curses.endwin()


if __name__ == '__main__':
    main()
