/**
 * Auth manager — handles registration, login, token refresh, and profile
 * retrieval against the AdiuvAI backend API.
 *
 * Singleton. Tokens are persisted via the two-tier storage in `token.ts`
 * (safeStorage + electron-store fallback).
 *
 * @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.2
 */

import { getStore } from '../store';
import { getToken, setToken, deleteToken } from '../ai/token';
import { toCamelCase, toSnakeCase } from '../../shared/casing';
import { AuthTokensSchema, UserProfileSchema } from '../../shared/api-types';
import type { AuthTokens, UserProfile } from '../../shared/api-types';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/** Token key names in the encrypted store. */
const TOKEN_KEYS = {
  access: 'auth_access',
  refresh: 'auth_refresh',
  /** Stored as string representation of Unix-epoch milliseconds. */
  expiresAt: 'auth_expires_at',
} as const;

/** Refresh the access token when it expires within this window (seconds). */
const REFRESH_WINDOW_SEC = 5 * 60; // 5 minutes

/** Maximum request timeout (ms). */
const REQUEST_TIMEOUT_MS = 10_000;

// ---------------------------------------------------------------------------
// Memory types
// ---------------------------------------------------------------------------

export interface RelationOut {
  id: string;
  subjectLabel: string;
  subjectType: string;
  predicate: string;
  objectLabel: string;
  objectType: string;
  confidence: number;
  lastConfirmedAt: number | null;
}

export interface RelationPatch {
  subjectLabel?: string;
  objectLabel?: string;
  predicate?: string;
  confidence?: number;
}

// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------

export class AuthError extends Error {
  constructor(
    message: string,
    public readonly statusCode?: number,
  ) {
    super(message);
    this.name = 'AuthError';
  }
}

// ---------------------------------------------------------------------------
// AuthManager
// ---------------------------------------------------------------------------

/** Tracks a pending OAuth login promise until the deep-link callback arrives. */
interface PendingOAuth {
  provider: string;
  resolve: (tokens: AuthTokens) => void;
  reject: (err: Error) => void;
  timer: ReturnType<typeof setTimeout>;
}

/** How long (ms) to wait for the user to complete the browser OAuth flow. */
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

export class AuthManager {
  private static instance: AuthManager | null = null;
  private refreshPromise: Promise<void> | null = null;

  /**
   * Pending OAuth login promises keyed by state param.
   * One entry per in-flight OAuth flow (practically always ≤ 1).
   */
  private _pendingOAuth = new Map<string, PendingOAuth>();

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private constructor() {}

  static getInstance(): AuthManager {
    if (!this.instance) {
      this.instance = new AuthManager();
    }
    return this.instance;
  }

  // -------------------------------------------------------------------------
  // Public API
  // -------------------------------------------------------------------------

  /** Register a new account and store the returned tokens. */
  async register(email: string, password: string, name?: string, surname?: string): Promise<AuthTokens> {
    const body: Record<string, unknown> = { email, password };
    if (name) body.name = name;
    if (surname) body.surname = surname;
    const data = await this.post<AuthTokens>('/api/v1/auth/register', body);
    const tokens = AuthTokensSchema.parse(data);
    await this.storeTokens(tokens);
    return tokens;
  }

  /** Log in with email + password and store the returned tokens. */
  async login(email: string, password: string): Promise<AuthTokens> {
    const data = await this.post<AuthTokens>('/api/v1/auth/login', { email, password });
    const tokens = AuthTokensSchema.parse(data);
    await this.storeTokens(tokens);
    return tokens;
  }

  /** Clear all stored auth tokens. */
  async logout(): Promise<void> {
    await Promise.all([
      deleteToken(TOKEN_KEYS.access),
      deleteToken(TOKEN_KEYS.refresh),
      deleteToken(TOKEN_KEYS.expiresAt),
    ]);
  }

