import type { Pool } from 'pg';
import { unlink } from 'fs/promises';
import { withTransaction } from './db.js';

const NTFY_URL = process.env.NTFY_URL || '';
const NTFY_TOPIC = process.env.NTFY_TOPIC || 'muzick';
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';

/**
 * Hard deletion is IRREVERSIBLE and destroys the owner's music files. Three
 * independent gates stand in front of it, all env-configurable, all defaulting
 * to the safe value:
 *
 *  - MUZICK_ALLOW_HARD_DELETE (default false) — the master switch. While it is
 *    off the sweep runs its full selection logic and logs exactly what it WOULD
 *    delete, but performs no unlink and makes no transition to the terminal
 *    state. This is the default mode of operation; the owner enables it by hand
 *    after reading a dry-run log. It is not set to true anywhere in the repo.
 *  - MUZICK_HARD_DELETE_MAX_PER_SWEEP (default 5) — blast-radius cap. On hitting
 *    it the sweep stops, logs at error, and leaves the remainder for next time,
 *    so a selection bug cannot empty the library in one run.
 *  - MUZICK_HARD_DELETE_GRACE_DAYS (default 7) — minimum age since the track
 *    entered HIDDEN (dislikes.hidden_at). Separate from, and much longer than,
 *    dislikes.grace_hours (default 48), which only governs HIDDEN -> WARNED.
 *
 * Note that only the worker container mounts /music read-write; the backend
 * keeps `:ro`, so nothing in the API request path can unlink a library file.
 */
function envFlag(name: string): boolean {
  const raw = (process.env[name] || '').trim().toLowerCase();
  return raw === '1' || raw === 'true' || raw === 'yes';
}

function envInt(name: string, fallback: number, min: number): number {
  const raw = (process.env[name] || '').trim();
  if (!raw) return fallback;
  const n = Number(raw);
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < min) {
    console.error(
      `[Cleanup] Ignoring invalid ${name}=${JSON.stringify(raw)}; using ${fallback}`
    );
    return fallback;
  }
  return n;
}

export const HARD_DELETE_DEFAULTS = {
  /** MUZICK_ALLOW_HARD_DELETE */
  allow: false,
  /** MUZICK_HARD_DELETE_MAX_PER_SWEEP */
  maxPerSweep: 5,
  /** MUZICK_HARD_DELETE_GRACE_DAYS */
  graceDays: 7,
} as const;

interface HardDeleteConfig {
  allow: boolean;
  maxPerSweep: number;
  graceDays: number;
}

export function readHardDeleteConfig(): HardDeleteConfig {
  return {
    // Default OFF. Anything other than an explicit truthy opt-in is a dry run.
    allow: envFlag('MUZICK_ALLOW_HARD_DELETE'),
    maxPerSweep: envInt('MUZICK_HARD_DELETE_MAX_PER_SWEEP', HARD_DELETE_DEFAULTS.maxPerSweep, 1),
    graceDays: envInt('MUZICK_HARD_DELETE_GRACE_DAYS', HARD_DELETE_DEFAULTS.graceDays, 1),
  };
}

