"""
FastAPI user handling (via fastapi-users)
"""

import os
from uuid import UUID, uuid4
import asyncio

from typing import Optional, List, TYPE_CHECKING, cast, Callable, Tuple

from fastapi import (
    Request,
    HTTPException,
    Depends,
    APIRouter,
    Body,
)

from pymongo.errors import DuplicateKeyError
from pymongo.collation import Collation

from .models import (
    EmailStr,
    UserCreate,
    UserUpdateEmailName,
    UserUpdatePassword,
    User,
    UserOrgInfoOut,
    UserOut,
    UserRole,
    InvitePending,
    InviteOut,
    PaginatedInvitePendingResponse,
    FailedLogin,
    UpdatedResponse,
    SuccessResponse,
    UserEmailWithOrgInfo,
    PaginatedUserEmailsResponse,
)
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
from .utils import is_bool, dt_now

from .auth import (
    init_jwt_auth,
    RESET_AUD,
    RESET_ALLOW_AUD,
    VERIFY_AUD,
    VERIFY_ALLOW_AUD,
    RESET_VERIFY_TOKEN_LIFETIME_MINUTES,
    verify_and_update_password,
    get_password_hash,
    generate_password,
    generate_jwt,
    decode_jwt,
)

if TYPE_CHECKING:
    from .invites import InviteOps
    from .emailsender import EmailSender
    from .orgs import OrgOps
    from .basecrawls import BaseCrawlOps
    from .crawlconfigs import CrawlConfigOps
else:
    InviteOps = EmailSender = OrgOps = BaseCrawlOps = CrawlConfigOps = object


