import { Endpoint, ErrorCode, Headers, Model } from './constants.js';
import { GemMixin } from './components/gem-mixin.js';
import {
  APIError,
  AuthError,
  GeminiError,
  ImageGenerationError,
  ModelInvalid,
  TemporarilyBlocked,
  TimeoutError,
  UsageLimitExceeded,
} from './exceptions.js';
import { Candidate, Gem, GeneratedImage, ModelOutput, RPCData, WebImage } from './types/index.js';
import {
  extract_json_from_response,
  get_access_token,
  get_nested_value,
  logger,
  parse_file_name,
  rotate_1psidts,
  rotate_tasks,
  fetch_with_timeout,
  sleep,
  upload_file,
  write_cookie_file,
  resolveGeminiWebCookiePath,
} from './utils/index.js';

type InitOptions = {
  timeout?: number;
  auto_close?: boolean;
  close_delay?: number;
  auto_refresh?: boolean;
  refresh_interval?: number;
  verbose?: boolean;
};

type RequestKwargs = RequestInit & { timeout_ms?: number };

function normalize_headers(h?: HeadersInit): Record<string, string> {
  if (!h) return {};
  if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));
  if (h instanceof Headers) {
    const out: Record<string, string> = {};
    h.forEach((v, k) => {
      out[k] = v;
    });
    return out;
  }
  return { ...(h as Record<string, string>) };
}

function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] {
  const out: string[] = [];
  const seen = new Set<string>();
  const stack: unknown[] = [root];

  while (stack.length > 0 && out.length < limit) {
    const v = stack.pop();
    if (typeof v === 'string') {
      if (accept(v) && !seen.has(v)) {
        seen.add(v);
        out.push(v);
      }
      continue;
    }

    if (Array.isArray(v)) {
      for (let i = 0; i < v.length; i++) stack.push(v[i]);
      continue;
    }

    if (v && typeof v === 'object') {
      for (const val of Object.values(v as Record<string, unknown>)) stack.push(val);
    }
  }

  return out;
}

export class GeminiClient extends GemMixin {
  public cookies: Record<string, string> = {};
  public proxy: string | null = null;
  public _running: boolean = false;
  public access_token: string | null = null;
  public timeout: number = 300;
  public auto_close: boolean = false;
  public close_delay: number = 300;
  public auto_refresh: boolean = true;
  public refresh_interval: number = 540;
  public kwargs: RequestInit;

  private close_timer: ReturnType<typeof setTimeout> | null = null;
  private refresh_abort: AbortController | null = null;

  constructor(
    secure_1psid: string | null = null,
    secure_1psidts: string | null = null,
    proxy: string | null = null,
    kwargs: RequestInit = {},
  ) {
    super();
    this.proxy = proxy;
    this.kwargs = kwargs;

    if (secure_1psid) {
      this.cookies['__Secure-1PSID'] = secure_1psid;
      if (secure_1psidts) this.cookies['__Secure-1PSIDTS'] = secure_1psidts;
    }
  }

