import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { email, form, FormField, minLength, required } from '@angular/forms/signals';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { AuthenticationService, isGraphqlErrorWithCode } from '@aurora';
import { provideTranslocoScope, TranslocoDirective, TranslocoPipe } from '@jsverse/transloco';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideGithub, lucideLoader } from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { environment } from 'environments/environment';
import { catchError, finalize, map, Observable, of, switchMap } from 'rxjs';
import { OAuthSessionService } from './oauth-session.service';

/**
 * Returns true when `path` is a safe same-origin redirect path.
 *
 * Rejects:
 * - Absolute URLs (http://, https://, protocol-relative //)
 * - Paths containing backslash (Windows-style traversal some browsers honour)
 * - Anything that does not start with a single forward slash
 */
const isSafeRedirectPath = (path: string | null): path is string => {
  if (!path) return false;
  if (/^https?:\/\//i.test(path)) return false;
  if (path.startsWith('//')) return false;
  if (path.includes('\\')) return false;
  return path.startsWith('/');
};

/**
 * Returns true when `url` is a safe `continue` URL for the OAuth authorize flow.
 *
 * Accepts:
 *  (a) A path beginning `/api/o-auth/authorize` (no protocol, no double-slash).
 *  (b) An absolute URL whose origin exactly matches the configured backend API origin
 *      (`environment.api.rest`) AND whose path starts with `/api/o-auth/authorize`.
 *
 * Rejects all other absolute URLs, protocol-relative URLs, and empty/null values.
 * This prevents open-redirect attacks via the `?continue=` query parameter.
 *
 * @param url - The raw `continue` query param value.
 * @param apiRestBaseUrl - The configured backend REST base URL (e.g. `http://localhost:9080/api`).
 *   Defaults to `environment.api.rest`. Provided as a parameter to make testing straightforward.
 */
export const isSafeContinueUrl = (
  url: string | null,
  apiRestBaseUrl: string = environment.api.rest,
): url is string => {
  if (!url) return false;

  // (a) Path: must start with /api/o-auth/authorize but NOT be protocol-relative (//) or absolute.
  if (isSafeRedirectPath(url) && url.startsWith('/api/o-auth/authorize')) {
    return true;
  }

  // (b) Absolute URL: origin must match the configured API origin AND path must start with /api/o-auth/authorize.
  try {
    const parsed = new URL(url);
    const apiOrigin = new URL(apiRestBaseUrl).origin;
    return parsed.origin === apiOrigin && parsed.pathname.startsWith('/api/o-auth/authorize');
  } catch {
    return false;
  }
};

/**
 * Resolves the full navigation target for a validated `continue` path or absolute URL.
 *
 * When `continueUrl` is a bare path (e.g. `/api/o-auth/authorize?...`) the browser
 * would resolve it against the FRONTEND origin, not the backend origin where the
 * `/authorize` endpoint actually lives. This function prepends the backend origin when:
 *   - `apiRestBaseUrl` is an absolute URL (has a distinct origin).
 *   - The `continueUrl` is a bare path (not yet an absolute URL).
 *
 * When `continueUrl` is already an absolute URL (it passed variant (b) of isSafeContinueUrl),
 * it is returned as-is because the origin is already correct.
 *
 * When `apiRestBaseUrl` is relative/same-origin (not parseable as an absolute URL), the
 * frontend and backend share the same origin so the path resolves correctly without a prefix.
 *
 * @param continueUrl - A URL that has already passed `isSafeContinueUrl` validation.
 * @param apiRestBaseUrl - The configured backend REST base URL. Defaults to `environment.api.rest`.
 * @returns The full URL to navigate the browser to — always targeting the backend origin.
 */
export const resolveContinueNavigationTarget = (
  continueUrl: string,
  apiRestBaseUrl: string = environment.api.rest,
): string => {
  // If it is already an absolute URL, it targets the correct origin (validated by isSafeContinueUrl).
  if (/^https?:\/\//i.test(continueUrl)) {
    return continueUrl;
  }

  // It is a bare path. Prepend the backend origin if we can derive one.
  try {
    const backendOrigin = new URL(apiRestBaseUrl).origin;
    return `${backendOrigin}${continueUrl}`;
  } catch {
    // apiRestBaseUrl is relative/same-origin — path resolves correctly without prefix.
    return continueUrl;
  }
};

@Component({
  selector: 'app-login-form',
  imports: [
    FormField,
    RouterLink,
    HlmFieldImports,
    HlmInputImports,
    HlmButtonImports,
    NgIcon,
    TranslocoDirective,
    TranslocoPipe,
  ],
  providers: [provideIcons({ lucideGithub, lucideLoader }), provideTranslocoScope('auth')],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form
      *transloco="let t; scope: 'auth'"
      (submit)="login($event)"
    >
      <hlm-field-group>
        <div class="flex flex-col items-center gap-1 text-center">
          <h1 class="text-2xl font-bold">{{ t('auth.signIn.Headline') }}</h1>
          <p class="text-muted-foreground text-sm text-balance">
            {{ t('auth.signIn.Subheadline') }}
          </p>
        </div>
        <hlm-field>
          <label
            hlmFieldLabel
            for="email"
          >
            {{ t('auth.signIn.EmailLabel') }}
          </label>
          <input
            id="email"
            type="email"
            [formField]="loginForm.email"
            [placeholder]="t('auth.signIn.EmailPlaceholder')"
            hlmInput
            autocomplete="email"
          />
          @if (loginForm.email().touched() && loginForm.email().invalid()) {
            <hlm-field-error>
              {{ loginForm.email().errors()[0].message | transloco }}
            </hlm-field-error>
          }
        </hlm-field>
        <hlm-field>
          <div class="flex items-center">
            <label
              hlmFieldLabel
              for="password"
            >
              {{ t('auth.signIn.PasswordLabel') }}
            </label>
            <a
              class="ml-auto text-sm underline-offset-4 hover:underline"
              hlmFieldDescription
              routerLink="."
            >
              {{ t('auth.signIn.ForgotPassword') }}
            </a>
          </div>
          <input
            id="password"
            type="password"
            [formField]="loginForm.password"
            hlmInput
            autocomplete="current-password"
          />
          @if (loginForm.password().touched() && loginForm.password().invalid()) {
            <hlm-field-error>
              {{ loginForm.password().errors()[0].message | transloco }}
            </hlm-field-error>
          } @else if (signInError()) {
            <hlm-field-error forceShow>
              {{ signInError() | transloco }}
            </hlm-field-error>
          }
        </hlm-field>
        <hlm-field>
          <button
            type="submit"
            [attr.aria-busy]="loading() ? 'true' : null"
            [disabled]="loginForm().invalid() || loading()"
            hlmBtn
          >
            @if (loading()) {
              <ng-icon
                name="lucideLoader"
                aria-hidden="true"
                class="animate-spin text-base"
              />
              {{ t('auth.signIn.SubmitButtonLoading') }}
            } @else {
              {{ t('auth.signIn.SubmitButton') }}
            }
          </button>
        </hlm-field>
        <hlm-field-separator>{{ t('auth.signIn.Divider') }}</hlm-field-separator>
        <hlm-field>
          <button
            type="button"
            hlmBtn
            variant="outline"
          >
            <ng-icon
              name="lucideGithub"
              class="text-xl"
            />
            {{ t('auth.signIn.GithubButton') }}
          </button>
          <p
            class="text-center"
            hlmFieldDescription
          >
            {{ t('auth.signIn.NoAccountPrompt') }}
            <a routerLink="/auth/sign-up">{{ t('auth.signIn.SignUpLink') }}</a>
          </p>
        </hlm-field>
      </hlm-field-group>
    </form>
  `,
})
export class LoginForm implements OnInit {
  private readonly authenticationService = inject(AuthenticationService);
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);
  private readonly oauthSessionService = inject(OAuthSessionService);

  readonly loginModel = signal({
    email: '',
    password: '',
  });

  readonly loading = signal(false);
  readonly signInError = signal<string | null>(null);

  // Validation messages reference translation keys; the template pipes the resulting
  // string through `| transloco` to render localized text. Storing keys (not literals)
  // keeps the form reactive to language switches without re-instantiating the form.
  readonly loginForm = form(this.loginModel, (f) => {
    required(f.email, { message: 'auth.signIn.errors.EmailRequired' });
    email(f.email, { message: 'auth.signIn.errors.EmailInvalid' });
    required(f.password, { message: 'auth.signIn.errors.PasswordRequired' });
    minLength(f.password, 8, { message: 'auth.signIn.errors.PasswordTooShort' });
  });

  ngOnInit(): void {
    this.attemptAuthenticatedContinue().subscribe();
  }

  /**
   * When the user is already authenticated on the hub AND arrived with a valid
   * `?continue=` (the OAuth /authorize URL the satellite bounced through), skip the
   * login form entirely: establish the hub_session cookie with the EXISTING access
   * token, then hand off to the continue URL. This is the SSO fast-path — without it,
   * an already-logged-in hub user gets stuck because signIn() throws 'already logged in'.
   *
   * Returns an Observable<boolean>: true when the SSO continue was attempted (browser
   * is navigating away), false when it fell through and the form should render normally.
   */
  attemptAuthenticatedContinue(): Observable<boolean> {
    const continueUrl = this.route.snapshot.queryParamMap.get('continue');

    if (!isSafeContinueUrl(continueUrl)) {
      return of(false);
    }

    const navigationTarget = resolveContinueNavigationTarget(continueUrl);

    return this.authenticationService.check().pipe(
      switchMap((authenticated) => {
        if (!authenticated) {
          return of(false);
        }

        return this.oauthSessionService.establishSession(this.getAccessToken()).pipe(
          map(() => {
            window.location.href = navigationTarget;
            return true;
          }),
          catchError((err: unknown) => {
            // Parity with handlePostLoginNavigation's logging: a backend /o-auth/session
            // outage on the SSO fast-path must not be invisible. Form stays usable.
            console.error('[LoginForm] SSO continue session establishment failed', err);
            return of(false);
          }),
        );
      }),
    );
  }

  /**
   * Handles navigation after a successful login, branching on the `?continue=` param:
   *
   * - **With `continue`**: POSTs the freshly-issued access token to `/api/o-auth/session`
   *   with `withCredentials: true` (so the browser receives the `hub_session` cookie),
   *   then navigates the browser to the `continue` URL via `window.location.href`
   *   (full-page redirect, not Angular Router — `continue` is a backend `/authorize` URL
   *   that must complete the OAuth round-trip in the same browser tab).
   *
   * - **Without `continue`**: falls through to the existing `?redirectURL=` / root
   *   navigation via Angular Router, keeping the current behavior intact.
   *
   * This method is extracted from `login()` to make the branching logic unit-testable
   * in isolation from the template, transloco, and form machinery.
   *
   * @param accessToken - The access token received from the Password grant.
   * @returns An Observable that completes after navigation (or errors on session failure).
   */
  handlePostLoginNavigation(accessToken: string): Observable<unknown> {
    const continueUrl = this.route.snapshot.queryParamMap.get('continue');

    if (isSafeContinueUrl(continueUrl)) {
      // Continue-flow: establish hub session cookie, then hand off to the backend
      // authorize endpoint via full-page navigation (so the browser sends the cookie).
      // Use resolveContinueNavigationTarget to ensure bare paths are prefixed with the
      // backend origin — a bare path would resolve against the FRONTEND origin (:4200)
      // and the browser would never reach the backend /authorize endpoint.
      const navigationTarget = resolveContinueNavigationTarget(continueUrl);
      return this.oauthSessionService.establishSession(accessToken).pipe(
        map(() => {
          window.location.href = navigationTarget;
          return null;
        }),
      );
    }

    // `continue` param was present but failed validation — fall through to default route.
    // Do NOT navigate to an untrusted URL.

    // Default flow: honor ?redirectURL= or fall back to app root.
    // Wrap in Observable for a uniform return type.
    const redirectURL = this.route.snapshot.queryParamMap.get('redirectURL');
    return of(null).pipe(
      switchMap(() =>
        this.router.navigateByUrl(isSafeRedirectPath(redirectURL) ? redirectURL : '/'),
      ),
    );
  }

  login(event: Event) {
    event.preventDefault();
    if (!this.loginForm().valid() || this.loading()) return;

    this.signInError.set(null);
    this.loading.set(true);
    this.authenticationService
      .signIn(this.loginModel())
      // Use `finalize` (NOT `tap`) to clear loading — `tap` only fires on next emissions,
      // leaving the spinner stuck if the request errors before emitting.
      .pipe(finalize(() => this.loading.set(false)))
      .subscribe({
        next: () => {
          // Delegate to handlePostLoginNavigation with the current access token.
          // AuthenticationService.signIn returns Observable<boolean>, not the token.
          // To get the access token for the session-establishment POST, getAccessToken()
          // reads it from localStorage via the canonical CREDENTIALS_KEY + base64/JSON
          // encoding used by TokenStore.setCredentials(). The token is guaranteed present
          // synchronously after signIn completes because TokenStore writes it synchronously
          // before the Observable emits. Injecting TokenStore directly here would require
          // exporting it from @aurora; using the localStorage read keeps the dependency
          // surface minimal at the cost of duplicating knowledge of the storage key and
          // encoding — see getAccessToken() below for the exact reference.
          this.handlePostLoginNavigation(this.getAccessToken()).subscribe({
            error: (err: unknown) => {
              // Session establishment failed — log but don't leave user stuck.
              console.error('[LoginForm] Session establishment failed', err);
            },
          });
        },
        error: (err: unknown) => {
          // invalid_grant is suppressed from the global toast (skipGlobalErrors in signIn adapter);
          // render it inline so the user knows their credentials are wrong without leaving the form.
          if (isGraphqlErrorWithCode(err, 'invalid_grant')) {
            this.signInError.set('auth.signIn.errors.InvalidCredentials');
          }
          // Unknown errors: global toast still fires because they were NOT in skipGlobalErrors.
        },
      });
  }

  /**
   * Retrieves the current access token from localStorage.
   *
   * TokenStore persists credentials as `btoa(JSON.stringify(OAuthTokens))` under the
   * key `'credentials'` (CREDENTIALS_KEY in `@aurora/modules/authentication/shared/authentication.ts`).
   * The token is guaranteed to be present synchronously after a successful signIn call
   * because TokenStore.setCredentials writes to localStorage before the Observable completes.
   *
   * We read localStorage directly rather than injecting TokenStore because TokenStore is
   * not exported from @aurora's public index. Injecting it would require either exporting it
   * or importing from a deep path — both couple LoginForm to an internal of @aurora.
   * The storage key and encoding are a stable internal contract; if they change,
   * `authentication.ts` (CREDENTIALS_KEY + `readCredentials()`) and this method must be
   * updated together.
   */
  private getAccessToken(): string {
    try {
      const raw = localStorage.getItem('credentials');
      if (!raw) return '';
      const parsed = JSON.parse(atob(raw)) as { accessToken?: string };
      return parsed.accessToken ?? '';
    } catch {
      return '';
    }
  }
}