# ============================================================================
# pylint: disable=raise-missing-from, too-many-public-methods, too-many-instance-attributes
class UserManager:
    """Browsertrix UserManager"""

    invites: InviteOps
    email: EmailSender

    org_ops: OrgOps
    base_crawl_ops: BaseCrawlOps
    crawl_config_ops: CrawlConfigOps

    def __init__(self, mdb, email, invites):
        self.users = mdb.get_collection("users")
        self.failed_logins = mdb.get_collection("logins")
        self.email = email
        self.invites = invites

        self.org_ops = cast(OrgOps, None)
        self.crawl_config_ops = cast(CrawlConfigOps, None)
        self.base_crawl_ops = cast(BaseCrawlOps, None)

        self.email_collation = Collation("en", strength=2)

        self.registration_enabled = is_bool(os.environ.get("REGISTRATION_ENABLED"))

    # pylint: disable=attribute-defined-outside-init
    def set_ops(self, org_ops, crawl_config_ops, base_crawl_ops):
        """set org ops"""
        self.org_ops = org_ops
        self.crawl_config_ops = crawl_config_ops
        self.base_crawl_ops = base_crawl_ops

    async def init_index(self):
        """init lookup index"""
        await self.users.create_index("id", unique=True)
        await self.users.create_index("email", unique=True)

        await self.users.create_index(
            "email",
            name="case_insensitive_email_index",
            collation=self.email_collation,
        )

        # Expire failed logins object after one hour
        await self.failed_logins.create_index("attempted", expireAfterSeconds=3600)

    async def register(
        self, create: UserCreate, request: Optional[Request] = None
    ) -> User:
        """override user creation to check if invite token is present"""
        create.name = create.name or create.email

        # if open registration not enabled, can only register with an invite
        if not self.registration_enabled and not create.inviteToken:
            raise HTTPException(status_code=400, detail="invite_token_required")

        invite: Optional[InvitePending] = None
        if create.inviteToken:
            # raises if invite is invalid
            invite = await self.invites.get_valid_invite(
                create.inviteToken, email=create.email
            )

        try:
            user = await self.create_user(
                name=create.name,
                email=create.email,
                password=create.password,
                is_verified=invite is not None,
            )

        except DuplicateKeyError:
            maybe_user = await self.get_by_email(create.email)
            # shouldn't happen since user should exist if we have duplicate key, but just in case!
            if not maybe_user:
                raise HTTPException(status_code=400, detail="user_missing")

            if not await self.check_password(maybe_user, create.password):
                raise HTTPException(status_code=400, detail="invalid_current_password")

            user = maybe_user

        default_register_org = await self.org_ops.get_default_register_org()

        # if invite, add via invite path
        if invite:
            await self.org_ops.add_user_by_invite(
                invite, user, default_org=default_register_org
            )

        else:
            await self.org_ops.add_user_to_org(
                default_register_org, user.id, UserRole.CRAWLER
            )

            asyncio.create_task(self.request_verify(user, request))

        return user

    async def get_user_info_with_orgs(self, user: User) -> UserOut:
        """return User info"""
        user_orgs, _ = await self.org_ops.get_orgs_for_user(
            user,
            # Set high so that we get all orgs even after reducing default page size
            page_size=1_000,
        )

        if user_orgs:
            orgs = [
                UserOrgInfoOut(
                    id=org.id,
                    name=org.name,
                    slug=org.slug,
                    default=org.default,
                    role=(
                        UserRole.SUPERADMIN
                        if user.is_superuser
                        else org.users.get(str(user.id))
                    ),
                )
                for org in user_orgs
            ]
        else:
            orgs = []

        return UserOut(
            id=user.id,
            email=user.email,
            name=user.name,
            orgs=orgs,
            is_superuser=user.is_superuser,
            is_verified=user.is_verified,
        )

    async def validate_password(self, password: str) -> None:
        """
        Validate a password. raise HTTPException with status 422
        if password is invalid
        """
        pw_length = len(password)
        if not 8 <= pw_length <= 64:
            raise HTTPException(status_code=400, detail="invalid_password")

    async def check_password(self, user: User, password: str) -> bool:
        """check if password is valid, also update hashed_password if needed"""
        verified, updated_password_hash = verify_and_update_password(
            password, user.hashed_password
        )

        if not verified:
            return False

        # Update password hash to a more robust one if needed
        if updated_password_hash:
            user.hashed_password = updated_password_hash
            await self.users.find_one_and_update(
                {"id": user.id}, {"$set": {"hashed_password": user.hashed_password}}
            )

        return True

    async def authenticate(self, email: EmailStr, password: str) -> Optional[User]:
        """authenticate user via login form"""
        user = await self.get_by_email(email)
        if not user:
            # Run the hasher to mitigate timing attack
            # Inspired from Django: https://code.djangoproject.com/ticket/20760
            get_password_hash(password)
            return None

        if await self.check_password(user, password):
            return user

        return None

    async def get_user_names_by_ids(self, user_ids: List[str]) -> dict[str, str]:
        """return list of user names for given ids"""
        user_uuid_ids = [UUID(id_) for id_ in user_ids]
        cursor = self.users.find(
            {"id": {"$in": user_uuid_ids}}, projection=["id", "name", "email"]
        )
        return await cursor.to_list(length=1000)

    async def get_user_emails_by_ids(self):
        """return dict of user emails keyed by id"""
        email_id_map = {}
        async for user in self.users.find({}):
            email_id_map[user["id"]] = user["email"]
        return email_id_map

    async def get_superuser(self) -> Optional[User]:
        """return current superuser, if any"""
        user_data = await self.users.find_one({"is_superuser": True})
        if not user_data:
            return None

        return User(**user_data)

    async def create_super_user(self) -> None:
        """Initialize a super user from env vars"""
        email = os.environ.get("SUPERUSER_EMAIL")
        password = os.environ.get("SUPERUSER_PASSWORD")
        name = os.environ.get("SUPERUSER_NAME", "admin")
        if not email:
            print("No superuser defined", flush=True)
            return

        if not password:
            password = generate_password()

        superuser = await self.get_superuser()
        if superuser:
            if str(superuser.email) != email:
                await self.update_email_name(superuser, cast(EmailStr, email), name)
                print("Superuser email updated")

            if not await self.check_password(superuser, password):
                await self._update_password(superuser, password)
                print("Superuser password updated")

            return

        try:
            res = await self.create_user(
                name=name, email=email, password=password, is_superuser=True
            )
            print(f"Super user {email} created", flush=True)
            print(res, flush=True)
        except DuplicateKeyError as exc:
            print(exc)
            print(f"User {email} already exists", flush=True)

    async def request_verify(
        self, user: User, request: Optional[Request] = None
    ) -> None:
        """start verifying user, if not already verified"""
        if user.is_verified:
            raise HTTPException(status_code=400, detail="verify_user_already_verified")

        token_data = {
            "user_id": str(user.id),
            "email": user.email,
            "aud": VERIFY_AUD,
        }
        token = generate_jwt(
            token_data,
            RESET_VERIFY_TOKEN_LIFETIME_MINUTES,
        )

        self.email.send_user_validation(
            user.email, token, dict(request.headers) if request else None
        )

    # pylint: disable=too-many-arguments
    async def create_user(
        self,
        name: str,
        email: str,
        password: Optional[str] = None,
        is_superuser=False,
        is_verified=False,
    ) -> User:
        """create new user in db"""

        if not email:
            raise HTTPException(status_code=400, detail="missing_user_email")

        if not password:
            password = generate_password()

        await self.validate_password(password)

        hashed_password = get_password_hash(password)

        id_ = uuid4()

        user = User(
            id=id_,
            email=email,
            name=name,
            hashed_password=hashed_password,
            is_superuser=is_superuser,
            is_verified=is_verified,
        )

        await self.users.insert_one(user.dict())

        return user

    async def get_by_id(self, _id: UUID) -> Optional[User]:
        """get user by unique id"""
        user = await self.users.find_one({"id": _id})

        if not user:
            return None

        return User(**user)

    async def get_by_email(self, email: str) -> Optional[User]:
        """get user by email"""
        user = await self.users.find_one(
            {"email": email}, collation=self.email_collation
        )
        if not user:
            return None

        return User(**user)

    async def verify(self, token: str) -> None:
        """validate verification request token"""
        exc = HTTPException(
            status_code=400,
            detail="verify_user_bad_token",
        )

        try:
            data = decode_jwt(token, audience=VERIFY_ALLOW_AUD)
        except:
            raise exc

        try:
            user_id = data["user_id"]
            email = data["email"]
        except KeyError:
            raise exc

        user = await self.get_by_email(email)
        if not user:
            raise exc

        try:
            user_uuid = UUID(user_id)
        except ValueError:
            raise exc

        if user_uuid != user.id:
            raise exc

        if user.is_verified:
            raise HTTPException(
                status_code=400,
                detail="verify_user_already_verified",
            )

        user.is_verified = True
        await self.update_verified(user)

    async def forgot_password(
        self, user: User, request: Optional[Request] = None
    ) -> None:
        """start forgot password reset request"""
        token_data = {
            "user_id": str(user.id),
            "aud": RESET_AUD,
        }
        token = generate_jwt(
            token_data,
            RESET_VERIFY_TOKEN_LIFETIME_MINUTES,
        )

        print(f"User {user.id} has forgot their password. Reset token: {token}")
        self.email.send_user_forgot_password(
            user.email, token, request and request.headers
        )

    async def reset_password(self, token: str, password: str) -> None:
        """reset password to new password given reset token"""
        try:
            data = decode_jwt(token, audience=RESET_ALLOW_AUD)
        except:
            raise HTTPException(
                status_code=400,
                detail="reset_password_bad_token",
            )

        user_id = data["user_id"]

        try:
            user_uuid = UUID(user_id)
        except ValueError:
            raise HTTPException(
                status_code=400,
                detail="reset_password_bad_token",
            )

        user = await self.get_by_id(user_uuid)
        if user:
            await self._update_password(user, password)

    async def change_password(
        self, user_update: UserUpdatePassword, user: User
    ) -> None:
        """Change password after checking existing password"""
        if not await self.check_password(user, user_update.password):
            raise HTTPException(status_code=400, detail="invalid_current_password")

        await self._update_password(user, user_update.newPassword)

    async def change_email_name(
        self, user_update: UserUpdateEmailName, user: User
    ) -> None:
        """Change email and/or name, if specified, throw if neither is specified"""
        if not user_update.email and not user_update.name:
            raise HTTPException(status_code=400, detail="no_updates_specified")

        await self.update_email_name(user, user_update.email, user_update.name)

        if user_update.name:
            await self.base_crawl_ops.update_usernames(user.id, user_update.name)
            await self.crawl_config_ops.update_usernames(user.id, user_update.name)

    async def update_verified(self, user: User) -> None:
        """Update verified status for user"""
        await self.users.find_one_and_update(
            {"id": user.id}, {"$set": {"is_verified": user.is_verified}}
        )

    async def update_email_name(
        self, user: User, email: Optional[EmailStr], name: Optional[str]
    ) -> None:
        """Update email for user"""
        query: dict[str, str] = {}
        if email:
            query["email"] = str(email)
        if name:
            query["name"] = name

        try:
            await self.users.find_one_and_update({"id": user.id}, {"$set": query})
        except DuplicateKeyError:
            raise HTTPException(status_code=400, detail="user_already_exists")

    async def _update_password(self, user: User, new_password: str) -> None:
        """Update hashed_password for user, overwriting previous password hash

        Internal method, use change_password() for password verification first

        Method also ensures user is not locked after password change
        """
        await self.validate_password(new_password)
        hashed_password = get_password_hash(new_password)
        if hashed_password == user.hashed_password:
            return
        user.hashed_password = hashed_password
        await self.users.find_one_and_update(
            {"id": user.id},
            {"$set": {"hashed_password": hashed_password}},
        )
        await self.reset_failed_logins(user.email)

    async def reset_failed_logins(self, email: str) -> None:
        """Reset consecutive failed login attempts by deleting FailedLogin object"""
        await self.failed_logins.delete_one(
            {"email": email}, collation=self.email_collation
        )

    async def inc_failed_logins(self, email: str) -> None:
        """Inc consecutive failed login attempts for user by 1

        If a FailedLogin object doesn't already exist, create it
        """
        failed_login = FailedLogin(id=uuid4(), email=email, attempted=dt_now())

        await self.failed_logins.find_one_and_update(
            {"email": email},
            {
                "$setOnInsert": failed_login.to_dict(exclude={"count", "attempted"}),
                "$set": {"attempted": failed_login.attempted},
                "$inc": {"count": 1},
            },
            upsert=True,
            collation=self.email_collation,
        )

    async def get_failed_logins_count(self, email: str) -> int:
        """Get failed login attempts for user, falling back to 0"""
        failed_login = await self.failed_logins.find_one(
            {"email": email}, collation=self.email_collation
        )
        if not failed_login:
            return 0
        return failed_login.get("count", 0)

    async def get_user_emails(
        self,
        page_size: int = DEFAULT_PAGE_SIZE,
        page: int = 1,
    ) -> Tuple[List[UserEmailWithOrgInfo], int]:
        """Get user emails with org info for each for paginated endpoint"""
        # Zero-index page for query
        page = page - 1
        skip = page_size * page

        emails: List[UserEmailWithOrgInfo] = []

        total = await self.users.count_documents({"is_superuser": False})
        async for res in self.users.find(
            {"is_superuser": False}, skip=skip, limit=page_size
        ):
            user = User(**res)
            user_out = await self.get_user_info_with_orgs(user)
            emails.append(
                UserEmailWithOrgInfo(email=user_out.email, orgs=user_out.orgs)
            )

        return emails, total


