/**
 * Custom Playwright Reporter for Grafana Loki
 * 
 * Streams test events and results to Loki in real-time for centralized logging and monitoring.
 */

import {
  FullConfig,
  FullResult,
  Reporter,
  Suite,
  TestCase,
  TestResult,
  TestStep,
} from '@playwright/test/reporter';

interface LokiStream {
  stream: Record<string, string>;
  values: Array<[string, string]>;
}

interface LokiPushRequest {
  streams: LokiStream[];
}

interface LokiReporterOptions {
  lokiUrl?: string;
  username?: string;
  password?: string;
  batchSize?: number;
  flushInterval?: number;
  labels?: Record<string, string>;
}

/**
 * Custom Playwright reporter that sends logs to Grafana Loki
 */
export default class LokiReporter implements Reporter {
  private lokiUrl: string;
  private basicAuth?: string;
  private batchSize: number;
  private flushInterval: number;
  private buffer: LokiStream[] = [];
  private flushTimer?: NodeJS.Timeout;
  private labels: Record<string, string>;
  private testStartTime?: number;
  private maxRetries: number = 3;

  constructor(options: LokiReporterOptions = {}) {
    this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100';
    this.batchSize = options.batchSize || 5;
    this.flushInterval = options.flushInterval || 5000;
    
    // Setup basic auth if credentials provided
    const username = options.username || process.env.GRAFANA_LOKI_USERNAME;
    const password = options.password || process.env.GRAFANA_LOKI_PASSWORD;
    if (username && password) {
      this.basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
    }

    // Base labels for all logs - sanitize to ensure Grafana Cloud compatibility
    this.labels = this.sanitizeLabels({
      job: 'playwright_tests',
      workflow: process.env.GITHUB_WORKFLOW || 'local',
      branch: process.env.GITHUB_REF_NAME || 'local',
      commit: process.env.GITHUB_SHA || 'local',
      run_id: process.env.GITHUB_RUN_ID || 'local',
      ...options.labels,
    });

    // Setup periodic flush
    this.flushTimer = setInterval(() => this.flush(), this.flushInterval);
  }

  /**
   * Called once before running tests
   */
  async onBegin(config: FullConfig, suite: Suite) {
    this.testStartTime = Date.now();
    
    const testCount = suite.allTests().length;
    await this.log({
      event: 'test_suite_start',
      message: `Starting Playwright test suite with ${testCount} tests`,
      total_tests: testCount,
      workers: config.workers,
    });
  }

  /**
   * Called after a test has been started
   */
  async onTestBegin(test: TestCase) {
    await this.log({
      event: 'test_start',
      test_name: test.title,
      test_file: this.getRelativePath(test.location.file),
      project: test.parent.project()?.name || 'unknown',
      message: `Test started: ${test.title}`,
    }, {
      browser: test.parent.project()?.name || 'unknown',
      test_file: this.getRelativePath(test.location.file),
    });
  }

  /**
   * Called after a test has been finished
   */
  async onTestEnd(test: TestCase, result: TestResult) {
    const status = result.status;
    const duration = result.duration;
    const browser = test.parent.project()?.name || 'unknown';
    const testFile = this.getRelativePath(test.location.file);

    // Determine log message based on status
    let message = `Test ${status}: ${test.title}`;
    if (status === 'failed' || status === 'timedOut') {
      message = `${message} - ${result.error?.message || 'Unknown error'}`;
    }

    await this.log({
      event: 'test_end',
      test_name: test.title,
      test_file: testFile,
      status,
      duration_ms: duration,
      retry: result.retry,
      message,
      error: status === 'failed' ? result.error?.message : undefined,
      error_stack: status === 'failed' ? result.error?.stack : undefined,
    }, {
      browser,
      test_file: testFile,
      test_name: test.title,
      status,
    });

    // Log individual test steps for failed tests
    if (status === 'failed') {
      for (const step of result.steps) {
        await this.logStep(test, step, browser, testFile);
      }
    }
  }

