import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
  AuditEventsService,
  type AdminAuditPage,
  type AdminAuditQuery,
} from './audit-events.service';

/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
const PAGE_SIZES = [25, 50, 100, 200] as const;

/**
 * Audit log viewer per ADR-0020's v1 admin catalogue. Consumes
 * `GET /api/admin/audit` from the BFF (PR #132) and renders:
 *
 *   - A filter row binding to the same fields the BFF accepts
 *     (event type, audience, outcome, subject prefix, time range).
 *   - A page-size selector + previous / next buttons. Pagination is
 *     offset-based to match the BFF's v1 implementation.
 *   - A table of results with the timestamp (locale-formatted),
 *     event type, audience+outcome cell, actor hash + subject, and
 *     trace id. Payload is rendered as JSON in a details disclosure
 *     so the table stays scannable but the structured detail is one
 *     click away.
 *
 * The page emits its own `admin.audit.query` audit row server-side on
 * every fetch — the BFF deterrent against fishing expeditions is on
 * the read endpoint itself, not in the SPA.
 */
@Component({
  selector: 'app-audit-page',
  imports: [FormsModule],
  templateUrl: './audit.html',
  styleUrl: './audit.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuditPage {
  private readonly service = inject(AuditEventsService);

  protected readonly pageSizes = PAGE_SIZES;

  // Filter form state. Each field is a separate signal so a change
  // to one doesn't churn the others through ngModel's reference
  // equality. All start empty — the BFF returns the most recent
  // events on an unfiltered query.
  protected readonly eventType = signal('');
  protected readonly actorIdHash = signal('');
  protected readonly audience = signal<'' | 'workforce' | 'customer'>('');
  protected readonly outcome = signal<'' | 'success' | 'failure' | 'denied'>('');
  protected readonly subjectPrefix = signal('');
  protected readonly createdAtFrom = signal('');
  protected readonly createdAtTo = signal('');

  protected readonly limit = signal<number>(50);
  protected readonly offset = signal<number>(0);

  // Result state. `page` is the current server response; `loading`
  // is true while a fetch is in flight; `error` carries a short
  // string for the alert region.
  protected readonly page = signal<AdminAuditPage | null>(null);
  protected readonly loading = signal(false);
  protected readonly error = signal<string | null>(null);

  /** True when the loaded page does NOT cover the next offset boundary. */
  protected readonly hasNextPage = computed(() => {
    const p = this.page();
    if (p === null) return false;
    return p.offset + p.items.length < p.total;
  });

  protected readonly hasPreviousPage = computed(() => {
    const p = this.page();
    if (p === null) return false;
    return p.offset > 0;
  });

  // Human-readable "results 1–50 of 1 234" string. Defensive — when
  // the page is empty we report "no results" rather than "1–0".
  protected readonly resultRange = computed(() => {
    const p = this.page();
    if (p === null) return '';
    if (p.items.length === 0) return p.total === 0 ? 'No results' : `0 of ${p.total}`;
    const first = p.offset + 1;
    const last = p.offset + p.items.length;
    return `${first}–${last} of ${p.total}`;
  });

  /**
   * Run a new query with the current filter signals at offset 0.
   * Called by the "Apply filters" button + the initial render via
   * the template's `@defer (on viewport)` block.
   */
  async search(): Promise<void> {
    this.offset.set(0);
    await this.fetch();
  }

  /** Page navigation — moves the offset by `limit`, then fetches. */
  async next(): Promise<void> {
    if (!this.hasNextPage()) return;
    this.offset.update((o) => o + this.limit());
    await this.fetch();
  }

  async previous(): Promise<void> {
    if (!this.hasPreviousPage()) return;
    this.offset.update((o) => Math.max(0, o - this.limit()));
    await this.fetch();
  }

  /** Reset every filter to its empty value and re-query. */
  async clearFilters(): Promise<void> {
    this.eventType.set('');
    this.actorIdHash.set('');
    this.audience.set('');
    this.outcome.set('');
    this.subjectPrefix.set('');
    this.createdAtFrom.set('');
    this.createdAtTo.set('');
    this.offset.set(0);
    await this.fetch();
  }

  /** Format an ISO timestamp into the user's locale for the table. */
  protected formatTimestamp(iso: string): string {
    try {
      return new Date(iso).toLocaleString();
    } catch {
      // Fall back to the raw ISO string if Date refuses it — better
      // than rendering "Invalid Date" in the table.
      return iso;
    }
  }

  /** Stringify the JSONB payload for the disclosure widget. */
  protected formatPayload(payload: Record<string, unknown> | null): string {
    if (payload === null) return '—';
    try {
      return JSON.stringify(payload, null, 2);
    } catch {
      return String(payload);
    }
  }

  private async fetch(): Promise<void> {
    this.loading.set(true);
    this.error.set(null);
    try {
      const filters = this.buildFilters();
      const page = await firstValueFrom(this.service.query(filters));
      this.page.set(page);
    } catch (err) {
      // Don't surface the raw error message to the admin — it's
      // either a 401 (handled by the unauthorized interceptor), a
      // 403 (admin role revoked mid-session), or a 5xx. A short
      // generic string keeps the UI predictable; the structured
      // error envelope is in the network tab for ops.
      const status = errorStatus(err);
      this.error.set(
        status === 403
          ? 'You do not have access to the audit log on this session.'
          : 'Could not load the audit log. Try again in a moment.',
      );
      this.page.set(null);
    } finally {
      this.loading.set(false);
    }
  }

  private buildFilters(): AdminAuditQuery {
    return {
      eventType: this.eventType().trim() || undefined,
      actorIdHash: this.actorIdHash().trim() || undefined,
      audience: this.audience() || undefined,
      outcome: this.outcome() || undefined,
      subjectPrefix: this.subjectPrefix().trim() || undefined,
      createdAtFrom: this.createdAtFrom() ? toIso(this.createdAtFrom()) : undefined,
      createdAtTo: this.createdAtTo() ? toIso(this.createdAtTo()) : undefined,
      limit: this.limit(),
      offset: this.offset(),
    };
  }

  ngOnInit(): void {
    // Fire the initial unfiltered query so the table renders with
    // the most recent events on first paint. Fire-and-forget — the
    // page handles its own loading / error state internally.
    void this.fetch();
  }
}

function toIso(localValue: string): string {
  // `<input type="datetime-local">` emits `YYYY-MM-DDTHH:mm` (no
  // timezone). Treat that as the user's local time and convert to
  // a true ISO 8601 string so the BFF's IsISO8601 validator accepts
  // it. Falls back to the raw value if Date can't parse it (the
  // BFF will then return a 400 with a clear validation error).
  const d = new Date(localValue);
  return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
}

function errorStatus(err: unknown): number | null {
  if (typeof err === 'object' && err !== null && 'status' in err) {
    const s = (err as { status: unknown }).status;
    return typeof s === 'number' ? s : null;
  }
  return null;
}
