from mojo.helpers import dates, crypto
from mojo import errors as merrors
from mojo.helpers.settings import settings
from mojo.apps.account.models.user import User

# Token kind prefixes — visible to the webapp before any decoding
KIND_PASSWORD_RESET = "pr"
KIND_MAGIC_LOGIN = "ml"
KIND_EMAIL_VERIFY = "ev"
KIND_INVITE = "iv"
KIND_EMAIL_CHANGE = "ec"
KIND_PHONE_CHANGE = "pc"
KIND_DEACTIVATE = "dv"

# Secrets keys per kind — kept separate so they don't invalidate each other
_JTI_KEYS = {
    KIND_PASSWORD_RESET: "password_reset_jti",
    KIND_MAGIC_LOGIN: "magic_login_jti",
    KIND_EMAIL_VERIFY: "email_verify_jti",
    KIND_INVITE: "invite_jti",
    KIND_EMAIL_CHANGE: "email_change_jti",
    KIND_PHONE_CHANGE: "phone_change_jti",
    KIND_DEACTIVATE: "deactivate_jti",
}
_TS_KEYS = {
    KIND_PASSWORD_RESET: "password_reset_ts",
    KIND_MAGIC_LOGIN: "magic_login_ts",
    KIND_EMAIL_VERIFY: "email_verify_ts",
    KIND_INVITE: "invite_ts",
    KIND_EMAIL_CHANGE: "email_change_ts",
    KIND_PHONE_CHANGE: "phone_change_ts",
    KIND_DEACTIVATE: "deactivate_ts",
}

# Backward-compat override hooks for tests:
# - `_TTL[kind] = -1` forces immediate token expiry.
# - `EMAIL_VERIFY_CODE_TTL` / `EMAIL_CHANGE_CODE_TTL` can be patched directly.
# Keep values `None` by default so runtime reads still come from settings.
_TTL = {
    KIND_PASSWORD_RESET: None,
    KIND_MAGIC_LOGIN: None,
    KIND_EMAIL_VERIFY: None,
    KIND_INVITE: None,
    KIND_EMAIL_CHANGE: None,
    KIND_PHONE_CHANGE: None,
    KIND_DEACTIVATE: None,
}
EMAIL_VERIFY_CODE_TTL = None
EMAIL_CHANGE_CODE_TTL = None


def _token_ttl(kind):
    override = _TTL.get(kind, None)
    if override is not None:
        return override
    ttl_map = {
        KIND_PASSWORD_RESET: settings.get("PASSWORD_RESET_TOKEN_TTL", 3600),
        KIND_MAGIC_LOGIN: settings.get("MAGIC_LOGIN_TOKEN_TTL", 3600),
        KIND_EMAIL_VERIFY: settings.get("EMAIL_VERIFY_TOKEN_TTL", 86400),
        KIND_INVITE: settings.get("INVITE_TOKEN_TTL", 604800),
        KIND_EMAIL_CHANGE: settings.get("EMAIL_CHANGE_TOKEN_TTL", 3600),
        KIND_PHONE_CHANGE: settings.get("PHONE_CHANGE_TOKEN_TTL", 600),
        KIND_DEACTIVATE: settings.get("DEACTIVATE_TOKEN_TTL", 900),
    }
    return ttl_map.get(kind, 3600)


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

def _generate(user, kind):
    """
    Generate a signed token for the given kind.

    Returned string format:  <kind>:<hex_encoded_payload><6-char-sig>
    The kind prefix is readable by the webapp without any decoding.
    """
    now_ts = int(dates.utcnow().timestamp())
    jti = crypto.random_string(12, True, True, False)
    token = crypto.b64_encode({"uid": user.pk, "ts": now_ts, "jti": jti, "kind": kind})
    sig = crypto.sign(token, user.get_auth_key())
    user.set_secret(_JTI_KEYS[kind], jti)
    user.set_secret(_TS_KEYS[kind], now_ts)
    user.save(update_fields=["mojo_secrets", "modified"])
    hex_token = token.encode("utf-8").hex() + sig[-6:]
    return f"{kind}:{hex_token}"


