import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {Title} from '@angular/platform-browser';
import {TranslatePipe} from '@ngx-translate/core';

type StopwatchState = 'idle' | 'running' | 'paused';

/** Which of the two configured intervals a beep belongs to. */
type IntervalSlot = 'first' | 'second';

const FIRST_INTERVAL_FREQUENCY_HZ = 880;
const SECOND_INTERVAL_FREQUENCY_HZ = 587;

@Component({
  selector: 'app-stopwatch',
  standalone: true,
  imports: [
    FormsModule,
    MatButtonModule,
    MatCardModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    MatCheckboxModule,
    TranslatePipe
  ],
  templateUrl: './stopwatch.component.html'
})
export class StopwatchComponent implements OnDestroy {
  readonly state = signal<StopwatchState>('idle');
  readonly elapsedMs = signal(0);

  readonly intervalEnabled = signal(false);
  readonly firstIntervalMinutes = signal(1);
  readonly firstIntervalSeconds = signal(0);

  readonly secondIntervalEnabled = signal(false);
  readonly secondIntervalMinutes = signal(2);
  readonly secondIntervalSeconds = signal(0);

  readonly flashing = signal(false);

  readonly display = computed(() => formatElapsed(this.elapsedMs()));

  readonly firstIntervalMs = computed(
    () => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds())
  );

  readonly secondIntervalMs = computed(() => {
    if (!this.secondIntervalEnabled()) {
      return 0;
    }
    return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds());
  });

  /** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */
  readonly schedulePreview = computed(() => {
    const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4);
    return beepTimes.map(formatElapsedShort).join(' · ');
  });

  private intervalId: number | null = null;
  private startTimestamp = 0;
  private accumulatedMs = 0;
  private previousElapsedMs = 0;
  private audioContext: AudioContext | null = null;
  private flashTimeoutId: number | null = null;
  private readonly titleService = inject(Title);
  private readonly originalTitle = this.titleService.getTitle();

  constructor() {
    effect(() => {
      const state = this.state();
      const elapsed = this.elapsedMs();
      this.updateDocumentTitle(state, elapsed);
    });
  }

  start(): void {
    if (this.state() === 'running') {
      return;
    }
    this.ensureAudioContext();
    this.resumeAudioContext();
    this.startTimestamp = performance.now();
    this.previousElapsedMs = this.accumulatedMs;
    this.state.set('running');
    this.startTicking();
  }

  pause(): void {
    if (this.state() !== 'running') {
      return;
    }
    this.accumulatedMs = this.elapsedMs();
    this.cancelTick();
    this.state.set('paused');
  }

  stop(): void {
    this.cancelTick();
    this.accumulatedMs = 0;
    this.previousElapsedMs = 0;
    this.elapsedMs.set(0);
    this.state.set('idle');
  }

  reset(): void {
    this.stop();
  }

  /** Number inputs can emit empty strings or negative values, so sanitize before storing. */
  updateIntervalValue(target: WritableSignal<number>, value: unknown): void {
    target.set(toNonNegativeInteger(value));
  }

  ngOnDestroy(): void {
    this.cancelTick();
    if (this.flashTimeoutId !== null) {
      clearTimeout(this.flashTimeoutId);
      this.flashTimeoutId = null;
    }
    if (this.audioContext) {
      this.audioContext.close().catch(() => undefined);
      this.audioContext = null;
    }
    this.titleService.setTitle(this.originalTitle);
  }

  private startTicking(): void {
    this.cancelTick();
    this.tick();
    // setInterval keeps firing in background tabs (throttled to ~1s) so beeps still play when unfocused.
    this.intervalId = window.setInterval(this.tick, 50);
  }

  private tick = (): void => {
    if (this.state() !== 'running') {
      return;
    }
    const now = performance.now();
    const newElapsed = this.accumulatedMs + (now - this.startTimestamp);

    this.checkIntervalCrossing(this.previousElapsedMs, newElapsed);
    this.previousElapsedMs = newElapsed;
    this.elapsedMs.set(newElapsed);
  };

  private cancelTick(): void {
    if (this.intervalId !== null) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  private updateDocumentTitle(state: StopwatchState, elapsed: number): void {
    if (state === 'idle') {
      this.titleService.setTitle(this.originalTitle);
      return;
    }
    const prefix = state === 'running' ? '▶' : '⏸';
    this.titleService.setTitle(`${prefix} ${formatElapsedShort(elapsed)} · ${this.originalTitle}`);
  }

  private checkIntervalCrossing(prevMs: number, currMs: number): void {
    if (!this.intervalEnabled()) {
      return;
    }
    const firstMs = this.firstIntervalMs();
    const secondMs = this.secondIntervalMs();

    const previousBeeps = countBeeps(prevMs, firstMs, secondMs);
    const currentBeeps = countBeeps(currMs, firstMs, secondMs);
    if (currentBeeps <= previousBeeps) {
      return;
    }

    const slot = slotOfBeep(currentBeeps, secondMs);
    this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
    this.triggerFlash();
  }

  private triggerFlash(): void {
    if (this.flashTimeoutId !== null) {
      clearTimeout(this.flashTimeoutId);
    }
    this.flashing.set(true);
    this.flashTimeoutId = window.setTimeout(() => {
      this.flashing.set(false);
      this.flashTimeoutId = null;
    }, 450);
  }

  private ensureAudioContext(): void {
    if (this.audioContext) {
      return;
    }
    const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
    if (Ctor) {
      this.audioContext = new Ctor();
    }
  }

  private resumeAudioContext(): void {
    // Browsers suspend AudioContext until a user gesture; resume on start so beeps can play in background tabs.
    if (this.audioContext && this.audioContext.state === 'suspended') {
      this.audioContext.resume().catch(() => undefined);
    }
  }

  private beep(frequencyHz: number): void {
    if (!this.audioContext) {
      return;
    }
    const ctx = this.audioContext;
    const oscillator = ctx.createOscillator();
    const gain = ctx.createGain();

    oscillator.type = 'sine';
    oscillator.frequency.value = frequencyHz;

    const now = ctx.currentTime;
    const duration = 0.18;
    gain.gain.setValueAtTime(0, now);
    gain.gain.linearRampToValueAtTime(0.25, now + 0.01);
    gain.gain.linearRampToValueAtTime(0, now + duration);

    oscillator.connect(gain);
    gain.connect(ctx.destination);
    oscillator.start(now);
    oscillator.stop(now + duration);
  }
}

