#!/usr/bin/env python3

"""
KeePass Console Browser, providing a read-only TUI for KeePass databases.
"""

import argparse
import sys
from pathlib import Path
from typing import Optional, List, Tuple, Iterator, Callable

from pykeepass import PyKeePass
from pykeepass.entry import Entry
from pykeepass.exceptions import CredentialsError
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll, Horizontal, Vertical
from textual.reactive import reactive, Reactive
from textual.screen import Screen
from textual.timer import Timer
from textual.widget import Widget
from textual.widgets import Header, Input, Tree, Label


class KeePassAppError(RuntimeError):
    pass


class TallHeader(Header):
    def __init__(self) -> None:
        super().__init__()
        self.tall = True


class StaticLabel(Label):
    """Generic label component for static text."""

    DEFAULT_CSS = """
    StaticLabel {
        min-height: 1;
    }
    """

    value: Reactive[str] = reactive("")

    def __init__(self, id: Optional[str] = None) -> None:
        super().__init__(id=id, markup=False)

    def watch_value(self) -> None:
        self.update(self.value)


class UnlockScreen(Screen[Optional[PyKeePass]]):
    """Modal startup password prompt, result is an opened database."""

    BINDINGS = [
        Binding("escape", "quit", "quit", show=False),
    ]

    class MiddleCenter(Widget, inherit_bindings=False):
        """Generic container that centers full viewport."""

        DEFAULT_CSS = """
        MiddleCenter {
            align: center middle;
            width: 1fr;
            height: 1fr;
        }
        """

    class PasswordInput(Input):
        valid_hint: Reactive[Optional[bool]] = reactive(True)

        def __init__(self) -> None:
            super().__init__(placeholder="Password", password=True)
            self.styles.max_width = 32

        def watch_valid_hint(self) -> None:
            if self.valid_hint is None:
                self.set_class(False, "-invalid")
                self.set_class(True, "-valid")
                self.loading = True
                # self.disabled = True
            else:
                self.set_class(not self.valid_hint, "-invalid")
                self.set_class(self.valid_hint, "-valid")
                self.loading = False
                # self.disabled = False

    def __init__(self, filename: Path, keyfile: Optional[Path]) -> None:
        self._filename: Path = filename
        self._keyfile: Optional[Path] = keyfile
        self._input: UnlockScreen.PasswordInput = UnlockScreen.PasswordInput()
        super().__init__()

    def compose(self) -> ComposeResult:
        yield TallHeader()
        yield UnlockScreen.MiddleCenter(self._input)

    async def on_input_submitted(self, evt: Input.Submitted) -> None:
        if self._input.valid_hint is not None:
            self._input.valid_hint = None
            self._try_unlock(evt.value)

    async def on_input_changed(self) -> None:
        self._input.valid_hint = True

    @work(thread=True)
    def _try_unlock(self, password: str) -> None:
        try:
            keepass: PyKeePass = PyKeePass(self._filename.as_posix(), password=password, keyfile=self._keyfile)
        except CredentialsError:
            self.app.call_from_thread(self._try_unlock_cb, None)
        except Exception as e:
            raise KeePassAppError(f"Cannot unlock '{self._filename}': {str(e)}") from None
        else:
            self.app.call_from_thread(self._try_unlock_cb, keepass)

    def _try_unlock_cb(self, keepass: Optional[PyKeePass]) -> None:
        if keepass is None:
            self._input.valid_hint = False
            self._input.focus()
        else:
            self.dismiss(keepass)

    def action_quit(self) -> None:
        self.dismiss(None)


class KeePassEntryTags(Widget):
    """Generic tag labels, for selected database entry."""

    DEFAULT_CSS = """
    KeePassEntryTags {
        height: auto;
        min-height: 1;
        width: 100%;
        layout: horizontal;
    }
    """

    class KeePassEntryTag(Label):
        DEFAULT_CSS = """
        KeePassEntryTag {
            background: $accent-darken-3;
            padding: 0;
            margin-right: 1;
            min-height: 1;
        }
        """

    tags: Reactive[List[str]] = reactive([])

    def _mount_tags(self) -> None:
        self.mount_all([KeePassEntryTags.KeePassEntryTag(tag, markup=False) for tag in self.tags])

    def on_mount(self) -> None:
        self._mount_tags()

    def watch_tags(self) -> None:
        if self.is_mounted:
            self.remove_children()  # NB: no need for await, makes copy
            self._mount_tags()