def _verify(raw_token, expected_kind=None):
    """
    Verify a token generated by _generate().
    Returns the User on success, raises ValueException on failure.

    expected_kind: if provided, the token's kind must match exactly.
    """
    orig_token = raw_token
    user = None
    try:
        # Parse kind prefix
        if ":" not in raw_token:
            raise merrors.ValueException("Invalid token format")
        kind, hex_token = raw_token.split(":", 1)
        if kind not in _JTI_KEYS:
            raise merrors.ValueException("Unknown token kind")
        if expected_kind is not None and kind != expected_kind:
            raise merrors.ValueException("Invalid token kind")

        tsig = hex_token[-6:]
        hex_token = hex_token[:-6]
        token = bytes.fromhex(hex_token).decode("utf-8")
        obj = crypto.b64_decode(token)
        if not isinstance(obj, dict) or "uid" not in obj or "ts" not in obj or "jti" not in obj:
            raise merrors.ValueException("Invalid token")
        if obj.get("kind") != kind:
            raise merrors.ValueException("Token kind mismatch")

        user = User.objects.get(pk=obj["uid"])
        sig = crypto.sign(token, user.get_auth_key())
        if sig[-6:] != tsig:
            user.report_incident(
                details=f"{user.username} invalid token signature (kind={kind})",
                event_type="invalid_token")
            raise merrors.ValueException("Invalid token signature")

        now_ts = int(dates.utcnow().timestamp())
        ttl = _token_ttl(kind)
        if now_ts - int(obj["ts"]) > int(ttl):
            user.report_incident(
                details=f"{user.username} expired token (kind={kind})",
                event_type="expired_token")
            raise merrors.ValueException("Expired token")

        expected_jti = user.get_secret(_JTI_KEYS[kind])
        if not expected_jti or expected_jti != obj["jti"]:
            user.report_incident(
                details=f"{user.username} token already used or superseded (kind={kind})",
                event_type="invalid_token")
            raise merrors.ValueException("Token already used")

        # Consume — single use
        user.set_secret(_JTI_KEYS[kind], None)
        user.save(update_fields=["mojo_secrets", "modified"])
        return user

    except merrors.ValueException:
        raise
    except Exception as err:
        if user:
            user.report_incident(
                details=f"{user.username} invalid token format (kind={kind})",
                event_type="token:unknown",
                error=str(err),
                level=8, token=orig_token)
        else:
            User.class_report_incident(
                details="invalid token format",
                event_type="token:unknown",
                error=str(err),
                level=8, token=orig_token)
        raise merrors.ValueException("Invalid token format")


# -----------------------------------------------------------------
# Public API
# -----------------------------------------------------------------

def generate_password_reset_token(user):
    """Generate a password-reset token (kind=pr)."""
    return _generate(user, KIND_PASSWORD_RESET)


def verify_password_reset_token(token):
    """Verify a password-reset token and return the User."""
    return _verify(token, expected_kind=KIND_PASSWORD_RESET)


def generate_magic_login_token(user, channel="email"):
    """Generate a magic-login token (kind=ml). channel: 'email' or 'sms'."""
    token = _generate(user, KIND_MAGIC_LOGIN)
    user.set_secret("magic_login_channel", channel)
    user.save(update_fields=["mojo_secrets", "modified"])
    return token


def verify_magic_login_token(token):
    """Verify a magic-login token and return (user, channel)."""
    user = _verify(token, expected_kind=KIND_MAGIC_LOGIN)
    channel = user.get_secret("magic_login_channel") or "email"
    user.set_secret("magic_login_channel", None)
    user.save(update_fields=["mojo_secrets", "modified"])
    return user, channel


def generate_email_verify_token(user):
    """Generate an email verification token (kind=ev). TTL defaults to 24h."""
    return _generate(user, KIND_EMAIL_VERIFY)


def verify_email_verify_token(token):
    """Verify an email verification token and return the User."""
    return _verify(token, expected_kind=KIND_EMAIL_VERIFY)


