// Testers.AI core client for Node 18+. Zero dependencies; uses global fetch.
// Fault-tolerant by design — analysis is optional extra testing and must not
// crash or block the host test unless { strict: true } is explicitly set.

'use strict';

const fs = require('node:fs');
const path = require('node:path');

const DEFAULTS = {
  apiKey: undefined,
  baseUrl: 'https://api.testers.ai',
  enabled: true,
  // Vision/persona calls on the live backend can take 60–180s; size defaults
  // for that reality. Bump higher only for very large pages.
  timeoutMs: 180_000,
  maxRetries: 3,
  maxRetryWaitMs: 240_000,
  strict: false,
  sink: 'framework',
  sinkDir: './testersai-results',
  logFormat: 'json',
  quiet: false,
};

function fromEnv(overrides = {}) {
  const env = process.env;
  const b = (v, d) => v == null ? d : /^(1|true|yes|on)$/i.test(v);
  const cfg = {
    apiKey: env.TESTERSAI_API_KEY,
    baseUrl: env.TESTERSAI_BASE_URL || DEFAULTS.baseUrl,
    enabled: b(env.TESTERSAI_ENABLED, DEFAULTS.enabled),
    timeoutMs: Number(env.TESTERSAI_TIMEOUT_MS || DEFAULTS.timeoutMs),
    maxRetries: Number(env.TESTERSAI_MAX_RETRIES || DEFAULTS.maxRetries),
    maxRetryWaitMs: Number(env.TESTERSAI_MAX_RETRY_WAIT_MS || DEFAULTS.maxRetryWaitMs),
    strict: b(env.TESTERSAI_STRICT, DEFAULTS.strict),
    sink: env.TESTERSAI_SINK || DEFAULTS.sink,
    sinkDir: env.TESTERSAI_SINK_DIR || DEFAULTS.sinkDir,
    logFormat: env.TESTERSAI_LOG_FORMAT || DEFAULTS.logFormat,
    quiet: b(env.TESTERSAI_QUIET, DEFAULTS.quiet),
  };
  for (const [k, v] of Object.entries(overrides)) if (v !== undefined) cfg[k] = v;
  return cfg;
}

class TestersAIError extends Error {
  constructor(msg) { super(msg); this.name = 'TestersAIError'; }
}

class TestersAI {
  constructor(config = {}) {
    this.config = { ...DEFAULTS, ...fromEnv(config) };
  }

  async analyzeScreenshot(image, { context = {}, checks = [], testers = [] } = {}) {
    const buf = typeof image === 'string' ? fs.readFileSync(image)
              : image instanceof Uint8Array ? Buffer.from(image)
              : Buffer.from(image);
    const payload = { _artifact: buf.toString('base64'), _artifactType: 'screenshot', context };
    this._attachSelection(payload, checks, testers);
    return this._call('screenshot', '/api/test', payload);
  }

  async analyzeConsole(logs, { context = {}, checks = [], testers = [] } = {}) {
    const entries = typeof logs === 'string'
      ? [{ level: 'log', text: logs }]
      : logs.map((e) => ({ ...e }));
    const payload = { _artifact: JSON.stringify(entries), _artifactType: 'console_logs', context };
    this._attachSelection(payload, checks, testers);
    return this._call('console', '/api/test', payload);
  }

  async analyzeNetwork(harOrEntries, { context = {}, checks = [], testers = [] } = {}) {
    const artifact = (harOrEntries && harOrEntries.log)
      ? JSON.stringify(harOrEntries)
      : JSON.stringify(Array.from(harOrEntries).map((e) => ({ ...e })));
    const payload = { _artifact: artifact, _artifactType: 'network', context };
    this._attachSelection(payload, checks, testers);
    return this._call('network', '/api/test', payload);
  }

  async analyzePageText(text, { context = {}, checks = [], testers = [] } = {}) {
    const payload = { _artifact: text, _artifactType: 'page_text', context };
    this._attachSelection(payload, checks, testers);
    return this._call('page_text', '/api/test', payload);
  }

