import subprocess
import json
import logging
from typing import Any, Dict, List, Optional

from lastpass_vault.passphrase import (  # noqa: F401
    generate_passphrase,
    passphrase_entropy,
    check_passphrase_strength,
    PassphraseStrength,
    DEFAULT_SPECIAL_CHARS,
    WORDLIST_LARGE,
    WORDLIST_SHORT1,
    WORDLIST_SHORT2,
    SEP_SPACE,
    SEP_SLASH,
    SEP_TILDE,
    SEP_NONE,
    SEP_DASH,
    SEP_DOT,
    SAFE_SEPARATORS,
)

class LastPassError(Exception):
    """Base exception for LastPass operations."""
    pass


class LastPassVault:
    """A wrapper around the LastPass CLI (lpass)."""

    _VALID_SYNC = frozenset({"auto", "now", "no"})

    def __init__(self, lpass_path: str = "lpass"):
        self.lpass_path = lpass_path

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _run_command(self, args: List[str], input_data: Optional[str] = None) -> str:
        """Run an lpass command and return stdout.

        All lpass subcommands are passed as a list so the subprocess module
        never invokes a shell, preventing command-injection attacks.
        Sensitive values (passwords, CSV data) must be supplied via
        input_data so they reach the process through its stdin pipe rather
        than appearing in the process argument list.
        """
        try:
            command = [self.lpass_path] + args
            result = subprocess.run(
                command,
                input=input_data,
                capture_output=True,
                text=True,
                check=True
            )
            return result.stdout.strip()
        except subprocess.CalledProcessError as e:
            error_msg = e.stderr.strip() or e.stdout.strip()
            raise LastPassError(f"Command '{' '.join(command)}' failed: {error_msg}")

    def _validate_sync(self, sync: Optional[str]) -> None:
        """Raise ValueError if sync is not a valid lpass --sync value."""
        if sync is not None and sync not in self._VALID_SYNC:
            raise ValueError(
                f"sync must be one of {sorted(self._VALID_SYNC)!r}; got {sync!r}"
            )

    # ------------------------------------------------------------------
    # Authentication
    # ------------------------------------------------------------------

    def login(self, username: str, password: Optional[str] = None,
              trust: bool = False, plaintext_key: bool = False,
              force: bool = False) -> bool:
        """Log into LastPass.

        Args:
            username: LastPass account email address.
            password: Master password, delivered to lpass via stdin.
                If None, lpass will prompt interactively.
            trust: Mark this device as trusted so subsequent logins skip MFA.
            plaintext_key: Store the vault decryption key in plaintext on disk.
                WARNING: This materially weakens security. The key can be read
                by any process that can access the lpass config directory.
                Only use in tightly controlled, automated environments where
                no safer alternative exists.
            force: Suppress the plaintext-key confirmation prompt (only
                relevant when plaintext_key=True).
        """
        if plaintext_key:
            logging.warning(
                "SECURITY WARNING: plaintext_key=True causes lpass to write the "
                "vault decryption key to disk in plaintext. Any process or user "
                "with access to ~/.lpass (or $LPASS_HOME) can read the key. "
                "Only use this option in trusted, controlled environments."
            )
        try:
            args = ["login"]
            if trust:
                args.append("--trust")
            if plaintext_key:
                args.append("--plaintext-key")
                if force:
                    args.append("--force")
            args.append(username)
            # Password is passed via stdin, never via argv, to avoid it
            # appearing in system process listings.
            self._run_command(args, input_data=password)
            return True
        except LastPassError:
            # Do not include the exception message in the log — lpass stderr
            # may echo authentication details we do not want persisted.
            logging.error("Login failed for user: %s", username)
            return False

    def logout(self, force: bool = False) -> bool:
        """Log out of LastPass."""
        try:
            args = ["logout"]
            if force:
                args.append("--force")
            self._run_command(args)
            return True
        except LastPassError:
            return False

    def passwd(self) -> bool:
        """Change the LastPass master password interactively.

        lpass will prompt for the current and new password via the configured
        pinentry program or stdin. Password values are never passed through
        this library — the interaction happens directly between lpass and the
        terminal.

        Returns True if lpass exited successfully, False otherwise.
        """
        try:
            self._run_command(["passwd"])
            return True
        except LastPassError as e:
            logging.error("passwd failed: %s", e)
            return False

    def is_logged_in(self) -> bool:
        """Check if currently logged into LastPass."""
        try:
            self._run_command(["status"])
            return True
        except LastPassError:
            return False

    # ------------------------------------------------------------------
    # Synchronisation
    # ------------------------------------------------------------------

    def sync(self, background: bool = False,
             sync_type: Optional[str] = None) -> bool:
        """Synchronize the local vault cache with the LastPass servers.

        Args:
            background: Run the sync as a background daemon process.
            sync_type: Override the sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync_type)
        try:
            args = ["sync"]
            if background:
                args.append("--background")
            if sync_type is not None:
                args.append(f"--sync={sync_type}")
            self._run_command(args)
            return True
        except LastPassError:
            return False

    # ------------------------------------------------------------------
    # Listing / searching
    # ------------------------------------------------------------------

    def list_items(self, group: Optional[str] = None,
                   long: bool = False,
                   last_touch: bool = False) -> List[Dict[str, str]]:
        """List vault entries.

        Args:
            group: If provided, restrict the listing to this group name.
            long: Include the last-modification timestamp (GMT) for each entry.
            last_touch: Include the last-touch timestamp (GMT) for each entry.

        Returns:
            A list of dicts. Each dict always contains "id", "name", and
            "username". When long=True, "last_modified" is also present.
            When last_touch=True, "last_touch" is also present.
        """
        fmt = "%ai|%an|%au"
        if long:
            fmt += "|%am"
        if last_touch:
            fmt += "|%aU"

        args = ["ls", "--format", fmt]
        if group is not None:
            args.append(group)

        try:
            output = self._run_command(args)
        except LastPassError:
            return []

        expected = 3 + int(long) + int(last_touch)
        items = []
        for line in output.splitlines():
            if not line.strip():
                continue
            parts = line.split("|")
            if len(parts) != expected:
                continue
            item: Dict[str, str] = {
                "id": parts[0].strip(),
                "name": parts[1].strip(),
                "username": parts[2].strip(),
            }
            idx = 3
            if long:
                item["last_modified"] = parts[idx].strip()
                idx += 1
            if last_touch:
                item["last_touch"] = parts[idx].strip()
            items.append(item)
        return items

    def search(self, query: str) -> List[Dict[str, str]]:
        """Search vault entries by name or username (case-insensitive)."""
        q = query.lower()
        return [
            item for item in self.list_items()
            if q in item["name"].lower() or q in item["username"].lower()
        ]

    # ------------------------------------------------------------------
    # Retrieving item data
    # ------------------------------------------------------------------

    def get_password(self, item_name_or_id: str) -> Optional[str]:
        """Retrieve the password for a specific item."""
        try:
            return self._run_command(["show", "--password", item_name_or_id])
        except LastPassError:
            return None

    def get_url(self, item_name_or_id: str) -> Optional[str]:
        """Retrieve the URL for a specific item."""
        try:
            return self._run_command(["show", "--url", item_name_or_id])
        except LastPassError:
            return None

    def get_notes(self, item_name_or_id: str) -> Optional[str]:
        """Retrieve the secure notes for a specific item."""
        try:
            return self._run_command(["show", "--notes", item_name_or_id])
        except LastPassError:
            return None

    def get_field(self, item_name_or_id: str, field_name: str) -> Optional[str]:
        """Retrieve the value of a named custom field for a specific item.

        The field name is embedded in a single argv token (--field=<name>)
        rather than as a separate argument, so whitespace or special characters
        in field_name cannot split into additional lpass flags.
        """
        try:
            return self._run_command(
                ["show", f"--field={field_name}", item_name_or_id]
            )
        except LastPassError:
            return None

    def get_item_json(self, item_name_or_id: str,
                      sync: Optional[str] = None,
                      expand_multi: bool = False) -> Optional[Any]:
        """Retrieve full item details as a parsed JSON object.

        Args:
            item_name_or_id: Item name or numeric ID.
            sync: Sync mode — "auto", "now", or "no". Uses lpass default
                when None.
            expand_multi: If True, return data from all entries when the name
                matches more than one item.

        Returns:
            The parsed JSON value (typically a list of dicts), or None on
            error.
        """
        self._validate_sync(sync)
        args = ["show", "--json"]
        if sync is not None:
            args.append(f"--sync={sync}")
        if expand_multi:
            args.append("--expand-multi")
        args.append(item_name_or_id)
        try:
            output = self._run_command(args)
            return json.loads(output)
        except LastPassError:
            return None
        except json.JSONDecodeError as e:
            logging.error("Failed to parse JSON from lpass show: %s", e)
            return None

    def get_item_details(self, item_name_or_id: str) -> Dict[str, str]:
        """Retrieve full details for a specific item as a key/value dict."""
        try:
            output = self._run_command(["show", "--all", item_name_or_id])
        except LastPassError:
            return {}

        details = {}
        for line in output.splitlines():
            if ":" in line:
                key, value = line.split(":", 1)
                details[key.strip()] = value.strip()
        return details

    # ------------------------------------------------------------------
    # Mutating vault entries
    # ------------------------------------------------------------------

    def add(self, item_name: str, template: Optional[str] = None,
            sync: Optional[str] = None) -> bool:
        """Create a new vault entry non-interactively.

        The entry content is supplied as a template string passed to lpass via
        stdin. The template uses the same key: value format as lpass show --all
        output, for example::

            "Username: alice\\nPassword: s3cret\\nURL: https://example.com"

        SECURITY: Any passwords in the template are transmitted via stdin,
        never via command-line arguments, so they do not appear in the
        system process argument list.

        Args:
            item_name: Name (or group/name path) for the new entry,
                e.g. "Work/Email".
            template: Entry content as a newline-separated key: value string.
                If None, lpass will open an editor (interactive fallback).
            sync: Sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync)
        args = ["add", "--non-interactive"]
        if sync is not None:
            args.append(f"--sync={sync}")
        args.append(item_name)
        try:
            self._run_command(args, input_data=template)
            return True
        except LastPassError as e:
            logging.error("add failed: %s", e)
            return False

    def edit(self, item_name_or_id: str, field: str, value: str,
             sync: Optional[str] = None) -> bool:
        """Edit a single field of an existing vault entry.

        SECURITY: The new value is always passed to lpass via stdin regardless
        of which field is being edited, including "password". It never appears
        in the process argument list.

        Args:
            item_name_or_id: Item name or numeric ID.
            field: The field to edit. Use "username", "password", "url",
                "notes", or "name" for built-in fields, or any other string
                for a custom field (passed as --field=<name>).
                NOTE: The lpass CLI only supports editing custom fields on
                Secure Notes. Attempting to edit a custom field on a regular
                password entry will fail with an error from lpass.
            value: New value for the field.
            sync: Sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync)
        _BUILTIN_FIELDS = {"username", "password", "url", "notes", "name"}
        if field in _BUILTIN_FIELDS:
            field_arg = f"--{field}"
        else:
            field_arg = f"--field={field}"

        args = ["edit", "--non-interactive", field_arg]
        if sync is not None:
            args.append(f"--sync={sync}")
        args.append(item_name_or_id)
        # value is passed via stdin — never via argv — to prevent it from
        # appearing in the process argument list.
        try:
            self._run_command(args, input_data=value)
            return True
        except LastPassError as e:
            logging.error("edit failed: %s", e)
            return False

    def mv(self, item_name_or_id: str, group: str,
           sync: Optional[str] = None) -> bool:
        """Move a vault entry to a different group.

        Args:
            item_name_or_id: Unique item name or numeric ID.
            group: Destination group name.
            sync: Sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync)
        args = ["mv"]
        if sync is not None:
            args.append(f"--sync={sync}")
        args.extend([item_name_or_id, group])
        try:
            self._run_command(args)
            return True
        except LastPassError as e:
            logging.error("mv failed: %s", e)
            return False

    def rm(self, item_name_or_id: str, sync: Optional[str] = None) -> bool:
        """Delete a vault entry permanently.

        WARNING: Deletion is not easily reversible. Ensure you have confirmed
        the correct item before calling this method.

        Args:
            item_name_or_id: Unique item name or numeric ID.
            sync: Sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync)
        args = ["rm"]
        if sync is not None:
            args.append(f"--sync={sync}")
        args.append(item_name_or_id)
        try:
            self._run_command(args)
            return True
        except LastPassError as e:
            logging.error("rm failed: %s", e)
            return False

    def duplicate(self, item_name_or_id: str,
                  sync: Optional[str] = None) -> bool:
        """Clone a vault entry, assigning it a new unique ID.

        Args:
            item_name_or_id: Unique item name or numeric ID.
            sync: Sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync)
        args = ["duplicate"]
        if sync is not None:
            args.append(f"--sync={sync}")
        args.append(item_name_or_id)
        try:
            self._run_command(args)
            return True
        except LastPassError as e:
            logging.error("duplicate failed: %s", e)
            return False

    # ------------------------------------------------------------------
    # Password generation
    # ------------------------------------------------------------------

    def generate(self, item_name_or_id: str, length: int,
                 username: Optional[str] = None,
                 url: Optional[str] = None,
                 no_symbols: bool = False,
                 clip: bool = False,
                 sync: Optional[str] = None) -> Optional[str]:
        """Generate a random password and store it in the vault.

        Args:
            item_name_or_id: Name or ID of the entry to create or update.
            length: Password length. Must be a positive integer.
            username: Optional username to associate with the entry.
            url: Optional URL to associate with the entry.
            no_symbols: Generate an alphanumeric-only password (no symbols).
            clip: Copy the generated password to the system clipboard.
                NOTE: Clipboard contents may be readable by other processes
                on the system. Prefer retrieving the password programmatically
                when possible.
            sync: Sync mode — "auto", "now", or "no".

        Returns:
            The generated password string on success, or None on failure.
            The caller is responsible for handling this value securely —
            do not log it or store it in plaintext.

        Raises:
            ValueError: If length is not a positive integer.
        """
        if length <= 0:
            raise ValueError(
                f"length must be a positive integer; got {length!r}"
            )
        self._validate_sync(sync)
        args = ["generate"]
        if sync is not None:
            args.append(f"--sync={sync}")
        if username is not None:
            args.extend(["--username", username])
        if url is not None:
            args.extend(["--url", url])
        if no_symbols:
            args.append("--no-symbols")
        if clip:
            args.append("--clip")
        args.extend([item_name_or_id, str(length)])
        try:
            return self._run_command(args)
        except LastPassError as e:
            logging.error("generate failed: %s", e)
            return None

    # ------------------------------------------------------------------
    # Backup and restore
    # ------------------------------------------------------------------

    def export(self, fields: Optional[List[str]] = None,
               sync: Optional[str] = None) -> Optional[str]:
        """Export all vault data as an unencrypted CSV string.

        SECURITY WARNING: The returned string contains every vault password in
        plaintext. The caller must:
        - Never log this value.
        - Never write it to unencrypted storage.
        - Never transmit it over an unencrypted channel.
        - Minimise the time it is held in memory.
        - If writing to disk, use encrypted storage (e.g. pipe through gpg).

        Args:
            fields: Optional list of field names to include in the export.
                Defaults to all fields when None. Valid field names:
                id, url, username, password, extra, name, fav, grouping,
                group, fullname, last_touch, last_modified_gmt, attachpresent.
            sync: Sync mode — "auto", "now", or "no".

        Returns:
            Raw CSV string on success, or None on failure.
        """
        self._validate_sync(sync)
        args = ["export"]
        if sync is not None:
            args.append(f"--sync={sync}")
        if fields:
            args.extend(["--fields", ",".join(fields)])
        try:
            return self._run_command(args)
        except LastPassError as e:
            logging.error("export failed: %s", e)
            return None

    def import_vault(self, csv_data: str,
                     keep_dupes: bool = False,
                     sync: Optional[str] = None) -> bool:
        """Import vault entries from a CSV string.

        The CSV data is passed to lpass via stdin, avoiding the need to write
        a temporary file that could be left behind unencrypted.

        SECURITY WARNING: csv_data may contain plaintext passwords. The caller
        must:
        - Never log this value.
        - Obtain it only from encrypted storage.
        - Securely discard the source after import.

        Args:
            csv_data: CSV content in the format produced by export().
            keep_dupes: If True, import entries even when duplicates exist.
            sync: Sync mode — "auto", "now", or "no".
        """
        self._validate_sync(sync)
        args = ["import"]
        if sync is not None:
            args.append(f"--sync={sync}")
        if keep_dupes:
            args.append("--keep-dupes")
        # Pass csv_data via stdin so it never touches the filesystem.
        try:
            self._run_command(args, input_data=csv_data)
            return True
        except LastPassError as e:
            logging.error("import failed: %s", e)
            return False


if __name__ == "__main__":
    pass