def generate_email_verify_code(user):
    """
    Generate a 6-digit email verification code and store it in user secrets.
    Returns the code string so the caller can include it in the verification email.
    """
    code = crypto.random_string(6, allow_digits=True, allow_chars=False, allow_special=False)
    user.set_secret("email_verify_code", code)
    user.set_secret("email_verify_code_ts", int(dates.utcnow().timestamp()))
    user.save(update_fields=["mojo_secrets", "modified"])
    return code


def verify_email_verify_code(user, code):
    """
    Verify the 6-digit email verification code for a user.
    Raises ValueException on mismatch or expiry.
    Consumes the code on success (single-use).
    """
    stored = user.get_secret("email_verify_code")
    stored_ts = int(user.get_secret("email_verify_code_ts") or 0)
    now_ts = int(dates.utcnow().timestamp())
    if not stored or code != stored:
        user.report_incident(
            details=f"{user.username} invalid email verify code",
            event_type="email_verify:invalid")
        raise merrors.ValueException("Invalid code")
    verify_ttl = EMAIL_VERIFY_CODE_TTL if EMAIL_VERIFY_CODE_TTL is not None else settings.get("EMAIL_VERIFY_CODE_TTL", 600)
    if now_ts - stored_ts > int(verify_ttl):
        user.report_incident(
            details=f"{user.username} expired email verify code",
            event_type="email_verify:expired")
        raise merrors.ValueException("Expired code")
    # Consume — single use
    user.set_secret("email_verify_code", None)
    user.set_secret("email_verify_code_ts", None)
    user.save(update_fields=["mojo_secrets", "modified"])


def generate_phone_verify_code(user):
    """
    Generate a 6-digit SMS verification code and store it in user secrets.
    Returns the code string so the caller can pass it to phonehub.send_sms().
    """
    code = crypto.random_string(6, True, False, False)
    user.set_secret("phone_verify_code", code)
    user.set_secret("phone_verify_ts", int(dates.utcnow().timestamp()))
    user.save(update_fields=["mojo_secrets", "modified"])
    return code


def verify_phone_verify_code(user, code):
    """
    Verify the 6-digit code for a user.
    Raises ValueException on mismatch or expiry.
    Consumes the code on success (single-use).
    """
    stored = user.get_secret("phone_verify_code")
    stored_ts = int(user.get_secret("phone_verify_ts") or 0)
    now_ts = int(dates.utcnow().timestamp())
    if not stored or code != stored:
        user.report_incident(
            details=f"{user.username} invalid phone verify code",
            event_type="phone_verify:invalid")
        raise merrors.ValueException("Invalid code")
    if now_ts - stored_ts > int(settings.get("PHONE_VERIFY_CODE_TTL", 600)):
        user.report_incident(
            details=f"{user.username} expired phone verify code",
            event_type="phone_verify:expired")
        raise merrors.ValueException("Expired code")
    # Consume — single use
    user.set_secret("phone_verify_code", None)
    user.set_secret("phone_verify_ts", None)
    user.save(update_fields=["mojo_secrets", "modified"])


def generate_invite_token(user):
    """Generate an invite token (kind=iv). TTL defaults to 7 days."""
    token = _generate(user, KIND_INVITE)
    # Cache the full token so get_or_generate_invite_token can reuse it
    user.set_secret("invite_token", token)
    user.save(update_fields=["mojo_secrets", "modified"])
    return token


def get_or_generate_invite_token(user):
    """
    Return the existing invite token if still valid, otherwise generate a fresh one.
    Allows inviting the same user to multiple groups without overwriting the JTI
    and invalidating already-sent invite emails.
    """
    existing_ts = int(user.get_secret("invite_ts") or 0)
    existing_token = user.get_secret("invite_token")
    if existing_ts and existing_token:
        now_ts = int(dates.utcnow().timestamp())
        if now_ts - existing_ts < int(settings.get("INVITE_TOKEN_TTL", 604800)):
            return existing_token
    return generate_invite_token(user)