  /**
   * Generate diverse-user persona feedback.
   * Pass a screenshot (path / Buffer / Uint8Array) and/or pageText.
   * Returns AnalysisResult; raw.personas has the generated personas.
   * Ratings below `failBelow` are auto-promoted to issues.
   */
  async analyzePersonas({
    screenshot, pageText, context = {},
    personas = 3, personaTraits, attributes, failBelow,
  } = {}) {
    if (screenshot == null && !pageText) {
      throw new TestersAIError('analyzePersonas requires screenshot or pageText');
    }
    const payload = {
      context, personas: Number(personas), testers: 'personas',
      _artifactType: screenshot != null ? 'screenshot' : 'page_text',
    };
    if (screenshot != null) {
      const buf = typeof screenshot === 'string' ? fs.readFileSync(screenshot)
                : screenshot instanceof Uint8Array ? Buffer.from(screenshot)
                : Buffer.from(screenshot);
      payload._artifact = buf.toString('base64');
    } else if (pageText) {
      payload._artifact = pageText;
    }
    if (personaTraits) payload.persona_traits = [...personaTraits];
    if (attributes)    payload.attributes = [...attributes];

    const r = await this._call('personas', '/api/test', payload);
    if (r.ok && r.raw && typeof failBelow === 'number') {
      for (const p of (r.raw.personas || [])) {
        for (const [attr, score] of Object.entries(p.ratings || {})) {
          const s = Number(score);
          if (Number.isFinite(s) && s < failBelow) {
            r.issues.push({
              severity: s <= 3 ? 'critical' : 'high',
              category: `persona:${attr}`,
              message: `${p.name || 'user'} rated ${attr} ${s}/10`,
              location: null,
              evidence: (p.comments || '').slice(0, 240),
            });
          }
        }
      }
    }
    return r;
  }

  _attachSelection(payload, checks, testers) {
    const combined = [...(checks || []), ...(testers || [])];
    if (combined.length === 0) return;
    const { resolve } = require('./checks');
    const r = resolve(combined);
    if (r.checks.length)         payload.checks = r.checks;
    if (r.testers.length)        payload.testers = r.testers;
    if (r.custom_testers.length) payload.custom_testers = r.custom_testers;
    if (r.unknown.length) {
      this._log(
        'Unknown check/tester tokens ignored: ' +
        r.unknown.map((u) => JSON.stringify(u)).join(', ') +
        '. Valid names: https://testers.ai/sdk#checks'
      );
    }
  }

  _log(msg) {
    if (this.config.quiet) return;
    try { process.stderr.write(`[testersai] ${msg}\n`); } catch (_) { /* best-effort */ }
  }

  _looksInvalid(k) {
    // Real testers.ai tokens are 64-char hex; legacy keys may use sk_ / sk-.
    // We only flag obviously-too-short / empty values; anything else is sent
    // to the server which is the source of truth.
    return !k || k.length < 16;
  }

  _toWire(payload) {
    const p = { ...payload };
    const wire = {
      artifact: p._artifact,
      artifactType: p._artifactType || 'screenshot',
      token: this.config.apiKey,
      outputFormat: 'default',
    };
    delete p._artifact; delete p._artifactType;
    if (p.testers != null) {
      wire.testers = Array.isArray(p.testers) ? p.testers.join(',') : String(p.testers);
      delete p.testers;
    } else {
      wire.testers = 'auto';
    }
    for (const k of ['checks', 'custom_testers', 'context',
                     'persona_traits', 'attributes', 'personas']) {
      if (k in p) { wire[k] = p[k]; delete p[k]; }
    }
    Object.assign(wire, p);   // forward anything unknown
    return wire;
  }

