'use strict';

import {Controller} from '@hotwired/stimulus';

// Admin-scoped localStorage namespace. The cms-bundle equivalent uses
// `ferienpass.lastLoginMethod` — keeping these separate so admin and member
// logins don't overwrite each other's "last method" hint when both happen
// from the same browser.
const RETURNING_KEY = 'ferienpass.admin.returningUser';
const METHOD_KEY = 'ferienpass.admin.lastLoginMethod';

/*
 * Single Stimulus controller that runs the admin login UI.
 *
 * The DOM contains three mutually exclusive "views":
 *   - magic       → e-mail input + "send magic link" primary action
 *   - password    → e-mail + password inputs + submit
 *   - passkeyOnly → only the passkey button, with a secondary "use e-mail" reveal
 *
 * The active view is chosen at connect() from `ferienpass.admin.lastLoginMethod`
 * so a returning admin lands on whatever worked last. Switching is via the
 * `revealPassword` / `revealEmail` actions wired up on buttons.
 *
 * The password form is a regular Symfony form_login submit (not JSON) — the
 * firewall handles redirect/error. We only intercept submit to remember the
 * method and to do a quick client-side email validation.
 */
export default class extends Controller {
    static values = {
        magicUrl: String,
    };

    static targets = [
        "error", "username", "password", "remember_me",
        "emailBlock", "magicActions", "magicSent", "passwordBlock",
        "passkeyCta", "magicPasskeyAlt",
        "passwordEye", "passwordEyeOff", "magicButton",
        "passkeyError",
    ];

    view = 'magic';
    pwVisible = false;
    magicSent = false;

    connect() {
        let last = null;
        try {
            last = localStorage.getItem(METHOD_KEY);
        } catch (e) {
        }

        if ('password' === last) {
            this.view = 'password';
        } else if ('passkey' === last) {
            this.view = 'passkey';
        } else {
            this.view = 'magic';
        }

        this.applyState();
        this.consumeFlashError();
    }

    // A failed login redirects back here with the error stashed in the
    // FOSHttpCacheBundle `flashes` cookie. We render it inline above the form
    // rather than letting the generic flash_message toast controller handle it:
    // that controller POSTs to /admin/flash-message, which anonymous visitors
    // can't reach, so the toast never appeared. We clear the cookie here (the
    // toast controller is also mounted on this page via <twig:Admin:Notifications/>)
    // so it doesn't fire that doomed request afterwards.
    consumeFlashError() {
        const raw = this.getCookie('flashes');
        if (!raw) {
            return;
        }

        this.clearCookie('flashes');

        let data;
        try {
            data = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
        } catch (e) {
            return;
        }

        const errors = data && Array.isArray(data.error) ? data.error : [];
        const message = errors
            .map((m) => (typeof m === 'object' && m !== null ? (m.message ?? m.title ?? '') : m))
            .filter(Boolean)
            .join(' ');

        if (!message || !this.hasErrorTarget) {
            return;
        }

        const slot = this.errorTarget.querySelector('[data-message]');
        if (slot) {
            slot.textContent = message;
        } else {
            this.errorTarget.textContent = message;
        }

        this.errorTarget.style.display = '';
    }

    getCookie(name) {
        const prefix = name + '=';
        const parts = document.cookie.split(';');
        for (let i = 0; i < parts.length; i++) {
            const c = parts[i].trim();
            if (c.indexOf(prefix) === 0) {
                return c.substring(prefix.length);
            }
        }

        return null;
    }

    clearCookie(name) {
        document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
    }

    revealPassword() {
        this.view = 'password';
        this.applyState();
        if (this.hasPasswordTarget) {
            this.passwordTarget.focus();
        }
    }

    revealEmail() {
        this.view = 'magic';
        this.applyState();
        if (this.hasUsernameTarget) {
            this.usernameTarget.focus();
        }
    }

    togglePassword() {
        this.pwVisible = !this.pwVisible;
        this.applyState();
    }

    enterEmail(event) {
        // In the passwordless views, Enter sends the magic link instead of
        // submitting the (hidden) password form.
        if ('password' !== this.view) {
            event.preventDefault();
            this.sendMagicLink();
        }
    }

    syncMagic() {
        const hasEmail = !!this.usernameTarget.value.trim();
        this.magicButtonTargets.forEach((b) => {
            b.disabled = !hasEmail;
        });
    }

    isValidEmail() {
        const value = this.usernameTarget.value.trim();
        return value.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
    }

    validateEmail() {
        this.setEmailError(this.usernameTarget.value.trim().length > 0 && !this.isValidEmail());
    }

    emailInput() {
        this.syncMagic();
        if (this.isValidEmail()) {
            this.setEmailError(false);
        }
    }

    setEmailError(hasError) {
        ['border-red-500', 'ring-4', 'ring-red-500/30'].forEach((c) => this.usernameTarget.classList.toggle(c, hasError));
        this.usernameTarget.setAttribute('aria-invalid', hasError ? 'true' : 'false');
    }