  /**
   * Return a valid access token, refreshing transparently if near expiry.
   * Returns `null` if not authenticated.
   */
  async getAccessToken(): Promise<string | null> {
    const token = await getToken(TOKEN_KEYS.access);
    if (!token) return null;

    // Check expiry — refresh if within the window
    const expiresAtStr = await getToken(TOKEN_KEYS.expiresAt);
    if (expiresAtStr) {
      // Backend returns expires_at in milliseconds; convert to seconds.
      const expiresAtSec = Math.floor(Number(expiresAtStr) / 1000);
      const nowSec = Math.floor(Date.now() / 1000);
      if (expiresAtSec - nowSec < REFRESH_WINDOW_SEC) {
        const isExpired = nowSec >= expiresAtSec;
        // Coalesce concurrent refresh calls
        if (!this.refreshPromise) {
          this.refreshPromise = this.refreshTokens().finally(() => {
            this.refreshPromise = null;
          });
        }
        try {
          await this.refreshPromise;
          return (await getToken(TOKEN_KEYS.access)) ?? token;
        } catch {
          // Refresh failed — if the token is already expired, don't
          // return a stale token that will certainly be rejected.
          if (isExpired) {
            console.warn('[Auth] Token expired and refresh failed — logging out.');
            await this.logout();
            return null;
          }
          // Token not yet expired — return it; it may still work.
          return token;
        }
      }
    }

    return token;
  }

  /** Whether we have stored auth tokens. */
  async isAuthenticated(): Promise<boolean> {
    const token = await getToken(TOKEN_KEYS.access);
    return token !== null;
  }

  /** Fetch the user profile from the backend. */
  async getProfile(): Promise<UserProfile> {
    const data = await this.get<UserProfile>('/api/v1/auth/me');
    return UserProfileSchema.parse(data);
  }

  /** Update the user's profile (name, surname) on the backend. */
  async updateProfile(fields: { name?: string; surname?: string }): Promise<UserProfile> {
    const data = await this.put<UserProfile>('/api/v1/auth/me', fields);
    return UserProfileSchema.parse(data);
  }

  /** Update core memory key/value pairs and optionally mark onboarding complete. */
  async updateMemory(
    memory: Record<string, string>,
    markOnboarded = false,
  ): Promise<UserProfile> {
    const data = await this.put<UserProfile>('/api/v1/auth/me/memory', {
      memory,
      markOnboarded,
    });
    return UserProfileSchema.parse(data);
  }

  /** One-shot LLM normalization for free-text onboarding answers. */
  async normalizeOnboarding(
    inputs: Record<string, string>,
  ): Promise<Record<string, string>> {
    const res = await this.post<{ normalized: Record<string, string> }>('/api/v1/auth/onboarding/normalize', { inputs });
    return res.normalized;
  }

  /** Reset onboarding so the wizard runs again. */
  async resetOnboarding(): Promise<void> {
    await this.post('/api/v1/auth/me/onboarding/reset', {});
  }

  /** Change password (email/password users only). */
  async changePassword(currentPassword: string, newPassword: string): Promise<void> {
    await this.put('/api/v1/auth/me/password', {
      currentPassword,
      newPassword,
    });
  }

  /** List linked OAuth providers for the current user. */
  async listOAuthAccounts(): Promise<{ provider: string; providerEmail: string | null; createdAt: number }[]> {
    return this.get('/api/v1/auth/me/oauth-accounts');
  }

  /** Unlink an OAuth provider from the current user. */
  async unlinkOAuthAccount(provider: string): Promise<void> {
    await this.httpDelete(`/api/v1/auth/me/oauth-accounts/${encodeURIComponent(provider)}`);
  }

  /** Update the user's avatar URL. */
  async updateAvatar(avatarUrl: string): Promise<UserProfile> {
    const data = await this.put<UserProfile>('/api/v1/auth/me/avatar', { avatarUrl });
    return UserProfileSchema.parse(data);
  }

  /** Permanently delete the user's account. */
  async deleteAccount(): Promise<void> {
    await this.httpDelete('/api/v1/auth/me');
  }

  // ── Billing ────────────────────────────────────────────────────────

  /** Get current subscription info. */
  async getSubscription(): Promise<Record<string, unknown>> {
    return this.get('/api/v1/billing/subscription');
  }

  /** Create a Stripe checkout session for a tier upgrade. */
  async createCheckout(tier: string): Promise<{ checkoutUrl: string }> {
    return this.post('/api/v1/billing/checkout', { tier });
  }

  /** Cancel the active subscription. */
  async cancelSubscription(): Promise<void> {
    await this.httpDelete('/api/v1/billing/subscription');
  }

  /** List billing invoices from Stripe. */
  async listInvoices(): Promise<Record<string, unknown>[]> {
    return this.get('/api/v1/billing/invoices');
  }

