import fs from 'fs/promises';
import type { Queryable } from './db.js';
import { Queue } from 'bullmq';
import { ScannerService } from './scanner.service.js';

const MUSIC_DIR = process.env.MUSIC_DIR || '/mnt/hdd1/media/Music';

// Safety rails for the missing-file pass. `MISSING` is written by an automated
// 03:00 cron and there is no automatic path back, so an unmounted /mnt/hdd1
// would otherwise flip the ENTIRE library to MISSING in one sweep.
//
// INTEGRITY_MISSING_ABORT_PCT: if more than this share of the tracks actually
// checked are unreachable, the pass writes nothing and logs an error. A genuine
// bulk deletion is rare and is better handled by an explicit re-scan than by a
// silent cron; a vanished mount is common and catastrophic.
const MISSING_ABORT_PCT = Number(process.env.INTEGRITY_MISSING_ABORT_PCT ?? 10);
// Below this many checked tracks the percentage is statistically meaningless
// (1 of 3 missing is 33%), so the threshold is not applied.
const MISSING_ABORT_MIN_SAMPLE = Number(
  process.env.INTEGRITY_MISSING_ABORT_MIN_SAMPLE ?? 20
);
// Tracks examined per sweep. The sweep is paginated by a persisted keyset
// cursor, so consecutive runs deterministically cover the whole library.
const MISSING_PAGE_SIZE = Number(process.env.INTEGRITY_MISSING_PAGE_SIZE ?? 5000);

// Detection regexes for the known (now-fixed) corruption bug. These are the
// exact patterns used by the original one-off repair script:
//   title  : trailing ' (Enriched)' (possibly stacked)
//   artist : trailing 'Unknown Artist' (concatenated, no separator, possibly stacked)
// Anchored to end-of-string; '+' handles stacked corruption.
const TITLE_CORRUPT_RE = `( \\(Enriched\\))+\\s*$`;
const ARTIST_CORRUPT_RE = `\\s*(Unknown Artist)+\\s*$`;

interface CorruptRow {
  id: string;
  path: string;
  title: string;
  artist: string;
}

interface MissingRow {
  id: string;
  path: string;
  title: string;
  artist: string;
}

export interface SweepSummary {
  detected: number;
  fixed: number;
  needsReview: number;
  /** True when a guard stopped the sweep before it wrote anything. */
  aborted?: boolean;
  /** Human-readable reason when `aborted` is set. */
  abortReason?: string;
}

export class IntegrityService {
  private scanner: ScannerService;

  constructor(private pgClient: Queryable, private queue: Queue) {
    this.scanner = new ScannerService(pgClient, queue);
  }

  /**
   * Self-provision the issues table. Idempotent (CREATE TABLE IF NOT EXISTS),
   * mirrors backend/src/db/schema.sql so the worker runs on older DBs.
   */
  async ensureSchema(): Promise<void> {
    await this.pgClient.query(
      `CREATE TABLE IF NOT EXISTS track_integrity_issues (
         id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
         track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
         issue_type TEXT NOT NULL,
         status TEXT NOT NULL DEFAULT 'OPEN',
         details TEXT,
         detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
         resolved_at TIMESTAMP,
         UNIQUE(track_id, issue_type)
       )`
    );
    // Persisted keyset cursor for the paginated missing-file pass, so the sweep
    // resumes where the previous run stopped instead of re-checking the same
    // arbitrary 5,000 rows forever.
    await this.pgClient.query(
      `CREATE TABLE IF NOT EXISTS integrity_sweep_state (
         key TEXT PRIMARY KEY,
         cursor_path TEXT,
         updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
       )`
    );
  }

  /**
   * Verify MUSIC_DIR actually looks like the mounted library before any pass
   * that treats "file not on disk" as authoritative.
   *
   * An unmounted bind leaves either a missing path or an EMPTY directory —
   * both indistinguishable, from `fs.access` on individual tracks, from "every
   * file was deleted". Requiring at least one entry catches the empty-mountpoint
   * case, which is the one that would otherwise destroy the library's state.
   */
  private async checkMusicDirLive(): Promise<{ live: boolean; reason?: string }> {
    try {
      const st = await fs.stat(MUSIC_DIR);
      if (!st.isDirectory()) {
        return { live: false, reason: `${MUSIC_DIR} exists but is not a directory` };
      }
    } catch (err) {
      return { live: false, reason: `${MUSIC_DIR} is not accessible: ${(err as Error).message}` };
    }
    try {
      const entries = await fs.readdir(MUSIC_DIR);
      if (entries.length === 0) {
        return {
          live: false,
          reason: `${MUSIC_DIR} is empty — the library bind mount is almost certainly not mounted`,
        };
      }
    } catch (err) {
      return { live: false, reason: `${MUSIC_DIR} is not readable: ${(err as Error).message}` };
    }
    return { live: true };
  }

