import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';

const SETTINGS_API = `${API_BASE}/settings`;

export interface CustomProvider {
  name: string;
  base_url: string;
  api_key: string;
  models: Array<{ value: string; label: string; context_window?: number }>;
}

export interface SubscriptionUsage {
  requests_in_window: number;
  plan_limit: number;
  window_hours: number;
  /** unix ms */
  window_ends_at: number;
}

export interface AppSettings {
  default_system_prompt: string | null;
  default_folder: string | null;
  default_model: string;
  default_mode: string;
  default_max_turns: number | null;
  default_thinking_level: 'off' | 'low' | 'medium' | 'high' | 'auto';
  zoom_sensitivity: number;
  /** What a plain mouse wheel does on the canvas; trackpad two-finger always pans. */
  mouse_wheel_action: 'zoom' | 'scroll';
  theme: 'light' | 'dark';
  new_agent_shortcut: string;
  dictation_shortcut?: string | null;
  dictation_model?: string | null;
  dictation_dictionary?: string;
  dictation_sounds?: boolean;
  memory_enabled?: boolean;
  dictation_haptics?: boolean;
  dictation_sound_volume?: number;
  dictation_disabled_surfaces?: string;
  anthropic_api_key: string | null;
  openai_api_key?: string | null;
  google_api_key?: string | null;
  openrouter_api_key?: string | null;
  custom_providers?: CustomProvider[];
  browser_homepage: string;
  browser_import_signins: boolean;
  auto_select_mode_on_new_agent: boolean;
  expand_new_chats_in_dashboard: boolean;
  auto_reveal_sub_agents: boolean;
  dev_mode: boolean;
  /** Notification toggles read before firing native notifications. */
  notify_agent_completion?: boolean;
  notify_agent_errors?: boolean;
  notify_workflow_runs?: boolean;
  notify_workflow_failures?: boolean;
  notify_sound?: boolean;
  notify_when_focused?: boolean;
  allow_experimental_updates: boolean;
  /** Managed subscription state; surfaces only when user has subscribed via cloud. */
  connection_mode?: 'own_key' | 'openswarm-pro' | 'free-trial';
  openswarm_bearer_token?: string | null;
  openswarm_proxy_url?: string | null;
  /** Zero-config free trial: server-owned, set by the cloud mint. remaining drives the onboarding "runs low" nudge. */
  free_trial_token?: string | null;
  free_trial_remaining?: number | null;
  free_trial_runs_limit?: number | null;
  /** Epoch seconds when the rolling window refills; powers the "fresh runs in ~Xh" nudge. */
  free_trial_resets_at?: number | null;
  openswarm_subscription_plan?: string | null;
  openswarm_subscription_expires?: string | null;
  openswarm_usage_cached?: SubscriptionUsage | null;
  /** Identity populated by /api/auth/signin-activate; Stripe checkout also fills these. */
  user_id?: string | null;
  user_name?: string | null;
  user_email?: string | null;
  signin_method?: 'google' | 'email' | 'stripe' | null;
  /** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */
  installation_id?: string | null;
  /** Onboarding v3 lifecycle: absent = never seen, 'done'/'skipped' once resolved. */
  onboarding_v3?: string | null;
  /** User-picked accent hex from the onboarding theme pad; null = stock accent. */
  accent_color?: string | null;
  /** Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas. */
  accent_gradient?: string[] | null;
  /** Global text-size ratio. Scales the root font-size so every rem-based size grows/shrinks together, no layout breakage. 1 = default. */
  ui_font_scale?: number;
  /** Dictation activation: true = hold the hotkey/mic to record and release to stop (WhisperFlow style); false = tap toggles. */
  voice_hold_to_talk?: boolean;
  personalized_greeting?: string | null;
  /** The short one-glance identity hook shown in the reveal's focal beat (greeting is the longer read). */
  personalized_headline?: string | null;
  personalized_starters?: PersonalizedStarter[];
  personalized_automations?: PersonalizedAutomation[];
  /** The hero's two-level menu: 4 general categories, each with 4 starters tailored to this user. */
  personalized_menu?: PersonalizedMenu | null;
  personalized_usage_summary?: string | null;
}

export interface PersonalizedStarter {
  title: string;
  prompt: string;
  reason?: string;
}

export interface PersonalizedAutomation {
  title: string;
  prompt: string;
  cadence: 'daily' | 'weekday' | 'weekly';
}

export interface PersonalizedMenu {
  computer: PersonalizedStarter[];
  research: PersonalizedStarter[];
  web: PersonalizedStarter[];
  build: PersonalizedStarter[];
}

export interface ActivateSubscriptionPayload {
  token: string;
  plan?: string | null;
  expires?: string | null;
}

export interface ActivateSigninPayload {
  token: string;
  signin_method: 'google' | 'email';
  email?: string | null;
}