  /**
   * Log test step details
   */
  private async logStep(test: TestCase, step: TestStep, browser: string, testFile: string) {
    await this.log({
      event: 'test_step',
      test_name: test.title,
      step_title: step.title,
      step_category: step.category,
      duration_ms: step.duration,
      error: step.error?.message,
      message: `Step: ${step.title}`,
    }, {
      browser,
      test_file: testFile,
      step_category: step.category,
    });
  }

  /**
   * Called after all tests have been finished
   */
  async onEnd(result: FullResult) {
    const duration = this.testStartTime ? Date.now() - this.testStartTime : 0;
    
    await this.log({
      event: 'test_suite_end',
      status: result.status,
      duration_ms: duration,
      message: `Test suite ${result.status} in ${(duration / 1000).toFixed(2)}s`,
    });

    // Flush remaining logs
    await this.flush();
    
    // Clear flush timer
    if (this.flushTimer) {
      clearInterval(this.flushTimer);
    }
  }

  /**
   * Sanitize label names to match Grafana Cloud requirements [a-zA-Z_][a-zA-Z0-9_]*
   */
  private sanitizeLabels(labels: Record<string, string>): Record<string, string> {
    const sanitized: Record<string, string> = {};
    for (const [key, value] of Object.entries(labels)) {
      const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&');
      const sanitizedValue = String(value).replace(/[\n\r\t]/g, ' ');
      sanitized[sanitizedKey] = sanitizedValue;
    }
    return sanitized;
  }

  /**
   * Log a message to Loki
   */
  private async log(data: Record<string, any>, extraLabels: Record<string, string> = {}) {
    const timestamp = Date.now() * 1000000; // Convert to nanoseconds
    
    const stream: LokiStream = {
      stream: this.sanitizeLabels({
        ...this.labels,
        ...extraLabels,
        event: data.event || 'log',
      }),
      values: [[timestamp.toString(), JSON.stringify(data)]],
    };

    this.buffer.push(stream);

    // Flush if buffer is full
    if (this.buffer.length >= this.batchSize) {
      await this.flush();
    }
  }

  /**
   * Flush buffered logs to Loki with retry logic for Grafana Cloud
   */
  private async flush() {
    if (this.buffer.length === 0) {
      return;
    }

    const payload: LokiPushRequest = {
      streams: this.buffer,
    };

    this.buffer = [];

    // Retry with exponential backoff
    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        const headers: Record<string, string> = {
          'Content-Type': 'application/json',
          'User-Agent': 'ThrillWiki-Playwright-Tests/1.0',
        };

        if (this.basicAuth) {
          headers['Authorization'] = `Basic ${this.basicAuth}`;
        }

        const response = await fetch(`${this.lokiUrl}/loki/api/v1/push`, {
          method: 'POST',
          headers,
          body: JSON.stringify(payload),
        });

        if (response.ok || response.status === 204) {
          // Success
          return;
        }

        if (response.status === 401 || response.status === 403) {
          console.error(`Loki authentication failed: ${response.status}. Check GRAFANA_LOKI_USERNAME and GRAFANA_LOKI_PASSWORD`);
          return; // Don't retry auth errors
        }

        if (response.status === 429) {
          console.warn(`Loki rate limit hit, retrying... (attempt ${attempt + 1}/${this.maxRetries})`);
        } else {
          console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
          const errorText = await response.text();
          console.error(`Response: ${errorText}`);
        }

        // Don't retry on last attempt
        if (attempt < this.maxRetries - 1) {
          await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
        }
      } catch (error) {
        console.error(`Error sending logs to Loki (attempt ${attempt + 1}/${this.maxRetries}):`, error);
        if (attempt < this.maxRetries - 1) {
          await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
        } else {
          // Re-add to buffer on final failure
          this.buffer.push(...payload.streams);
        }
      }
    }
  }

  /**
   * Get relative path from project root
   */
  private getRelativePath(filePath: string): string {
    const cwd = process.cwd();
    if (filePath.startsWith(cwd)) {
      return filePath.substring(cwd.length + 1);
    }
    return filePath;
  }

  /**
   * Print summary to console
   */
  printsToStdio() {
    return false;
  }
}