    async sendMagicLink() {
        if (!this.isValidEmail()) {
            this.validateEmail();
            this.usernameTarget.focus();
            return;
        }

        const email = this.usernameTarget.value.trim();

        if (this.magicUrlValue) {
            try {
                await fetch(this.magicUrlValue, {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
                    body: JSON.stringify({email}),
                });
            } catch (e) {
            }
        }

        try {
            localStorage.setItem(RETURNING_KEY, '1');
            localStorage.setItem(METHOD_KEY, 'magicLink');
        } catch (e) {
        }

        this.magicSent = true;
        this.applyState();
    }

    tryOtherEmail() {
        this.magicSent = false;
        this.applyState();
        this.usernameTarget.focus();
        this.usernameTarget.select();
    }

    // Marker the password form fires before its real submit happens — we don't
    // block the submit (Symfony's form_login handles it), we just persist the
    // hint for next visit. If the credentials turn out to be wrong the user
    // lands back here with `lastLoginMethod=password` set, which is fine — the
    // method itself was the intent, success isn't a prerequisite for the hint.
    rememberPassword() {
        try {
            localStorage.setItem(RETURNING_KEY, '1');
            localStorage.setItem(METHOD_KEY, 'password');
        } catch (e) {
        }
    }

    rememberPasskey() {
        try {
            localStorage.setItem(RETURNING_KEY, '1');
            localStorage.setItem(METHOD_KEY, 'passkey');
        } catch (e) {
        }
        // Clear any previously-shown passkey error on a successful retry.
        this.passkeyErrorTargets.forEach((el) => {
            el.style.display = 'none';
        });
    }

    // Fired by the web-auth--webauthn-stimulus controller when either the
    // /admin/assertion/options or /admin/assertion/result POST returns a
    // non-OK status, or when the browser ceremony itself throws. Without
    // this handler the user clicks "Mit Passkey anmelden", nothing visible
    // happens, and they're left wondering whether it worked.
    passkeyFailed(event) {
        const detail = event?.detail ?? {};
        const status = detail.assertionResponse?.status;
        const isCancel = detail.exception
            && (detail.exception.name === 'NotAllowedError' || detail.exception.name === 'AbortError');

        // User-cancelled prompts (closed the dialog, hit Esc) are not errors
        // worth surfacing — they already know what they did. Anything else
        // (401 from the server, no matching credential, etc.) gets the alert.
        if (isCancel) {
            return;
        }

        this.passkeyErrorTargets.forEach((el) => {
            // Optional per-status message via data attribute (e.g.
            // data-401-message="…"); falls back to the element's existing
            // text content otherwise.
            const customMessage = status ? el.dataset[`status${status}Message`] : null;
            if (customMessage && el.querySelector('[data-message]')) {
                el.querySelector('[data-message]').textContent = customMessage;
            }
            el.style.display = '';
            // Scroll into view so the user actually sees the error if the
            // passkey button was below the fold on small viewports.
            el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        });
    }

    applyState() {
        const sent = this.magicSent;

        // emailBlock is needed by all three views — magic ("e-mail + send
        // link"), password ("e-mail + password"), and passkey (e-mail acts as
        // the fallback path next to the fat passkey CTA). It only hides when
        // the magic-link "sent" confirmation has replaced the form.
        this.toggle(this.hasEmailBlockTarget, this.hasEmailBlockTarget ? this.emailBlockTarget : null, !sent);
        // magicActions (magic-link button + "use password" reveal + small
        // passkey alternative) shows in the magic view and as the fallback row
        // under the fat CTA in the passkey view. Hidden when the password
        // block is the primary surface.
        this.toggle(this.hasMagicActionsTarget, this.hasMagicActionsTarget ? this.magicActionsTarget : null, !sent && ('magic' === this.view || 'passkey' === this.view));
        this.toggle(this.hasPasswordBlockTarget, this.hasPasswordBlockTarget ? this.passwordBlockTarget : null, !sent && 'password' === this.view);
        this.toggle(this.hasPasskeyCtaTarget, this.hasPasskeyCtaTarget ? this.passkeyCtaTarget : null, !sent && 'passkey' === this.view);
        // Hide the small "Mit Passkey anmelden" alternative inside magicActions
        // whenever the fat passkey CTA is already shown — otherwise the
        // passkey button appears twice (once above the "Oder" divider, once
        // below it). In magic / password views the alternative is the only
        // passkey entry point, so keep it visible there.
        this.toggle(this.hasMagicPasskeyAltTarget, this.hasMagicPasskeyAltTarget ? this.magicPasskeyAltTarget : null, !sent && 'passkey' !== this.view);
        this.toggle(this.hasMagicSentTarget, this.hasMagicSentTarget ? this.magicSentTarget : null, sent);

        if (this.hasPasswordTarget) {
            this.passwordTarget.type = this.pwVisible ? 'text' : 'password';
        }
        this.toggle(this.hasPasswordEyeTarget, this.hasPasswordEyeTarget ? this.passwordEyeTarget : null, !this.pwVisible);
        this.toggle(this.hasPasswordEyeOffTarget, this.hasPasswordEyeOffTarget ? this.passwordEyeOffTarget : null, this.pwVisible);

        this.syncMagic();
    }

    toggle(has, el, visible) {
        if (has && el) {
            el.style.display = visible ? '' : 'none';
        }
    }
}