export interface BrowseResult {
  current: string;
  parent: string | null;
  directories: string[];
  files: string[];
}

interface SettingsState {
  data: AppSettings;
  loading: boolean;
  /** We have the user's real settings. Anything that judges the user (no model connected, onboarding
   *  not done, free runs spent) must read THIS, never `settled`: a failed fetch is not an answer. */
  loaded: boolean;
  /** The first fetch finished, either way. Only the paint gate wants this, so a dead backend shows a
   *  window with an honest error instead of a blank one. */
  settled: boolean;
  /** In-flight form edits preserved across modal close/reopen; null = synced with `data`. */
  draft: AppSettings | null;
  /** Tab the user was on when they closed the modal with unsaved edits. */
  draftTab: string | null;
  /** Newest settings-write requestId. A stale GET that resolves after a newer
   *  fetch/PUT is dropped, so a slow boot fetch can't clobber the free-trial arm. */
  latestWriteId: string | null;
  /** False until the boot free-trial mint attempt settles; gates the no-model banner. */
  freeTrialArmSettled: boolean;
  /** The backend's shipped default prompt, fetched on demand; null until it lands. */
  defaultSystemPrompt: string | null;
}

/** Baseline for every required settings field. Spread under any backend payload so an older saved shape that predates a newer field (e.g. new_agent_shortcut) can't surface as undefined and crash a consumer. */
export const DEFAULT_SETTINGS: AppSettings = {
  default_system_prompt: null,
  default_folder: null,
  default_model: 'opus-5',
  default_mode: 'agent',
  default_max_turns: null,
  default_thinking_level: 'auto',
  zoom_sensitivity: 50,
  mouse_wheel_action: 'zoom',
  ui_font_scale: 1,
  voice_hold_to_talk: true,
  theme: 'light',
  new_agent_shortcut: 'Meta+l',
  dictation_shortcut: null,
  dictation_model: null,
  dictation_dictionary: '',
  dictation_sounds: true,
  memory_enabled: true,
  dictation_haptics: true,
  dictation_sound_volume: 0.7,
  dictation_disabled_surfaces: '',
  anthropic_api_key: null,
  browser_homepage: 'https://duckduckgo.com',
  browser_import_signins: false,
  auto_select_mode_on_new_agent: false,
  expand_new_chats_in_dashboard: true,
  auto_reveal_sub_agents: true,
  dev_mode: false,
  allow_experimental_updates: false,
};

const initialState: SettingsState = {
  data: DEFAULT_SETTINGS,
  loading: false,
  loaded: false,
  settled: false,
  draft: null,
  draftTab: null,
  latestWriteId: null,
  freeTrialArmSettled: false,
  defaultSystemPrompt: null,
};

export const fetchSettings = createAsyncThunk('settings/fetch', async () => {
  const res = await fetch(SETTINGS_API);
  return (await res.json()) as AppSettings;
});

// Save ONLY the fields the user changed, merged server-side onto fresh state. Every renderer save uses this so a stale full object can never clobber a field the user didn't touch (e.g. one an agent just changed).
export const updateSettingsPatch = createAsyncThunk(
  'settings/patch',
  async (changes: Partial<AppSettings>) => {
    const res = await fetch(SETTINGS_API, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(changes),
    });
    const data = await res.json();
    return data.settings as AppSettings;
  }
);

// The default lives in ONE place (the backend); shipping a frontend copy rotted once already and made Reset show forever.
export const fetchDefaultSystemPrompt = createAsyncThunk(
  'settings/fetchDefaultSystemPrompt',
  async () => {
    const res = await fetch(`${SETTINGS_API}/default-system-prompt`);
    const data = await res.json();
    return data.default_system_prompt as string;
  }
);

export const resetSystemPrompt = createAsyncThunk(
  'settings/resetSystemPrompt',
  async () => {
    const res = await fetch(`${SETTINGS_API}/reset-system-prompt`, { method: 'POST' });
    const data = await res.json();
    return data.settings as AppSettings;
  }
);

export const dismissMcpSuggestion = createAsyncThunk(
  'settings/dismissMcpSuggestion',
  async (ids: string[]) => {
    const res = await fetch(`${SETTINGS_API}/dismiss-mcp-suggestion`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ids }),
    });
    const data = await res.json();
    return data.settings as AppSettings;
  }
);

export const browseDirectories = createAsyncThunk(
  'settings/browseDirectories',
  async (path: string) => {
    const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
    if (!res.ok) throw new Error((await res.json()).detail);
    return (await res.json()) as BrowseResult;
  }
);