class KeePassEntryHeader(Vertical):
    """Details view header, with highlighted title and tags."""

    DEFAULT_CSS = """
    KeePassEntryHeader {
        height: auto;
        padding: 1;
        background: $accent;
    }
    KeePassEntryHeader #title {
        text-style: bold;
        text-align: center;
        width: 100%;
        border-bottom: solid $accent-lighten-3;
    }
    """

    class KeePassEntryValue(StaticLabel):
        """Generic string value with border title."""

        DEFAULT_CSS = """
        KeePassEntryValue {
            width: 100%;
            margin-top: 1;
            border-title-color: $accent;
            border: solid $accent;
        }
        KeePassEntryValue:disabled {
            border: solid $panel;
        }
        """

        password: Reactive[bool] = reactive(False)

        def __init__(self, title: str, id: Optional[str] = None) -> None:
            super().__init__(id=id)
            self.border_title = title

        def _set_value(self) -> None:
            self.update("•" * len(self.value) if self.password else self.value)
            self.disabled = len(self.value) == 0

        def watch_value(self) -> None:
            self._set_value()

        def watch_password(self) -> None:
            self._set_value()


class KeePassEntry(Widget):
    """Selected database entry details view."""

    DEFAULT_CSS = """
    KeePassEntry {
        padding: 1;
    }
    """

    entry: Reactive[Optional[Entry]] = reactive(None)

    def __init__(self) -> None:
        self._tags: KeePassEntryTags = KeePassEntryTags()
        self._title: StaticLabel = StaticLabel(id="title")
        self._ctime: StaticLabel = StaticLabel()
        self._mtime: StaticLabel = StaticLabel()
        self._url: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Url")
        self._username: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Username")
        self._password: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Password")
        self._notes: KeePassEntryHeader. KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Notes")
        self._attachments: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Attachments")
        super().__init__()

    def compose(self) -> ComposeResult:
        with VerticalScroll():
            yield from [KeePassEntryHeader(self._title, self._tags, self._ctime, self._mtime),
                        self._url, self._username, self._password, self._notes, self._attachments]

    def watch_entry(self) -> None:
        if self.entry is None:
            self.visible = False
            return
        self.visible = True

        self._tags.tags = list(self.get_tags(self.entry))
        self._password.password = True

        self._title.value = self.get_title(self.entry)
        self._ctime.value = "Create: " + self.entry.ctime.strftime('%Y-%m-%d %H:%M:%S %Z')
        self._mtime.value = "Modify: " + self.entry.mtime.strftime('%Y-%m-%d %H:%M:%S %Z')
        self._url.value = self.entry.url or ""
        self._username.value = self.entry.username or ""
        self._password.value = self.entry.password or ""
        self._notes.value = self.entry.notes or "" if self.entry.notes != self.entry.url else ""
        self._attachments.value = "\n".join(_.filename for _ in self.entry.attachments)

    def reveal(self) -> None:
        self._password.password = not self._password.password

    @classmethod
    def get_tags(cls, entry: Entry) -> Iterator[str]:
        path: List[str] = list(filter(None, entry.path[:-1]))
        yield "/".join(path) if len(path) else "Root"
        yield from entry.tags or []
        if len(entry.attachments):
            yield "attachment"

    @classmethod
    def get_title(cls, entry: Entry) -> str:
        return entry.title or entry.username or entry.url or "???"

    @classmethod
    def get_sort_keys(cls, entry: Entry) -> Tuple:
        title: str = cls.get_title(entry)
        return title.lower(), title, entry.mtime

    @classmethod
    def get_search_keys(cls, entry: Entry) -> Iterator[str]:
        yield from filter(None, [entry.title, entry.url, entry.username, entry.notes])
        yield from cls.get_tags(entry)
        yield from [_.filename for _ in entry.attachments]


