import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {verifyCredentials} from '@/features/auth/mock/users.mock';
import {
  LOGIN_ERROR_PARAM,
  type LoginErrorCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import {
  REMEMBERED_MAX_AGE_SECONDS,
  SESSION_COOKIE,
  SESSION_MAX_AGE_SECONDS,
  createSessionToken,
  sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
import type {AuthSession, LoginError} from '@/features/auth/types/auth';
import type {ApiFailure, ApiSuccess} from '@/shared/types/api';

export const dynamic = 'force-dynamic';

/**
 * POST /api/auth/login
 *
 * The seam a real backend replaces. Everything above it — the repository, the
 * service, the form — speaks in {credentials} → {session | error} and does not
 * care whether the check happened against a fixture or an identity provider.
 *
 * Responsibilities that deliberately live HERE and not in the client:
 *   • deciding whether the credentials are valid
 *   • deciding how long the session lasts (rememberMe is a request, not an
 *     instruction — the server sets the cookie lifetime)
 *   • issuing the httpOnly cookie the client can never read or forge
 *
 * ── Two content types, one endpoint ──────────────────────────────────────
 * It answers both `application/json` (the hydrated form, via authRepository)
 * and `application/x-www-form-urlencoded` (the browser posting the form
 * natively, before React has hydrated or when its bundle never arrived).
 *
 * That second path is not a nicety, it is the fix for a real leak. The sign-in
 * form has named inputs; a <form> with no method and no action submits GET to
 * its own URL, so a submit landing in the pre-hydration window rewrote the
 * address bar to `/login?email=…&password=…` — putting the password in the
 * URL bar, in browser history, in the referrer and in every access log between
 * here and the user. Declaring method="post" action="/api/auth/login" means
 * the worst case is now a normal POST with the credentials in the body.
 *
 * The two paths differ ONLY in how the answer is shaped: JSON gets an
 * envelope, a native post gets a 303 redirect, because a browser that just
 * submitted a form needs somewhere to land, not a document full of braces.
 */

interface LoginRequestBody {
  email?: unknown;
  password?: unknown;
  rememberMe?: unknown;
}

/** What the request asked for, normalised across both content types. */
interface ParsedLogin {
  email: string;
  password: string;
  rememberMe: boolean;
  /** Only meaningful on the native path — where to land after success. */
  next: string | null;
  /** True when the browser posted the form itself, so answer in redirects. */
  isFormPost: boolean;
}

function asString(value: unknown): string {
  return typeof value === 'string' ? value : '';
}

async function parseRequest(req: NextRequest): Promise<ParsedLogin | null> {
  const contentType = req.headers.get('content-type') ?? '';

  if (
    contentType.includes('application/x-www-form-urlencoded') ||
    contentType.includes('multipart/form-data')
  ) {
    const form = await req.formData();
    return {
      email: asString(form.get('email')).trim(),
      password: asString(form.get('password')),
      // An unchecked checkbox is absent from the payload entirely; any present
      // value means checked. Never parsed as a boolean-ish string.
      rememberMe: form.get('rememberMe') !== null,
      next: asString(form.get('next')) || null,
      isFormPost: true,
    };
  }

  let body: LoginRequestBody;
  try {
    body = (await req.json()) as LoginRequestBody;
  } catch {
    return null;
  }
  return {
    email: asString(body.email).trim(),
    password: asString(body.password),
    rememberMe: body.rememberMe === true,
    next: null,
    isFormPost: false,
  };
}

/**
 * A failure, in whichever dialect the caller speaks.
 *
 * The redirect carries a code, never the submitted values — bouncing the email
 * back through the URL to repopulate the field would reintroduce exactly the
 * leak this endpoint exists to close.
 */
function failure(
  req: NextRequest,
  parsed: Pick<ParsedLogin, 'isFormPost' | 'next'> | null,
  code: LoginErrorCode,
  error: LoginError,
  status: number,
): NextResponse {
  if (parsed?.isFormPost) {
    const target = new URL('/login', req.url);
    target.searchParams.set(LOGIN_ERROR_PARAM, code);
    // Preserve the deep link so a failed attempt does not cost the user the
    // page they were originally trying to reach. Validated, not echoed raw.
    const next = resolveRedirectTarget(parsed.next);
    if (next !== '/dashboard') target.searchParams.set('next', next);
    // 303: the browser must follow with GET, not repeat the POST.
    return NextResponse.redirect(target, 303);
  }

  // Shaped as the app's standard envelope so the client's error path is the
  // same one every other endpoint uses; `field` rides alongside for the form.
  const body: ApiFailure & {field: LoginError['field']} = {
    error: {
      code: status === 401 ? 'unauthorized' : 'bad_request',
      message: error.message,
    },
    field: error.field,
  };
  return NextResponse.json(body, {
    status,
    headers: {'cache-control': 'no-store'},
  });
}

export async function POST(req: NextRequest): Promise<NextResponse> {
  const parsed = await parseRequest(req);

  if (!parsed) {
    return failure(
      req,
      null,
      'malformed',
      {field: 'form', message: 'Malformed request body.'},
      400,
    );
  }

  // Server-side validation, repeated rather than trusted from the client. The
  // form validates too, for latency; this validates because the form is not a
  // security boundary and a POST can arrive without it.
  if (!parsed.email) {
    return failure(req, parsed, 'email_required', {
      field: 'email',
      message: 'Enter your email address.',
    }, 400);
  }
  if (!/^\S+@\S+\.\S+$/.test(parsed.email)) {
    return failure(req, parsed, 'email_invalid', {
      field: 'email',
      message: 'Enter a valid email address.',
    }, 400);
  }
  if (!parsed.password) {
    return failure(req, parsed, 'password_required', {
      field: 'password',
      message: 'Enter your password.',
    }, 400);
  }

  const check = verifyCredentials(parsed.email, parsed.password);

  if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
    return failure(req, parsed, 'invalid_credentials', {
      field: 'form',
      message: 'Invalid email or password.',
    }, 401);
  }

  const maxAge = parsed.rememberMe
    ? REMEMBERED_MAX_AGE_SECONDS
    : SESSION_MAX_AGE_SECONDS;

  const token = createSessionToken(
    {
      sub: check.user.id,
      email: check.user.email,
      name: check.user.name,
      role: check.user.role,
      organisation: check.user.organisation,
    },
    maxAge,
  );

  const session: AuthSession = {
    user: check.user,
    expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
  };

  const response = parsed.isFormPost
    ? // 303 so the browser re-issues as GET. Without it, a refresh on the
      // landing page would re-POST the credentials.
      NextResponse.redirect(
        new URL(resolveRedirectTarget(parsed.next), req.url),
        303,
      )
    : NextResponse.json(
        {
          data: session,
          meta: {generatedAt: new Date().toISOString()},
        } satisfies ApiSuccess<AuthSession>,
        {headers: {'cache-control': 'no-store'}},
      );

  /**
   * Set on the response rather than through the `cookies()` store, because
   * this response may be a redirect: the header has to ride along with the 303
   * itself or the browser follows it to the dashboard still signed out, gets
   * bounced back to /login by the proxy, and the sign-in appears to have
   * silently failed.
   *
   * A browser-session cookie still needs a server-side expiry, or a tab left
   * open for a week would hold a valid token indefinitely — hence the token's
   * own `exp` regardless of whether maxAge is sent.
   */
  response.cookies.set(
    SESSION_COOKIE,
    token,
    sessionCookieOptions(parsed.rememberMe ? maxAge : undefined),
  );

  return response;
}