def verify_invite_token(token):
    """Verify an invite token and return the User."""
    return _verify(token, expected_kind=KIND_INVITE)


def generate_email_change_token(user, new_email):
    """
    Generate an email-change confirmation token (kind=ec). TTL defaults to 1 hour.
    Stores the pending new_email in user secrets alongside the JTI so the confirm
    step can retrieve it without it being in the URL.
    Clears any outstanding code-flow OTP so link and code paths cannot be active
    at the same time.
    """
    token = _generate(user, KIND_EMAIL_CHANGE)
    user.set_secret("pending_email", new_email)
    # Clear any code-flow OTP so the two paths don't cross
    user.set_secret("email_change_otp", None)
    user.set_secret("email_change_otp_ts", None)
    user.save(update_fields=["mojo_secrets", "modified"])
    return token


def verify_email_change_token(token):
    """
    Verify an email-change token and return (user, new_email).
    Clears pending_email from secrets on success (single-use).
    Raises ValueException on any failure.
    """
    user = _verify(token, expected_kind=KIND_EMAIL_CHANGE)
    new_email = user.get_secret("pending_email")
    user.set_secret("pending_email", None)
    user.save(update_fields=["mojo_secrets", "modified"])
    if not new_email:
        user.report_incident(
            details=f"{user.username} email change token had no pending_email",
            event_type="email_change:invalid")
        raise merrors.ValueException("Invalid token")
    return user, new_email


def generate_email_change_otp(user, new_email):
    """
    Begin a code-based email change.

    Stores the pending new_email and a 6-digit OTP in user secrets.
    Clears any outstanding ec: link token so link and code paths cannot be active
    at the same time.
    Returns the OTP string so the caller can include it in an email to new_email.

    TTL is EMAIL_CHANGE_CODE_TTL (default 10 minutes).
    """
    # Invalidate any outstanding link-flow token so the two paths don't cross
    user.set_secret(_JTI_KEYS[KIND_EMAIL_CHANGE], None)
    user.set_secret(_TS_KEYS[KIND_EMAIL_CHANGE], None)
    otp = crypto.random_string(6, allow_digits=True, allow_chars=False, allow_special=False)
    user.set_secret("pending_email", new_email)
    user.set_secret("email_change_otp", otp)
    user.set_secret("email_change_otp_ts", int(dates.utcnow().timestamp()))
    user.save(update_fields=["mojo_secrets", "modified"])
    return otp


def verify_email_change_otp(user, code):
    """
    Complete a code-based email change.

    Validates the 6-digit OTP against what was stored at request time.
    Returns new_email on success.
    Raises ValueException on any failure (bad code, expired, no pending change).
    Clears all email-change OTP secrets on success so this is single-use.
    """
    new_email = user.get_secret("pending_email")
    stored_otp = user.get_secret("email_change_otp")
    stored_ts = int(user.get_secret("email_change_otp_ts") or 0)

    if not new_email or not stored_otp:
        user.report_incident(
            details=f"{user.username} email change OTP confirm had no pending state",
            event_type="email_change:invalid")
        raise merrors.ValueException("No pending email change")

    now_ts = int(dates.utcnow().timestamp())
    change_ttl = EMAIL_CHANGE_CODE_TTL if EMAIL_CHANGE_CODE_TTL is not None else settings.get("EMAIL_CHANGE_CODE_TTL", 600)
    if now_ts - stored_ts > int(change_ttl):
        user.set_secret("pending_email", None)
        user.set_secret("email_change_otp", None)
        user.set_secret("email_change_otp_ts", None)
        user.save(update_fields=["mojo_secrets", "modified"])
        user.report_incident(
            details=f"{user.username} email change OTP expired",
            event_type="email_change:expired")
        raise merrors.ValueException("Expired code")

    if code != stored_otp:
        user.report_incident(
            details=f"{user.username} invalid email change OTP",
            event_type="email_change:invalid_otp")
        raise merrors.ValueException("Invalid code")

    # Consume — single use
    user.set_secret("pending_email", None)
    user.set_secret("email_change_otp", None)
    user.set_secret("email_change_otp_ts", None)
    user.save(update_fields=["mojo_secrets", "modified"])

    return new_email