  private async getSweepCursor(): Promise<string | null> {
    const res = await this.pgClient.query<{ cursor_path: string | null }>(
      `SELECT cursor_path FROM integrity_sweep_state WHERE key = 'missing_files'`
    );
    return res.rows[0]?.cursor_path ?? null;
  }

  private async setSweepCursor(cursor: string | null): Promise<void> {
    await this.pgClient.query(
      `INSERT INTO integrity_sweep_state (key, cursor_path, updated_at)
       VALUES ('missing_files', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET cursor_path = EXCLUDED.cursor_path, updated_at = NOW()`,
      [cursor]
    );
  }

  /** Tracks whose title or artist still carry the corruption markers. */
  async detectCorruptedMetadata(): Promise<CorruptRow[]> {
    const res = await this.pgClient.query<CorruptRow>(
      `SELECT id, path, title, artist
         FROM tracks
        WHERE title ~ $1 OR artist ~ $2`,
      [TITLE_CORRUPT_RE, ARTIST_CORRUPT_RE]
    );
    return res.rows;
  }

  /**
   * Tracks (excluding ones already flagged DELETED) whose file is gone from disk.
   *
   * Paginated by keyset on `path` (UNIQUE, hence a total order): each call takes
   * the next `limit` rows after `afterPath`. Callers persist the returned
   * `nextCursor` so successive sweeps walk the whole library and wrap around,
   * instead of re-checking an arbitrary unordered `LIMIT 5000` forever.
   *
   * Returns the checked count too, so the caller can apply a ratio guard.
   */
  async detectMissingFiles(
    limit = MISSING_PAGE_SIZE,
    afterPath: string | null = null
  ): Promise<{ missing: MissingRow[]; checked: number; nextCursor: string | null }> {
    const res = await this.pgClient.query<MissingRow>(
      `SELECT id, path, title, artist
         FROM tracks
        WHERE state <> 'DELETED'
          AND ($2::text IS NULL OR path > $2)
        ORDER BY path
        LIMIT $1`,
      [limit, afterPath]
    );

    const missing: MissingRow[] = [];
    for (const row of res.rows) {
      try {
        await fs.access(row.path);
      } catch {
        missing.push(row);
      }
    }

    // Short page (or empty) means we reached the end: wrap to the start so the
    // next sweep begins a fresh cycle.
    const nextCursor =
      res.rows.length < limit ? null : res.rows[res.rows.length - 1].path;

    return { missing, checked: res.rows.length, nextCursor };
  }

  /** Upsert an issue row keyed on (track_id, issue_type), refreshing on re-detection. */
  private async upsertIssue(
    trackId: string,
    issueType: 'CORRUPT_METADATA' | 'MISSING_FILE',
    status: 'OPEN' | 'FIXED' | 'NEEDS_REVIEW',
    details: string | null
  ): Promise<void> {
    await this.pgClient.query(
      `INSERT INTO track_integrity_issues (track_id, issue_type, status, details, detected_at, resolved_at)
       VALUES ($1, $2, $3, $4, NOW(), CASE WHEN $3 = 'FIXED' THEN NOW() ELSE NULL END)
       ON CONFLICT (track_id, issue_type) DO UPDATE SET
         status = EXCLUDED.status,
         details = EXCLUDED.details,
         detected_at = NOW(),
         resolved_at = CASE WHEN EXCLUDED.status = 'FIXED' THEN NOW() ELSE NULL END`,
      [trackId, issueType, status, details]
    );
  }

  /**
   * The defensive SQL strip (PASS 2 from the original repair script). Anchored,
   * idempotent, only touches rows that actually match — a no-op on clean data.
   */
  private async stripCorruptionMarkers(): Promise<void> {
    await this.pgClient.query(
      `UPDATE tracks
          SET title = btrim(regexp_replace(title, '( \\(Enriched\\))+\\s*$', ''))
        WHERE title ~ '( \\(Enriched\\))+\\s*$' OR title <> btrim(title)`
    );
    await this.pgClient.query(
      `UPDATE tracks
          SET artist = btrim(regexp_replace(artist, '\\s*(Unknown Artist)+\\s*$', ''))
        WHERE artist ~ '\\s*(Unknown Artist)+\\s*$' OR artist <> btrim(artist)`
    );
  }