# ============================================================================
def init_user_manager(mdb, emailsender, invites):
    """
    Load users table and init /users routes
    """

    return UserManager(mdb, emailsender, invites)


# ============================================================================
# pylint: disable=too-many-locals, raise-missing-from
def init_users_api(app, user_manager: UserManager):
    """init fastapi_users"""

    auth_jwt_router, current_active_user, shared_secret_or_active_user = init_jwt_auth(
        user_manager
    )

    app.include_router(
        auth_jwt_router,
        prefix="/auth/jwt",
        tags=["auth"],
    )

    app.include_router(
        init_auth_router(user_manager),
        prefix="/auth",
        tags=["auth"],
    )

    app.include_router(
        init_users_router(current_active_user, user_manager),
        prefix="/users",
        tags=["users"],
    )

    return current_active_user, shared_secret_or_active_user


# ============================================================================
def init_auth_router(user_manager: UserManager) -> APIRouter:
    """/auth router"""

    auth_router = APIRouter()

    @auth_router.post("/register", status_code=201, response_model=UserOut)
    async def register(request: Request, create: UserCreate):
        user = await user_manager.register(create, request=request)
        return await user_manager.get_user_info_with_orgs(user)

    @auth_router.post(
        "/forgot-password", status_code=202, response_model=SuccessResponse
    )
    async def forgot_password(
        request: Request,
        email: EmailStr = Body(..., embed=True),
    ):
        user = await user_manager.get_by_email(email)
        if user:
            await user_manager.forgot_password(user, request)
        return {"success": True}

    @auth_router.post("/reset-password", response_model=SuccessResponse)
    async def reset_password(
        # request: Request,
        token: str = Body(...),
        password: str = Body(...),
    ):
        await user_manager.reset_password(token, password)
        return {"success": True}

    @auth_router.post(
        "/request-verify-token", status_code=202, response_model=SuccessResponse
    )
    async def request_verify_token(
        request: Request,
        email: EmailStr = Body(..., embed=True),
    ):
        user = await user_manager.get_by_email(email)
        if user:
            await user_manager.request_verify(user, request)

        return {"success": True}

    @auth_router.post("/verify", response_model=SuccessResponse)
    async def verify(
        token: str = Body(..., embed=True),
    ):
        await user_manager.verify(token)
        return {"success": True}

    return auth_router