  /**
   * Start a Google (or other provider) OAuth login flow.
   *
   * 1. Calls GET /api/v1/auth/oauth/{provider}/authorize to obtain the
   *    consent-screen URL and a PKCE state token from the backend.
   * 2. Opens the URL in the system browser via shell.openExternal().
   * 3. Returns a Promise that resolves with AuthTokens when the Electron app
   *    receives the deep-link callback (adiuvai://oauth/callback?...) and
   *    handleOAuthCallback() is called.
   *
   * Rejects after OAUTH_TIMEOUT_MS (5 min) if no callback arrives.
   */
  async loginWithOAuth(provider: string): Promise<AuthTokens> {
    // Fetch the authorization URL from the backend (public endpoint — no auth header needed).
    const url = `${this.baseUrl}/api/v1/auth/oauth/${encodeURIComponent(provider)}/authorize`;
    const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(`Failed to get OAuth authorize URL: ${res.status}${text ? ` ${text}` : ''}`);
    }
    const json = (await res.json()) as { url: string; state: string };

    // Open the consent screen in the system browser.
    const { shell } = await import('electron');
    await shell.openExternal(json.url);

    // Wait for the deep-link callback to arrive.
    return new Promise<AuthTokens>((resolve, reject) => {
      const timer = setTimeout(() => {
        this._pendingOAuth.delete(json.state);
        reject(new AuthError('OAuth login timed out — no callback received within 5 minutes'));
      }, OAUTH_TIMEOUT_MS);

      this._pendingOAuth.set(json.state, { provider, resolve, reject, timer });
    });
  }

  /**
   * Called by the main process when the OS delivers an adiuvai:// deep link.
   *
   * Parses code + state from the URL, exchanges the code with the backend,
   * stores the resulting JWT tokens, and resolves the pending loginWithOAuth()
   * promise.
   *
   * If no pending flow matches the state (e.g. duplicate or stale callback),
   * the call is silently ignored.
   */
  async handleOAuthCallback(deepLinkUrl: string): Promise<void> {
    let parsed: URL;
    try {
      parsed = new URL(deepLinkUrl);
    } catch {
      console.warn('[Auth] Received malformed deep-link URL:', deepLinkUrl);
      return;
    }

    const code = parsed.searchParams.get('code');
    const state = parsed.searchParams.get('state');
    const provider = parsed.searchParams.get('provider');

    if (!code || !state || !provider) {
      console.warn('[Auth] Deep-link missing required params:', deepLinkUrl);
      return;
    }

    const pending = this._pendingOAuth.get(state);
    if (!pending) {
      console.warn('[Auth] No pending OAuth flow for state:', state);
      return;
    }

    clearTimeout(pending.timer);
    this._pendingOAuth.delete(state);

    try {
      const data = await this.post<AuthTokens>(
        `/api/v1/auth/oauth/${encodeURIComponent(provider)}/callback`,
        { code, state },
      );
      const tokens = AuthTokensSchema.parse(data);
      await this.storeTokens(tokens);
      pending.resolve(tokens);
    } catch (err) {
      pending.reject(err instanceof Error ? err : new AuthError('OAuth callback exchange failed'));
    }
  }

  // ── Memory ────────────────────────────────────────────────────────────

  /** Return all core memory k/v pairs (plaintext). */
  async getCoreMemory(): Promise<Record<string, string>> {
    return this.get<Record<string, string>>('/api/v1/memory/core');
  }

  /** Add or overwrite a core memory key/value pair. */
  async addCoreKey(key: string, value: string): Promise<Record<string, string>> {
    return this.post<Record<string, string>>('/api/v1/memory/core', { key, value });
  }

  /** Delete a core memory key (GDPR). */
  async deleteCoreKey(key: string): Promise<void> {
    await this.httpDelete(`/api/v1/memory/core/${encodeURIComponent(key)}`);
  }

  /** Return relational memory rows. */
  async getRelationalMemory(): Promise<RelationOut[]> {
    return this.get<RelationOut[]>('/api/v1/memory/relational');
  }

  /** Edit a relation row's labels, predicate, or confidence. */
  async patchRelation(id: string, patch: RelationPatch): Promise<RelationOut> {
    const url = `${this.baseUrl}/api/v1/memory/relational/${encodeURIComponent(id)}`;
    const accessToken = await this.getAccessToken();
    if (!accessToken) throw new AuthError('Not authenticated');
    const res = await fetch(url, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
      body: JSON.stringify(patch),
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`, res.status);
    }
    const json: unknown = await res.json();
    return toCamelCase<RelationOut>(json);
  }

  /** Hard-delete a relation row (GDPR). */
  async deleteRelation(id: string): Promise<void> {
    await this.httpDelete(`/api/v1/memory/relational/${encodeURIComponent(id)}`);
  }

  /** Wipe all memory tiers for the current user (GDPR Art. 17). */
  async forgetAll(): Promise<void> {
    const url = `${this.baseUrl}/api/v1/memory/forget-all`;
    const accessToken = await this.getAccessToken();
    if (!accessToken) throw new AuthError('Not authenticated');
    const res = await fetch(url, {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}`, 'X-Confirm': 'true' },
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`, res.status);
    }
  }

  /** Explicitly refresh the token pair. */
  async refreshTokens(): Promise<void> {
    const refreshToken = await getToken(TOKEN_KEYS.refresh);
    if (!refreshToken) {
      throw new AuthError('No refresh token available');
    }

    // Use a direct fetch instead of this.post() to avoid sending the
    // (possibly expired) access token in the Authorization header.
    // The refresh endpoint only needs the refresh token in the body.
    const url = `${this.baseUrl}/api/v1/auth/refresh`;
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(toSnakeCase({ refreshToken })),
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(
        `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
        res.status,
      );
    }

    const json: unknown = await res.json();
    const tokens = AuthTokensSchema.parse(toCamelCase<AuthTokens>(json));
    await this.storeTokens(tokens);
  }

  // -------------------------------------------------------------------------
  // Internals
  // -------------------------------------------------------------------------

  private get baseUrl(): string {
    return getStore().get('backendUrl');
  }

  private async storeTokens(tokens: AuthTokens): Promise<void> {
    await Promise.all([
      setToken(TOKEN_KEYS.access, tokens.accessToken),
      setToken(TOKEN_KEYS.refresh, tokens.refreshToken),
      setToken(TOKEN_KEYS.expiresAt, String(tokens.expiresAt)),
    ]);
  }

  /**
   * Generic POST request to the backend.
   * Outgoing body is snake_cased, incoming JSON is camelCased + Zod-parsed by caller.
   */
  private async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const accessToken = await getToken(TOKEN_KEYS.access);

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    };
    if (accessToken) {
      headers['Authorization'] = `Bearer ${accessToken}`;
    }

    const res = await fetch(url, {
      method: 'POST',
      headers,
      body: JSON.stringify(toSnakeCase(body)),
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(
        `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
        res.status,
      );
    }

    const json: unknown = await res.json();
    return toCamelCase<T>(json);
  }

  /**
   * Generic PUT request to the backend (authenticated).
   */
  private async put<T>(path: string, body: Record<string, unknown>): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const accessToken = await this.getAccessToken();

    if (!accessToken) {
      throw new AuthError('Not authenticated');
    }

    const res = await fetch(url, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${accessToken}`,
      },
      body: JSON.stringify(toSnakeCase(body)),
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(
        `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
        res.status,
      );
    }

    const json: unknown = await res.json();
    return toCamelCase<T>(json);
  }

  /**
   * Generic GET request to the backend (authenticated).
   */
  private async get<T>(path: string): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const accessToken = await this.getAccessToken();

    if (!accessToken) {
      throw new AuthError('Not authenticated');
    }

    const res = await fetch(url, {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(
        `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
        res.status,
      );
    }

    const json: unknown = await res.json();
    return toCamelCase<T>(json);
  }

  private async httpDelete<T = void>(path: string): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const accessToken = await this.getAccessToken();

    if (!accessToken) {
      throw new AuthError('Not authenticated');
    }

    const res = await fetch(url, {
      method: 'DELETE',
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new AuthError(
        `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
        res.status,
      );
    }

    const text = await res.text();
    if (!text) return undefined as T;
    return toCamelCase<T>(JSON.parse(text));
  }
}

// ---------------------------------------------------------------------------
// Convenience singleton accessor
// ---------------------------------------------------------------------------

export function getAuthManager(): AuthManager {
  return AuthManager.getInstance();
}