  async init(
    timeoutOrOpts: number | InitOptions = 300,
    auto_close: boolean = false,
    close_delay: number = 300,
    auto_refresh: boolean = true,
    refresh_interval: number = 540,
    verbose: boolean = true,
  ): Promise<void> {
    const opts: InitOptions =
      typeof timeoutOrOpts === 'object'
        ? timeoutOrOpts
        : { timeout: timeoutOrOpts, auto_close, close_delay, auto_refresh, refresh_interval, verbose };

    const timeout = opts.timeout ?? 300;
    const ac = opts.auto_close ?? false;
    const cd = opts.close_delay ?? 300;
    const ar = opts.auto_refresh ?? true;
    const ri = opts.refresh_interval ?? 540;
    const vb = opts.verbose ?? true;

    try {
      const [token, valid] = await get_access_token(this.cookies, this.proxy, vb);
      this.access_token = token;
      this.cookies = valid;
      this._running = true;

      this.timeout = timeout;
      this.auto_close = ac;
      this.close_delay = cd;
      if (this.auto_close) await this.reset_close_task();

      this.auto_refresh = ar;
      this.refresh_interval = ri;

      const sid = this.cookies['__Secure-1PSID'];
      if (sid) {
        const existing = rotate_tasks.get(sid);
        if (existing && existing instanceof AbortController) existing.abort();
        rotate_tasks.delete(sid);
      }

      if (this.auto_refresh && sid) {
        const ctl = new AbortController();
        this.refresh_abort?.abort();
        this.refresh_abort = ctl;
        rotate_tasks.set(sid, ctl);
        void this.start_auto_refresh(ctl.signal);
      }

      await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'client').catch(() => {});

      if (vb) logger.success('Gemini client initialized successfully.');
    } catch (e) {
      await this.close();
      throw e;
    }
  }

  async close(delay: number = 0): Promise<void> {
    if (delay > 0) await sleep(delay * 1000);
    this._running = false;

    if (this.close_timer) {
      clearTimeout(this.close_timer);
      this.close_timer = null;
    }

    this.refresh_abort?.abort();
    this.refresh_abort = null;

    const sid = this.cookies['__Secure-1PSID'];
    const t = sid ? rotate_tasks.get(sid) : null;
    if (t && t instanceof AbortController) t.abort();
    if (sid) rotate_tasks.delete(sid);
  }

  async reset_close_task(): Promise<void> {
    if (this.close_timer) {
      clearTimeout(this.close_timer);
      this.close_timer = null;
    }

    this.close_timer = setTimeout(() => {
      void this.close(0);
    }, this.close_delay * 1000);
    this.close_timer.unref?.();
  }

  async start_auto_refresh(signal: AbortSignal): Promise<void> {
    while (!signal.aborted) {
      let newTs: string | null = null;
      try {
        newTs = await rotate_1psidts(this.cookies, this.proxy);
      } catch (e) {
        if (e instanceof AuthError) {
          logger.warning('AuthError: Failed to refresh cookies. Auto refresh task canceled.');
          return;
        }
        logger.warning(`Unexpected error while refreshing cookies: ${e instanceof Error ? e.message : String(e)}`);
      }

      if (newTs) {
        this.cookies['__Secure-1PSIDTS'] = newTs;
        await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'refresh').catch(() => {});
        logger.debug('Cookies refreshed. New __Secure-1PSIDTS applied.');
      }

      await sleep(this.refresh_interval * 1000, signal);
    }
  }

  protected async _run<T>(fn: () => Promise<T>, retry: number): Promise<T> {
    try {
      if (!this._running) {
        await this.init({
          timeout: this.timeout,
          auto_close: this.auto_close,
          close_delay: this.close_delay,
          auto_refresh: this.auto_refresh,
          refresh_interval: this.refresh_interval,
          verbose: false,
        });

        if (!this._running) {
          throw new APIError('Client initialization failed.');
        }
      }

      return await fn();
    } catch (e) {
      let r = retry;
      if (e instanceof ImageGenerationError) r = Math.min(1, r);
      if (e instanceof APIError && r > 0) {
        await sleep(1000);
        return await this._run(fn, r - 1);
      }
      throw e;
    }
  }

  async generate_content(
    prompt: string,
    files: string[] | null = null,
    model: Model | string | Record<string, unknown> = Model.UNSPECIFIED,
    gem: Gem | string | null = null,
    chat: ChatSession | null = null,
    kwargs: RequestKwargs = {},
  ): Promise<ModelOutput> {
    return await this._run(async () => {
      if (!prompt) throw new Error('Prompt cannot be empty.');

      let mdl: Model;
      if (typeof model === 'string') mdl = Model.from_name(model);
      else if (model instanceof Model) mdl = model;
      else if (model && typeof model === 'object') mdl = Model.from_dict(model);
      else throw new TypeError(`'model' must be a Model instance, string, or dictionary; got ${typeof model}`);

      const gem_id = gem instanceof Gem ? gem.id : gem;

      if (this.auto_close) await this.reset_close_task();

      if (!this.access_token) throw new APIError('Missing access token.');

      const f = files?.length ? files : null;
      const uploaded =
        f &&
        (await Promise.all(
          f.map(async (p) => [[await upload_file(p, this.proxy)], parse_file_name(p)] as [string[], string]),
        ));

      const first = uploaded ? [prompt, 0, null, uploaded] : [prompt];
      const inner: unknown[] = [first, null, chat ? chat.metadata : null];

      if (gem_id) {
        for (let i = 0; i < 16; i++) inner.push(null);
        inner.push(gem_id);
      }

      const f_req = JSON.stringify([null, JSON.stringify(inner)]);
      const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();

      const h0 = { ...Headers.GEMINI, ...mdl.model_header, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
      const h1 = { ...h0, ...normalize_headers(kwargs.headers) };

      let res: Response;
      try {
        const timeout_ms = typeof kwargs.timeout_ms === 'number' ? kwargs.timeout_ms : this.timeout * 1000;
        const { timeout_ms: _t, ...rest } = kwargs;
        res = await fetch_with_timeout(Endpoint.GENERATE, {
          method: 'POST',
          headers: h1,
          body,
          redirect: 'follow',
          ...this.kwargs,
          ...rest,
          timeout_ms,
        });
      } catch (e) {
        throw new TimeoutError(
          `Generate content request timed out, please try again. If the problem persists, consider setting a higher 'timeout' value when initializing GeminiClient. (${e instanceof Error ? e.message : String(e)})`,
        );
      }

      if (res.status !== 200) {
        await this.close();
        throw new APIError(`Failed to generate contents. Request failed with status code ${res.status}`);
      }

      const txt = await res.text();
      const response_json = extract_json_from_response(txt);

      let body_json: unknown[] | null = null;
      let body_index = 0;

      try {
        if (!Array.isArray(response_json)) throw new Error('Invalid JSON');
        for (let part_index = 0; part_index < response_json.length; part_index++) {
          const part = response_json[part_index];
          if (!Array.isArray(part)) continue;
          const part_body = get_nested_value<string | null>(part, [2], null);
          if (!part_body) continue;
          try {
            const part_json = JSON.parse(part_body) as unknown[];
            if (get_nested_value(part_json, [4], null)) {
              body_index = part_index;
              body_json = part_json;
              break;
            }
          } catch {}
        }
        if (!body_json) throw new Error('No body');
      } catch {
        await this.close();
        try {
          const code = get_nested_value<number>(response_json, [0, 5, 2, 0, 1, 0], -1);
          if (code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
            throw new UsageLimitExceeded(
              `Failed to generate contents. Usage limit of ${mdl.model_name} model has exceeded. Please try switching to another model.`,
            );
          }
          if (code === ErrorCode.MODEL_INCONSISTENT) {
            throw new ModelInvalid(
              'Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same `model` parameter when starting a chat session with previous metadata.',
            );
          }
          if (code === ErrorCode.MODEL_HEADER_INVALID) {
            throw new ModelInvalid(
              'Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
            );
          }
          if (code === ErrorCode.IP_TEMPORARILY_BLOCKED) {
            throw new TemporarilyBlocked(
              'Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while.',
            );
          }
        } catch (e) {
          if (e instanceof GeminiError) throw e;
        }

        logger.debug(`Invalid response: ${txt.slice(0, 500)}`);
        throw new APIError('Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request.');
      }

      try {
        const candidate_list = get_nested_value<unknown[]>(body_json, [4], []);
        const out: Candidate[] = [];

        for (let candidate_index = 0; candidate_index < candidate_list.length; candidate_index++) {
          const candidate = candidate_list[candidate_index];
          if (!Array.isArray(candidate)) continue;

          const rcid = get_nested_value<string | null>(candidate, [0], null);
          if (!rcid) continue;

          let text = String(get_nested_value(candidate, [1, 0], ''));
          if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {
            text = String(get_nested_value(candidate, [22, 0], text));
          }

          const thoughts = get_nested_value<string | null>(candidate, [37, 0, 0], null);

          const web_images: WebImage[] = [];
          for (const w of get_nested_value<unknown[]>(candidate, [12, 1], [])) {
            if (!Array.isArray(w)) continue;
            const url = get_nested_value<string | null>(w, [0, 0, 0], null);
            if (!url) continue;
            web_images.push(new WebImage(url, String(get_nested_value(w, [7, 0], '')), String(get_nested_value(w, [0, 4], '')), this.proxy));
          }

          const generated_images: GeneratedImage[] = [];
          const wants_generated =
            get_nested_value(candidate, [12, 7, 0], null) != null ||
            /http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);

          if (wants_generated) {
            let img_body: unknown[] | null = null;
            for (let part_index = body_index; part_index < (response_json as unknown[]).length; part_index++) {
              const part = (response_json as unknown[])[part_index];
              if (!Array.isArray(part)) continue;
              const part_body = get_nested_value<string | null>(part, [2], null);
              if (!part_body) continue;
              try {
                const part_json = JSON.parse(part_body) as unknown[];
                const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);
                if (!cand) continue;

                const urls = collect_strings(cand, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 1);
                if (urls.length > 0) {
                  img_body = part_json;
                  break;
                }
              } catch {}
            }

            if (!img_body) {
              throw new ImageGenerationError(
                'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
              );
            }

            const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
            const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
            if (finished) {
              text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
            }

            const gen = get_nested_value<unknown[]>(img_candidate, [12, 7, 0], []);
            for (let img_index = 0; img_index < gen.length; img_index++) {
              const g = gen[img_index];
              if (!Array.isArray(g)) continue;
              const url = get_nested_value<string | null>(g, [0, 3, 3], null);
              if (!url) continue;
              const img_num = get_nested_value<number | null>(g, [3, 6], null);
              const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]';
              const alt_list = get_nested_value<unknown[]>(g, [3, 5], []);
              const alt =
                (typeof alt_list[img_index] === 'string' ? (alt_list[img_index] as string) : null) ??
                (typeof alt_list[0] === 'string' ? (alt_list[0] as string) : '') ??
                '';
              generated_images.push(new GeneratedImage(url, title, alt, this.proxy, this.cookies));
            }

            if (generated_images.length === 0) {
              const urls = collect_strings(img_candidate, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 4);
              for (const url of urls) {
                generated_images.push(new GeneratedImage(url, '[Generated Image]', '', this.proxy, this.cookies));
              }
            }
          }

          out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images }));
        }

        if (out.length === 0) {
          throw new GeminiError('Failed to generate contents. No output data found in response.');
        }

        const metadata = get_nested_value<string[]>(body_json, [1], []);
        const output = new ModelOutput({ metadata, candidates: out });

        if (chat instanceof ChatSession) chat.last_output = output;
        return output;
      } catch (e) {
        if (e instanceof GeminiError || e instanceof APIError) throw e;
        throw new APIError('Failed to parse response body. Data structure is invalid.');
      }
    }, 2);
  }

  async generateContent(
    prompt: string,
    files?: string[] | null,
    model?: Model | string | Record<string, unknown>,
    gem?: Gem | string | null,
    chat?: ChatSession | null,
    kwargs?: RequestKwargs,
  ): Promise<ModelOutput> {
    return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {});
  }

  start_chat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
    return new ChatSession(this, opts);
  }

  startChat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
    return this.start_chat(opts);
  }

  protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise<Response> {
    if (!this.access_token) throw new APIError('Missing access token.');

    const f_req = JSON.stringify([payloads.map((p) => p.serialize())]);
    const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();

    const h0 = { ...Headers.GEMINI, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
    const h1 = { ...h0, ...normalize_headers(opts.headers) };

    const res = await fetch_with_timeout(Endpoint.BATCH_EXEC, {
      method: 'POST',
      headers: h1,
      body,
      redirect: 'follow',
      ...this.kwargs,
      ...opts,
      timeout_ms: this.timeout * 1000,
    });

    if (res.status !== 200) {
      await this.close();
      throw new APIError(`Batch execution failed with status code ${res.status}`);
    }

    return res;
  }
}