# ============================================================================
def init_users_router(
    current_active_user: Callable, user_manager: UserManager
) -> APIRouter:
    """/users routes"""
    users_router = APIRouter()

    @users_router.get("/me", tags=["users"], response_model=UserOut)
    async def current_user_with_org_info(user: User = Depends(current_active_user)):
        """/users/me with orgs user belongs to."""
        return await user_manager.get_user_info_with_orgs(user)

    @users_router.put(
        "/me/password-change", tags=["users"], response_model=UpdatedResponse
    )
    async def update_my_password(
        user_update: UserUpdatePassword,
        user: User = Depends(current_active_user),
    ):
        """update password, requires current password"""
        await user_manager.change_password(user_update, user)
        return {"updated": True}

    @users_router.patch("/me", tags=["users"], response_model=UpdatedResponse)
    async def update_my_email_and_name(
        user_update: UserUpdateEmailName,
        user: User = Depends(current_active_user),
    ):
        """update password, requires current password"""
        await user_manager.change_email_name(user_update, user)
        return {"updated": True}

    @users_router.get("/me/invite/{token}", tags=["invites"], response_model=InviteOut)
    async def get_existing_user_invite_info(
        token: UUID, user: User = Depends(current_active_user)
    ):
        invite = await user_manager.invites.get_valid_invite(
            token, email=None, userid=user.id
        )

        return await user_manager.invites.get_invite_out(invite, user_manager, True)

    @users_router.get("/invite/{token}", tags=["invites"], response_model=InviteOut)
    async def get_invite_info(token: UUID, email: EmailStr):
        invite = await user_manager.invites.get_valid_invite(token, email)

        return await user_manager.invites.get_invite_out(invite, user_manager, True)

    # pylint: disable=invalid-name
    @users_router.get(
        "/invites", tags=["invites"], response_model=PaginatedInvitePendingResponse
    )
    async def get_pending_invites(
        user: User = Depends(current_active_user),
        pageSize: int = DEFAULT_PAGE_SIZE,
        page: int = 1,
    ):
        if not user.is_superuser:
            raise HTTPException(status_code=403, detail="not_allowed")

        pending_invites, total = await user_manager.invites.get_pending_invites(
            user_manager, page_size=pageSize, page=page
        )
        return paginated_format(pending_invites, total, page, pageSize)

    @users_router.get(
        "/emails", tags=["users"], response_model=PaginatedUserEmailsResponse
    )
    async def get_user_emails(
        user: User = Depends(current_active_user),
        pageSize: int = DEFAULT_PAGE_SIZE,
        page: int = 1,
    ):
        """Get emails of registered users with org information (superuser only)"""
        if not user.is_superuser:
            raise HTTPException(status_code=403, detail="not_allowed")

        emails, total = await user_manager.get_user_emails(
            page_size=pageSize, page=page
        )
        return paginated_format(emails, total, page, pageSize)

    return users_router