/** POST /api/subscription/activate after catching openswarm://auth deep link; flips UI to Pro. */
export const activateSubscription = createAsyncThunk(
  'settings/activateSubscription',
  async (payload: ActivateSubscriptionPayload, { dispatch }) => {
    const res = await fetch(`${API_BASE}/subscription/activate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error((await res.text()) || 'Activation failed');
    await dispatch(fetchSettings());
    return (await res.json()) as { ok: boolean; plan: string };
  }
);

/** POST /api/auth/signin-activate after catching Google OAuth/magic-link bearer; persists identity. */
export const activateSignin = createAsyncThunk(
  'settings/activateSignin',
  async (payload: ActivateSigninPayload, { dispatch }) => {
    const res = await fetch(`${API_BASE}/auth/signin-activate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error((await res.text()) || 'Sign-in failed');
    await dispatch(fetchSettings());
    return (await res.json()) as {
      ok: boolean;
      user_id: string;
      email: string;
      plan: string;
      signin_method: 'google' | 'email';
    };
  },
);

/** POST /api/auth/signout; revokes cloud bearer, clears local identity. */
export const signOut = createAsyncThunk(
  'settings/signOut',
  async (_: void, { dispatch }) => {
    const res = await fetch(`${API_BASE}/auth/signout`, { method: 'POST' });
    if (!res.ok) throw new Error('Sign-out failed');
    await dispatch(fetchSettings());
    return true;
  },
);

/** POST /api/subscription/disconnect; clears bearer, reverts to own_key. Doesn't cancel Stripe. */
export const disconnectSubscription = createAsyncThunk(
  'settings/disconnectSubscription',
  async (_: void, { dispatch }) => {
    const res = await fetch(`${API_BASE}/subscription/disconnect`, { method: 'POST' });
    if (!res.ok) throw new Error('Disconnect failed');
    await dispatch(fetchSettings());
    return true;
  }
);

const settingsSlice = createSlice({
  name: 'settings',
  initialState,
  reducers: {
    /** Persist in-flight form edits + tab so they survive modal close; clearDraft drops the marker. */
    setDraft(state, action: PayloadAction<{ form: AppSettings; tab: string }>) {
      state.draft = action.payload.form;
      state.draftTab = action.payload.tab;
    },
    clearDraft(state) {
      state.draft = null;
      state.draftTab = null;
    },
    /** Boot sets this once the free-trial mint attempt returns (armed or not). Until then
     *  we hold the "No AI model connected" banner so a new user never sees it flash. */
    markFreeTrialArmSettled(state) {
      state.freeTrialArmSettled = true;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchSettings.pending, (state, action) => {
        state.loading = true;
        state.latestWriteId = action.meta.requestId;
      })
      .addCase(fetchSettings.fulfilled, (state, action) => {
        state.loading = false;
        state.loaded = true;
        state.settled = true;
        // Drop a stale response: on boot three fetches race (initial, sub-sync, free-trial mint); if the pre-mint one resolves last it would wipe the armed trial. Newest wins.
        if (state.latestWriteId && action.meta.requestId !== state.latestWriteId) return;
        // Fill any field an older backend shape omitted so no consumer reads undefined; the payload still wins for everything it does send.
        const merged = { ...DEFAULT_SETTINGS, ...action.payload };
        // Skip ref-assignment when byte-identical; keeps background refetch polls from re-firing every effect.
        const next = JSON.stringify(merged);
        const prev = JSON.stringify(state.data);
        if (next !== prev) {
          state.data = merged;
        }
      })
      .addCase(fetchSettings.rejected, (state) => {
        state.loading = false;
        state.settled = true;
        // `loaded` deliberately stays put. A failed fetch is not an answer, and claiming one made every gate read DEFAULT_SETTINGS as fact: with the backend down the app greeted a configured user as a brand-new one and offered to sell them a subscription. A refresh that fails keeps the last good copy; a first fetch that fails stays unknown.
      })
      .addCase(updateSettingsPatch.fulfilled, (state, action) => {
        // A user save is authoritative; claim newest so an in-flight GET can't overwrite it, and consume the draft so reopening shows the saved state.
        state.latestWriteId = action.meta.requestId;
        state.data = { ...DEFAULT_SETTINGS, ...action.payload };
        state.draft = null;
        state.draftTab = null;
      })
      .addCase(fetchDefaultSystemPrompt.fulfilled, (state, action) => {
        state.defaultSystemPrompt = action.payload;
      })
      .addCase(resetSystemPrompt.fulfilled, (state, action) => {
        state.latestWriteId = action.meta.requestId;
        state.data = { ...DEFAULT_SETTINGS, ...action.payload };
        state.draft = null;
        state.draftTab = null;
      })
      .addCase(dismissMcpSuggestion.fulfilled, (state, action) => {
        state.latestWriteId = action.meta.requestId;
        state.data = { ...DEFAULT_SETTINGS, ...action.payload };
      });
  },
});

export const { setDraft, clearDraft, markFreeTrialArmSettled } = settingsSlice.actions;
export default settingsSlice.reducer;