class SideBar(Vertical):
    """Filter input and database entry selection."""

    DEFAULT_CSS = """
    SideBar {
        dock: left;
        max-width: 50%;
        border-right: heavy $background;
    }
    SideBar Input {
        margin-top: 1;
    }
    SideBar KeePassTree {
        padding: 0 0 0 1;
        margin: 0 1 1 1;
    }
    """

    class FilterInput(Input):
        """Search term input with rate-limited callback."""

        BINDINGS = [
            Binding("down", "cursor_down", "Cursor Down", show=False),
        ]

        def __init__(self, callback: Callable[[Optional[str]], None]) -> None:
            super().__init__(placeholder="Search")
            self._callback: Callable[[Optional[str]], None] = callback
            self._timer: Optional[Timer] = None

        def _timer_cb(self) -> None:
            self._timer = None
            self._callback(self.value)

        def on_input_changed(self) -> None:
            self._callback(None)
            if self._timer is None:
                self._timer = self.set_timer(0.5, self._timer_cb, name="filter_timer", pause=False)
            else:
                self._timer.reset()

        def action_cursor_down(self) -> None:
            self.screen.focus_next()

    class KeePassTree(Tree[Entry]):
        """Possibly filtered database entry selection."""

        DEFAULT_CSS = """
        KeePassTree, KeePassTree LoadingIndicator.-overlay {
            background: $panel;
        }
        """

        def __init__(self, filename: Path, keepass: PyKeePass, details: KeePassEntry) -> None:
            super().__init__(filename.stem)
            self.show_root = False
            self.root.expand()
            self._details: KeePassEntry = details
            self._keepass: PyKeePass = keepass
            self._create_children(None)

        def _create_children(self, filter_value: Optional[str]) -> None:
            self.clear()
            for entry in sorted(self._keepass.entries, key=KeePassEntry.get_sort_keys):  # type: Entry
                if not filter_value or any(filter_value.lower() in _.lower()
                                           for _ in KeePassEntry.get_search_keys(entry)):
                    self.root.add_leaf(KeePassEntry.get_title(entry), data=entry)
            self.cursor_line = 0

        def apply_filter(self, value: Optional[str]) -> None:
            if value is None:
                self._details.entry = None
                self.loading = True
            else:
                self._create_children(value)
                self._details.entry = self.cursor_node.data if self.cursor_node is not None else None
                self.loading = False

        async def on_tree_node_highlighted(self, node: Tree.NodeHighlighted[Entry]) -> None:
            self._details.entry = node.node.data

        async def on_tree_node_selected(self) -> None:
            self._details.reveal()

    def __init__(self, filename: Path, keepass: PyKeePass, details: KeePassEntry) -> None:
        self._keepass: PyKeePass = keepass
        self._tree: SideBar.KeePassTree = SideBar.KeePassTree(filename, keepass, details)
        self._filter: Input = SideBar.FilterInput(self._tree.apply_filter)
        super().__init__(self._filter, self._tree)

    def reset(self) -> bool:
        if not self._filter.has_focus or self._filter.value:
            self._filter.value = ""
            self._filter.focus()
            return True
        return False


class MainScreen(Screen[None]):
    """Tree sidebar and database entry viewer."""

    BINDINGS = [
        Binding("escape", "quit", "back", show=True),
        Binding("ctrl+b", "toggle_sidebar", "toggle sidebar", show=True),
    ]

    def __init__(self, filename: Path, keepass: PyKeePass) -> None:
        self._details: KeePassEntry = KeePassEntry()
        self._tree: SideBar = SideBar(filename, keepass, self._details)
        super().__init__()

    def compose(self) -> ComposeResult:
        yield TallHeader()
        yield Horizontal(self._tree, self._details)

    def action_quit(self) -> None:
        if not self._tree.display:
            self._tree.display = True
        elif not self._tree.reset():
            self.dismiss(None)

    def action_toggle_sidebar(self) -> None:
        self._tree.display = not self._tree.display


class KeePassApp(App[int]):
    """Main app, switching between password/unlock screen and browser."""

    def __init__(self, filename: Path, keyfile: Optional[Path]) -> None:
        super().__init__()
        self._filename: Path = filename
        self._keyfile: Optional[Path] = keyfile
        self.title = "KeePass Console Browser"
        self.sub_title = filename.name

    def on_mount(self) -> None:
        self._on_lock(None)

    def _on_lock(self, _) -> None:
        self.push_screen(UnlockScreen(self._filename, self._keyfile), callback=self._on_unlock)

    def _on_unlock(self, keepass: Optional[PyKeePass]) -> None:
        if keepass is None:
            self.exit(0)
            return
        self.push_screen(MainScreen(self._filename, keepass), callback=self._on_lock)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--keyfile", type=Path, default=None, help="KeePass database keyfile", metavar="db.key")
    parser.add_argument("database", type=Path, help="KeePass database to read", metavar="database.kdbx")
    args = parser.parse_args()

    # premature checks to avoid huge app stacktraces
    if not args.database.is_file():
        print(f"Cannot read database '{args.database}': File not found", file=sys.stderr)
        return 1
    if args.keyfile is not None and not args.keyfile.is_file():
        print(f"Cannot read keyfile '{args.keyfile}': File not found", file=sys.stderr)
        return 1

    rv: Optional[int] = KeePassApp(args.database, args.keyfile).run()
    return rv if rv is not None else 1


if __name__ == "__main__":
    sys.exit(main())