function toMilliseconds(minutes: number, seconds: number): number {
  return minutes * 60_000 + seconds * 1_000;
}

function toNonNegativeInteger(value: unknown): number {
  const parsed = Math.floor(Number(value));
  if (!Number.isFinite(parsed) || parsed < 0) {
    return 0;
  }
  return parsed;
}

/**
 * The two intervals alternate: the first beep lands after `firstMs`, the next one
 * `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the
 * beeps therefore fall on 1, 3, 4, 6, 7 ... minutes.
 */
export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number {
  if (firstMs <= 0) {
    return 0;
  }
  if (secondMs <= 0) {
    return Math.floor(elapsedMs / firstMs);
  }

  const cycleMs = firstMs + secondMs;
  const completedCycles = Math.floor(elapsedMs / cycleMs);
  const remainderMs = elapsedMs % cycleMs;
  const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0;

  return completedCycles * 2 + firstBeepOfCycleReached;
}

/** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */
export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot {
  if (secondMs <= 0) {
    return 'first';
  }
  return beepNumber % 2 === 1 ? 'first' : 'second';
}

export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] {
  if (firstMs <= 0) {
    return [];
  }

  const times: number[] = [];
  let current = 0;
  for (let beepNumber = 1; beepNumber <= count; beepNumber++) {
    const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first';
    current += isFirstSlot ? firstMs : secondMs;
    times.push(current);
  }
  return times;
}

function formatElapsed(totalMs: number): string {
  const ms = Math.floor(totalMs % 1000);
  const totalSeconds = Math.floor(totalMs / 1000);
  const seconds = totalSeconds % 60;
  const totalMinutes = Math.floor(totalSeconds / 60);
  const minutes = totalMinutes % 60;
  const hours = Math.floor(totalMinutes / 60);

  return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(ms, 3)}`;
}

function formatElapsedShort(totalMs: number): string {
  const totalSeconds = Math.floor(totalMs / 1000);
  const seconds = totalSeconds % 60;
  const totalMinutes = Math.floor(totalSeconds / 60);
  const minutes = totalMinutes % 60;
  const hours = Math.floor(totalMinutes / 60);

  if (hours > 0) {
    return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
  }
  return `${pad(minutes, 2)}:${pad(seconds, 2)}`;
}

function pad(value: number, length: number): string {
  return value.toString().padStart(length, '0');
}