  async _call(kind, urlPath, payload) {
    const started = Date.now();
    if (!this.config.enabled) return result(kind, { skipped: true, reason: 'disabled' });
    if (!this.config.apiKey) {
      this._log(
        "TESTERSAI_API_KEY is not set. Get one at https://testers.ai/sdk " +
        "(click 'Get API key' on the docs site). Set TESTERSAI_QUIET=true to silence this."
      );
      return this._fail(kind, 'missing_api_key', started);
    }
    if (this._looksInvalid(this.config.apiKey)) {
      this._log(
        "TESTERSAI_API_KEY looks malformed (expected a string starting with 'sk_'). " +
        "Double-check the value you copied from https://testers.ai/sdk."
      );
    }

    const url = this.config.baseUrl.replace(/\/$/, '') + urlPath;
    const body = JSON.stringify(this._toWire(payload));
    const deadline = started + this.config.maxRetryWaitMs;
    let attempt = 0, lastErr = null;

    while (true) {
      attempt++;
      try {
        const ctrl = new AbortController();
        const t = setTimeout(() => ctrl.abort(), this.config.timeoutMs);
        let resp;
        try {
          resp = await fetch(url, {
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${this.config.apiKey}`,
              'Content-Type': 'application/json',
              'User-Agent': 'testersai-js/0.1.1',
            },
            body, signal: ctrl.signal,
          });
        } finally { clearTimeout(t); }

        if (resp.status === 401 || resp.status === 403) {
          this._log(
            `Testers.AI API rejected the key (HTTP ${resp.status}). ` +
            'The key may be invalid, expired, or revoked. ' +
            'Get a fresh one at https://testers.ai/sdk.'
          );
          return this._fail(kind, `http_${resp.status}`, started);
        }
        if (resp.status === 429 || resp.status >= 500) {
          lastErr = `http_${resp.status}`;
          if (!this._shouldRetry(attempt, deadline)) {
            return result(kind, {
              ok: false, skipped: true,
              reason: resp.status === 429 ? 'rate_limited' : 'server_error',
              error: lastErr, duration_ms: Date.now() - started,
            });
          }
          await this._sleepBackoff(attempt, deadline, resp.headers.get('retry-after'));
          continue;
        }
        if (!resp.ok) return this._fail(kind, `http_${resp.status}`, started);
        const raw = await resp.json();
        return build(kind, raw, started);
      } catch (err) {
        lastErr = `network:${err.name || 'Error'}`;
        if (!this._shouldRetry(attempt, deadline)) return this._fail(kind, lastErr, started);
        await this._sleepBackoff(attempt, deadline, null);
      }
    }
  }

  _shouldRetry(attempt, deadline) {
    return attempt <= this.config.maxRetries && Date.now() < deadline;
  }

  async _sleepBackoff(attempt, deadline, retryAfter) {
    let wait = Math.min(250 * 2 ** (attempt - 1), 2000);
    wait += Math.random() * (wait / 2);
    const ra = Number(retryAfter);
    if (!Number.isNaN(ra) && ra > 0) wait = Math.min(ra * 1000, wait * 2);
    const remaining = deadline - Date.now();
    if (remaining <= 0) return;
    await new Promise((r) => setTimeout(r, Math.min(wait, remaining)));
  }

  _fail(kind, error, started) {
    if (this.config.strict) throw new TestersAIError(error);
    return result(kind, { ok: false, skipped: true, reason: 'error', error, duration_ms: Date.now() - started });
  }
}

function result(kind, over = {}) {
  return { kind, ok: true, issues: [], raw: null, skipped: false,
           reason: null, error: null, duration_ms: 0, ...over };
}

function build(kind, raw, started) {
  // Backend may return `bugs` (real backend) or `issues` (mock). Accept both.
  const list = raw.bugs || raw.issues || [];
  const sevFromPriority = (p) => {
    const n = Number(p);
    if (!Number.isFinite(n)) return 'info';
    if (n >= 9) return 'critical';
    if (n >= 7) return 'high';
    if (n >= 4) return 'medium';
    if (n >= 2) return 'low';
    return 'info';
  };
  const cat = (b) => {
    const t = b.bug_type || b.category || b.type || b.tester;
    if (Array.isArray(t)) return t.map(String).join(', ') || 'general';
    return String(t || 'general');
  };
  const issues = list.filter((b) => b && typeof b === 'object').map((b) => {
    let sev = b.severity;
    if (!sev && b.bug_priority != null) sev = sevFromPriority(b.bug_priority);
    if (!sev) sev = 'info';
    return {
      severity: String(sev).toLowerCase(),
      category: cat(b),
      message:  String(b.bug_title || b.message || b.description || b.title || ''),
      location: b.location || b.element || b.selector || null,
      evidence: String(b.bug_reasoning_why_a_bug || b.evidence || b.details || b.snippet || '') || null,
    };
  });
  if (raw && raw.persona_feedback && !raw.personas) {
    raw = { ...raw, personas: Array.isArray(raw.persona_feedback)
                              ? raw.persona_feedback : [raw.persona_feedback] };
  }
  return { kind, ok: true, issues, raw, skipped: false, reason: null,
           error: null, duration_ms: Date.now() - started };
}

function failed(r) {
  return (r.issues || []).some((i) => i.severity === 'high' || i.severity === 'critical');
}

// ---------------- sinks ----------------

function buildSinks(cfg, frameworkCallback) {
  const sinks = [];
  for (const name of String(cfg.sink || '').split(',').map((s) => s.trim().toLowerCase())) {
    if (name === 'disk') sinks.push(diskSink(cfg.sinkDir, cfg.logFormat));
    else if (name === 'return') sinks.push(() => {});
    else if (name === 'framework' && frameworkCallback) sinks.push(safeSink(frameworkCallback));
  }
  return sinks;
}

function safeSink(fn) {
  return (r) => { try { fn(r); } catch { /* never bubble */ } };
}

function diskSink(dir, fmt) {
  fs.mkdirSync(dir, { recursive: true });
  return (r) => {
    const ts = new Date().toISOString().replace(/[:.]/g, '-');
    const file = path.join(dir, `${ts}-${r.kind}-${Math.random().toString(36).slice(2, 10)}.${ext(fmt)}`);
    try { fs.writeFileSync(file, format(r, fmt)); } catch { /* ignore */ }
  };
}

function dispatch(r, sinks) { for (const s of sinks) try { s(r); } catch {} return r; }

function ext(fmt) { return ({ json: 'json', junit: 'xml', tap: 'tap', text: 'txt' })[fmt] || 'txt'; }

function format(r, fmt) {
  if (fmt === 'json') return JSON.stringify(r, null, 2);
  if (fmt === 'text') {
    const lines = [`[testersai:${r.kind}] ok=${r.ok} skipped=${r.skipped} reason=${r.reason || '-'}`];
    for (const i of r.issues) lines.push(`  [${i.severity}] ${i.category}: ${i.message}`);
    return lines.join('\n') + '\n';
  }
  if (fmt === 'tap') {
    if (!r.issues.length) return `1..1\nok 1 - ${r.kind} clean\n`;
    const lines = [`1..${r.issues.length}`];
    r.issues.forEach((i, idx) => {
      const status = (i.severity === 'high' || i.severity === 'critical') ? 'not ok' : 'ok';
      lines.push(`${status} ${idx + 1} - [${i.severity}] ${i.category}: ${i.message}`);
    });
    return lines.join('\n') + '\n';
  }
  if (fmt === 'junit') {
    const failures = r.issues.filter((i) => i.severity === 'high' || i.severity === 'critical').length;
    const esc = (s) => String(s).replace(/[<>&"']/g, (c) =>
      ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
    const cases = r.issues.map((i) => {
      const isFail = i.severity === 'high' || i.severity === 'critical';
      const inner = isFail ? `<failure message="${esc(i.message)}">${esc(i.evidence || '')}</failure>` : '';
      return `<testcase classname="testersai.${r.kind}" name="${esc(i.category + ':' + i.message.slice(0, 60))}">${inner}</testcase>`;
    }).join('');
    return `<testsuite name="testersai.${r.kind}" tests="${r.issues.length || 1}" failures="${failures}">${cases}</testsuite>`;
  }
  return JSON.stringify(r);
}

const _checks = require('./checks');
module.exports = {
  TestersAI, TestersAIError, fromEnv, buildSinks, dispatch, failed,
  CheckType: _checks.CheckType,
  TESTERS: _checks.TESTERS,
  TESTERS_BY_KEY: _checks.TESTERS_BY_KEY,
  customTester: _checks.customTester,
};