  /**
   * Orchestrate a full integrity sweep: detect, attempt auto-repair, then
   * reconcile issue rows. Safe to run repeatedly; a clean DB is a no-op and
   * produces no spurious issue rows.
   */
  async runSweep(): Promise<SweepSummary> {
    await this.ensureSchema();

    const summary: SweepSummary = { detected: 0, fixed: 0, needsReview: 0 };

    // --- Liveness guard ------------------------------------------------------
    // Both passes treat the filesystem as authoritative (PASS 1 re-scans it,
    // the missing-file pass writes state='MISSING' from it). If the library
    // mount is gone, every conclusion the sweep draws is wrong and there is no
    // automatic way back, so refuse to run at all.
    const live = await this.checkMusicDirLive();
    if (!live.live) {
      summary.aborted = true;
      summary.abortReason = live.reason;
      console.error(
        `[Integrity] ABORTING sweep: music library not available — ${live.reason}. ` +
          `No tracks were examined and nothing was written.`
      );
      return summary;
    }

    // --- Corrupted metadata --------------------------------------------------
    const corrupt = await this.detectCorruptedMetadata();
    summary.detected = corrupt.length;

    // Record each corrupt row as an OPEN issue (only real matches get rows).
    for (const row of corrupt) {
      const details = `corrupt title="${row.title}" artist="${row.artist}"`;
      await this.upsertIssue(row.id, 'CORRUPT_METADATA', 'OPEN', details);
    }

    if (corrupt.length > 0) {
      // PASS 1: authoritative re-scan from disk overwrites title/artist by path.
      console.log(`[Integrity] PASS 1: re-scanning library at ${MUSIC_DIR}`);
      try {
        await this.scanner.scanDirectory(MUSIC_DIR);
      } catch (err) {
        console.error('[Integrity] PASS 1 rescan failed (continuing to PASS 2):', err);
      }
      // PASS 2: defensive SQL strip for rows the scanner couldn't fix.
      console.log('[Integrity] PASS 2: defensive SQL strip of corruption markers');
      await this.stripCorruptionMarkers();

      // Reconcile: re-check each previously-corrupt row individually.
      const stillCorrupt = await this.pgClient.query<{ id: string }>(
        `SELECT id FROM tracks
          WHERE id = ANY($1::uuid[]) AND (title ~ $2 OR artist ~ $3)`,
        [corrupt.map((r) => r.id), TITLE_CORRUPT_RE, ARTIST_CORRUPT_RE]
      );
      const stillCorruptIds = new Set(stillCorrupt.rows.map((r) => r.id));

      for (const row of corrupt) {
        if (stillCorruptIds.has(row.id)) {
          // Could not auto-fix (e.g. file missing) -> flag for manual review.
          await this.upsertIssue(
            row.id,
            'CORRUPT_METADATA',
            'NEEDS_REVIEW',
            `could not auto-repair; corrupt title="${row.title}" artist="${row.artist}"`
          );
          summary.needsReview++;
        } else {
          await this.upsertIssue(row.id, 'CORRUPT_METADATA', 'FIXED', null);
          summary.fixed++;
        }
      }
    }

    // --- Missing files -------------------------------------------------------
    const cursor = await this.getSweepCursor();
    const { missing, checked, nextCursor } = await this.detectMissingFiles(
      MISSING_PAGE_SIZE,
      cursor
    );

    // Ratio guard: a large fraction of the page being unreachable means the
    // filesystem, not the library, changed (partially-mounted disk, permission
    // loss, a moved music root). Write nothing and leave the cursor untouched so
    // the same page is re-examined once the cause is fixed.
    const missingPct = checked > 0 ? (missing.length / checked) * 100 : 0;
    if (
      checked >= MISSING_ABORT_MIN_SAMPLE &&
      missingPct > MISSING_ABORT_PCT
    ) {
      summary.aborted = true;
      summary.abortReason =
        `${missing.length}/${checked} checked tracks unreachable ` +
        `(${missingPct.toFixed(1)}% > ${MISSING_ABORT_PCT}% threshold)`;
      console.error(
        `[Integrity] ABORTING missing-file pass: ${summary.abortReason}. ` +
          `Nothing was marked MISSING and the sweep cursor was not advanced. ` +
          `Verify ${MUSIC_DIR} is fully mounted, then re-run; raise ` +
          `INTEGRITY_MISSING_ABORT_PCT only if this bulk removal is genuine.`
      );
      return summary;
    }

    for (const row of missing) {
      summary.detected++;
      summary.needsReview++;
      await this.upsertIssue(
        row.id,
        'MISSING_FILE',
        'NEEDS_REVIEW',
        `file not found on disk: ${row.path}`
      );
      // No-ghost rule: mark the track MISSING rather than deleting/keeping it as
      // playable. Guarded UPDATE keeps this idempotent (only flips non-MISSING rows).
      await this.pgClient.query(
        `UPDATE tracks SET state = 'MISSING' WHERE id = $1 AND state <> 'MISSING'`,
        [row.id]
      );
    }

    await this.setSweepCursor(nextCursor);

    console.log(
      `[Integrity] Sweep summary: detected=${summary.detected} fixed=${summary.fixed} ` +
        `needsReview=${summary.needsReview} checked=${checked} missing=${missing.length} ` +
        `nextCursor=${nextCursor === null ? '<start>' : nextCursor}`
    );
    return summary;
  }
}