async function sendNtfy(title: string, message: string): Promise<void> {
  if (!NTFY_URL) return;
  try {
    await fetch(`${NTFY_URL}/${NTFY_TOPIC}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, message, priority: 3 }),
    });
  } catch (err) {
    console.warn('[Cleanup] ntfy send failed:', err);
  }
}

interface Candidate {
  track_id: string;
  track_path: string;
  track_title: string | null;
  track_artist: string | null;
}

export interface SweepResult {
  warned: number;
  /** Files actually unlinked and DB rows actually deleted. Always 0 in dry-run. */
  deleted: number;
  /**
   * Eligible candidates seen. Selection stops at `maxPerSweep + 1` rows, so this
   * saturates at cap+1 — read `eligible > maxPerSweep` as "more remain".
   */
  eligible: number;
  /** True when MUZICK_ALLOW_HARD_DELETE was off, i.e. nothing was destroyed. */
  dryRun: boolean;
  /** Unlinks that failed and were left recorded in pending_file_deletions. */
  failed: number;
  /** Previously-failed/interrupted unlinks reaped at the start of this sweep. */
  reaped: number;
}

export class CleanupSweepService {
  // A Pool, not a Client: finalizeDeleted() runs a real transaction and must
  // check out a dedicated connection for it. The worker runs jobs with
  // `concurrency: 10`, so a BEGIN on a shared connection would enrol other
  // jobs' queries in this transaction and discard them on ROLLBACK.
  constructor(private pool: Pool) {}

  async runSweep(): Promise<SweepResult> {
    const config = readHardDeleteConfig();
    const warned = await this.advanceToWarned();
    const reaped = await this.reapPendingFileDeletions(config);
    const finalized = await this.finalizeDeleted(config);
    return { warned, reaped, ...finalized };
  }

  /**
   * Find HIDDEN dislikes past their grace period → send ntfy warning, mark WARNED.
   * Not gated: this transition destroys nothing.
   */
  private async advanceToWarned(): Promise<number> {
    const res = await this.pool.query<{ track_id: string; track_title: string; track_artist: string }>(
      `SELECT d.track_id, t.title AS track_title, t.artist AS track_artist
       FROM dislikes d
       JOIN tracks t ON t.id = d.track_id
       WHERE d.state = 'HIDDEN'
         AND NOW() > d.disliked_at + (d.grace_hours || ' hours')::interval`
    );

    for (const row of res.rows) {
      await this.pool.query(
        `UPDATE dislikes SET warned_at = NOW(), state = 'WARNED' WHERE track_id = $1`,
        [row.track_id]
      );
      await sendNtfy(
        'Track scheduled for deletion',
        `"${row.track_title}" by ${row.track_artist} will be permanently deleted in 24 hours. Restore it from Quarantine to keep it.`
      );
      console.log(`[Cleanup] Warned: ${row.track_title} (${row.track_id})`);
    }

    return res.rows.length;
  }

  /**
   * Retry files whose DB row is already gone but whose bytes survive — an unlink
   * that failed, or a worker that died between COMMIT and unlink. Subject to the
   * same gates as a fresh deletion.
   */
  private async reapPendingFileDeletions(config: HardDeleteConfig): Promise<number> {
    const res = await this.pool.query<{ path: string; track_id: string | null; attempts: number }>(
      `SELECT path, track_id, attempts
       FROM pending_file_deletions
       ORDER BY requested_at ASC
       LIMIT $1`,
      [config.maxPerSweep]
    );
    if (res.rows.length === 0) return 0;

    if (!config.allow) {
      for (const row of res.rows) {
        console.warn(
          `[Cleanup] DRY RUN (MUZICK_ALLOW_HARD_DELETE off): orphaned file left on disk ` +
            `after ${row.attempts} attempt(s): ${row.path}`
        );
      }
      return 0;
    }

    let reaped = 0;
    for (const row of res.rows) {
      if (await this.unlinkAndSettle(row.path, row.track_id)) reaped++;
    }
    return reaped;
  }

  /**
   * Find WARNED dislikes eligible for hard deletion and, unless dry-run, destroy
   * them: DB transaction commits FIRST, then the file is unlinked.
   *
   * The old implementation unlinked before the transaction and `continue`d on
   * failure, which is backwards on both counts: the irreversible act happened
   * before anything durable recorded it, and a failure left no trace beyond a log
   * line. Now the commit records the intent (audit row + pending_file_deletions
   * marker) and the unlink either clears the marker or annotates it for retry.
   * A crash in that window leaves the marker, which the next sweep reaps.
   */
  private async finalizeDeleted(
    config: HardDeleteConfig
  ): Promise<{ deleted: number; eligible: number; dryRun: boolean; failed: number }> {
    // Ask for one more than the cap purely to detect overflow.
    const res = await this.pool.query<Candidate>(
      `SELECT d.track_id, t.path AS track_path,
              t.title AS track_title, t.artist AS track_artist
       FROM dislikes d
       JOIN tracks t ON t.id = d.track_id
       WHERE d.state = 'WARNED'
         AND NOW() > d.warned_at + INTERVAL '24 hours'
         AND NOW() > COALESCE(d.hidden_at, d.disliked_at) + ($1 || ' days')::interval
       ORDER BY d.warned_at ASC
       LIMIT $2`,
      [config.graceDays, config.maxPerSweep + 1]
    );

    const eligible = res.rows.length;
    if (eligible === 0) {
      return { deleted: 0, eligible: 0, dryRun: !config.allow, failed: 0 };
    }

    let batch = res.rows;
    if (batch.length > config.maxPerSweep) {
      batch = batch.slice(0, config.maxPerSweep);
      console.error(
        `[Cleanup] Per-sweep deletion cap reached (${config.maxPerSweep}, ` +
          `MUZICK_HARD_DELETE_MAX_PER_SWEEP). Stopping after ${batch.length}; ` +
          `remaining eligible tracks are left for the next sweep.`
      );
    }

    if (!config.allow) {
      // Dry run: log the selection and return. No unlink, no state transition,
      // no audit row — this branch performs no writes of any kind.
      console.warn(
        `[Cleanup] DRY RUN: MUZICK_ALLOW_HARD_DELETE is off. ` +
          `${batch.length} file(s) WOULD be permanently deleted ` +
          `(grace=${config.graceDays}d, cap=${config.maxPerSweep}). Nothing was changed.`
      );
      for (const row of batch) {
        console.warn(
          `[Cleanup] DRY RUN would delete: ${row.track_path} ` +
            `("${row.track_title ?? '?'}" by ${row.track_artist ?? '?'}, track ${row.track_id})`
        );
      }
      return { deleted: 0, eligible, dryRun: true, failed: 0 };
    }

    let deleted = 0;
    let failed = 0;
    for (const row of batch) {
      try {
        // Step 1: commit the DB side. The audit row carries the denormalised
        // path/title/artist because feedback.track_id becomes NULL the moment
        // the track is deleted (ON DELETE SET NULL) — this row is the only
        // forensic record that this file ever existed.
        await withTransaction(this.pool, async (client) => {
          await client.query(
            `INSERT INTO feedback (user_id, track_id, action, track_path, track_title, track_artist)
             VALUES ($1, $2, 'deleted_permanent', $3, $4, $5)`,
            [SYSTEM_USER_ID, row.track_id, row.track_path, row.track_title, row.track_artist]
          );
          await client.query(
            `INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
             VALUES ($1, $2, $3, $4)
             ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
            [row.track_path, row.track_id, row.track_title, row.track_artist]
          );
          await client.query('DELETE FROM tracks WHERE id = $1', [row.track_id]);
        });
      } catch (err) {
        // withTransaction already rolled back and released the connection.
        // Nothing was committed and no file was touched: safe to skip.
        console.error(`[Cleanup] DB deletion failed for ${row.track_id}, file untouched:`, err);
        failed++;
        continue;
      }

      // Step 2: the irreversible part, now that it is durably recorded.
      if (await this.unlinkAndSettle(row.track_path, row.track_id)) {
        deleted++;
        console.log(`[Cleanup] Permanently deleted: ${row.track_title} (${row.track_id})`);
      } else {
        failed++;
      }
    }

    return { deleted, eligible, dryRun: false, failed };
  }

  /**
   * Unlink one file and settle its pending_file_deletions marker: clear it on
   * success (or ENOENT — the file is gone either way, which is the desired end
   * state), annotate it on any other error so the failure is durable and
   * retryable instead of swallowed. Returns true if the file is gone.
   *
   * Callers must only reach this once the DB side is committed.
   */
  private async unlinkAndSettle(path: string, trackId: string | null): Promise<boolean> {
    try {
      await unlink(path);
    } catch (err: any) {
      if (err?.code !== 'ENOENT') {
        console.error(
          `[Cleanup] Unlink FAILED for ${path} (track ${trackId ?? 'unknown'}); ` +
            `DB row already deleted, file left on disk and recorded in ` +
            `pending_file_deletions for retry:`,
          err
        );
        try {
          await this.pool.query(
            `UPDATE pending_file_deletions
             SET attempts = attempts + 1, last_attempt_at = NOW(), last_error = $2
             WHERE path = $1`,
            [path, String(err?.message || err?.code || err)]
          );
        } catch (markerErr) {
          console.error(`[Cleanup] Could not record unlink failure for ${path}:`, markerErr);
        }
        return false;
      }
    }

    try {
      await this.pool.query('DELETE FROM pending_file_deletions WHERE path = $1', [path]);
    } catch (markerErr) {
      // The file is gone; a stale marker only causes a harmless ENOENT retry.
      console.error(`[Cleanup] Could not clear deletion marker for ${path}:`, markerErr);
    }
    return true;
  }
}