export class ChatSession {
  private __metadata: Array<string | null> = [null, null, null];
  public geminiclient: GeminiClient;
  private _last_output: ModelOutput | null = null;
  public model: Model | string | Record<string, unknown>;
  public gem: Gem | string | null;

  constructor(
    geminiclient: GeminiClient,
    opts: {
      metadata?: Array<string | null>;
      cid?: string | null;
      rid?: string | null;
      rcid?: string | null;
      model?: Model | string | Record<string, unknown>;
      gem?: Gem | string | null;
    } = {},
  ) {
    this.geminiclient = geminiclient;
    this.model = opts.model ?? Model.UNSPECIFIED;
    this.gem = opts.gem ?? null;

    if (opts.metadata) this.metadata = opts.metadata;
    if (opts.cid) this.cid = opts.cid;
    if (opts.rid) this.rid = opts.rid;
    if (opts.rcid) this.rcid = opts.rcid;
  }

  toString(): string {
    return `ChatSession(cid='${this.cid}', rid='${this.rid}', rcid='${this.rcid}')`;
  }

  get last_output(): ModelOutput | null {
    return this._last_output;
  }

  set last_output(v: ModelOutput | null) {
    this._last_output = v;
    if (v) {
      this.metadata = (v.metadata ?? []) as Array<string | null>;
      this.rcid = v.rcid;
    }
  }