def generate_phone_change_token(user, new_phone):
    """
    Begin a phone-number change.

    Generates a signed pc: session token (identifies the user and the pending
    change) and a separate 6-digit OTP that must be sent to new_phone via SMS.
    Both are stored in user secrets; the OTP is also returned to the caller so
    it can be forwarded to phonehub.send_sms().

    Returns (session_token, otp_code).

    The session_token is an opaque string returned to the frontend so it can
    be submitted alongside the OTP code in the confirm step.  It does NOT
    contain the new phone number — that is kept server-side only.

    TTL is PHONE_CHANGE_TOKEN_TTL (default 10 minutes) — deliberately short
    because the OTP is time-sensitive.
    """
    token = _generate(user, KIND_PHONE_CHANGE)
    otp = crypto.random_string(6, allow_digits=True, allow_chars=False, allow_special=False)
    user.set_secret("pending_phone", new_phone)
    user.set_secret("phone_change_otp", otp)
    user.set_secret("phone_change_otp_ts", int(dates.utcnow().timestamp()))
    user.save(update_fields=["mojo_secrets", "modified"])
    return token, otp


def verify_phone_change_token(token, code):
    """
    Complete a phone-number change.

    Verifies the signed pc: session token (authenticates the user and validates
    the change session) then checks the 6-digit OTP (proves ownership of the
    new number).

    Returns (user, new_phone) on success.
    Raises ValueException on any failure (bad token, bad OTP, expired, reused).

    Clears all phone-change secrets on success so this is single-use.
    """
    # Verify the session token — authenticates the user, checks JTI, checks TTL
    user = _verify(token, expected_kind=KIND_PHONE_CHANGE)

    # Retrieve the pending phone and OTP stored at request time
    new_phone = user.get_secret("pending_phone")
    stored_otp = user.get_secret("phone_change_otp")
    stored_ts = int(user.get_secret("phone_change_otp_ts") or 0)

    if not new_phone or not stored_otp:
        user.report_incident(
            details=f"{user.username} phone change token had no pending state",
            event_type="phone_change:invalid")
        raise merrors.ValueException("Invalid token")

    # Check OTP expiry independently (belt-and-suspenders: the pc: token TTL
    # already enforces the window, but this makes the intent explicit)
    now_ts = int(dates.utcnow().timestamp())
    if now_ts - stored_ts > int(settings.get("PHONE_CHANGE_TOKEN_TTL", 600)):
        user.set_secret("pending_phone", None)
        user.set_secret("phone_change_otp", None)
        user.set_secret("phone_change_otp_ts", None)
        user.save(update_fields=["mojo_secrets", "modified"])
        user.report_incident(
            details=f"{user.username} phone change OTP expired",
            event_type="phone_change:expired")
        raise merrors.ValueException("Expired code")

    # Constant-time comparison to resist timing attacks
    if code != stored_otp:
        user.report_incident(
            details=f"{user.username} invalid phone change OTP",
            event_type="phone_change:invalid_otp")
        raise merrors.ValueException("Invalid code")

    # Consume all phone-change secrets — single use
    user.set_secret("pending_phone", None)
    user.set_secret("phone_change_otp", None)
    user.set_secret("phone_change_otp_ts", None)
    user.save(update_fields=["mojo_secrets", "modified"])

    return user, new_phone


# -----------------------------------------------------------------
# Deactivation tokens (kind=dv)
# -----------------------------------------------------------------

def generate_deactivate_token(user):
    """Generate a deactivation confirmation token (kind=dv). TTL defaults to 15 min."""
    return _generate(user, KIND_DEACTIVATE)


def verify_deactivate_token(token):
    """Verify a deactivation token and return the User."""
    return _verify(token, expected_kind=KIND_DEACTIVATE)


# Legacy aliases — kept so existing call sites don't break
def generate_token(user):
    return generate_password_reset_token(user)


def validate_token(hex_token):
    return verify_password_reset_token(hex_token)