  async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise<ModelOutput> {
    return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs);
  }

  async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise<ModelOutput> {
    return await this.send_message(prompt, files ?? null, kwargs ?? {});
  }

  choose_candidate(index: number): ModelOutput {
    if (!this.last_output) throw new Error('No previous output data found in this chat session.');
    if (index >= this.last_output.candidates.length) {
      throw new Error(`Index ${index} exceeds the number of candidates in last model output.`);
    }
    this.last_output.chosen = index;
    this.rcid = this.last_output.rcid;
    return this.last_output;
  }

  chooseCandidate(index: number): ModelOutput {
    return this.choose_candidate(index);
  }

  get metadata(): Array<string | null> {
    return this.__metadata;
  }

  set metadata(v: Array<string | null>) {
    if (v.length > 3) throw new Error('metadata cannot exceed 3 elements');
    this.__metadata = [null, null, null];
    for (let i = 0; i < v.length; i++) this.__metadata[i] = v[i] ?? null;
  }

  get cid(): string | null {
    return this.__metadata[0];
  }

  set cid(v: string | null) {
    this.__metadata[0] = v;
  }

  get rid(): string | null {
    return this.__metadata[1];
  }

  set rid(v: string | null) {
    this.__metadata[1] = v;
  }

  get rcid(): string | null {
    return this.__metadata[2];
  }

  set rcid(v: string | null) {
    this.__metadata[2] = v;
  }
}
