// Per-user job-search profile ("Suchprofil") + run queue.
//
// Background: the job search used to be two host-side cron scripts with the
// search criteria (seven cities, roles, buzzwords) hard-coded into the prompt —
// one user's profile, baked into the shell script. On a multi-user platform each
// user needs their own criteria, so the *what* now lives here, per user.
//
// Division of labour, forced by the deployment: the app runs in a container that
// has neither `claude` nor `ollama` (both live on the host), so the app cannot
// execute a search itself. Instead:
//
//   app (container)  ->  writes a row into `suchlaeufe` (queued run)
//   runner (host)    ->  picks queued/due runs up, executes the headless search,
//                        writes status + results back into the same table
//
// The runner talks to the tracker through the REST API using *that user's* API
// key, so an imported Jobangebot lands in the right account and goes through the
// same dedup/blacklist path as everything else.
//
// What the user configures (Städte, Modus, Zusatz-/Ausschlussbegriffe) and what
// is derived: the role titles and technology buzzwords still come from the user's
// own Lebenslauf (basis_dokumente, typ "Lebenslauf") — the profile steers *where*
// and sets guard rails, the CV supplies *what*.

const MODI = ['regional', 'remote', 'beides'];

// Weekdays as ISO numbers (1 = Monday … 7 = Sunday), matching what the UI shows.
const WOCHENTAGE = [
  { nr: 1, kurz: 'Mo' }, { nr: 2, kurz: 'Di' }, { nr: 3, kurz: 'Mi' },
  { nr: 4, kurz: 'Do' }, { nr: 5, kurz: 'Fr' }, { nr: 6, kurz: 'Sa' }, { nr: 7, kurz: 'So' },
];

const DEFAULTS = {
  aktiv: 0,
  modus: 'regional',
  staedte: [],
  berufsbezeichnungen: '',
  zusatz_begriffe: '',
  ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter',
  zeitplan_tage: '1,2,3,4,5',
  zeitplan_zeit: '17:00',
};

// Cap the city list: each city multiplies the number of web queries a run makes,
// and an unbounded list would turn one run into an all-night job.
const MAX_STAEDTE = 12;

function parseStaedte(raw) {
  if (Array.isArray(raw)) return normalizeStaedte(raw);
  if (typeof raw !== 'string' || !raw.trim()) return [];
  // Accept both the stored JSON array and free text from the form ("A, B\nC").
  if (raw.trim().startsWith('[')) {
    try { return normalizeStaedte(JSON.parse(raw)); } catch (e) { /* fall through */ }
  }
  return normalizeStaedte(raw.split(/[,;\n]/));
}

function normalizeStaedte(list) {
  const out = [];
  for (const s of list) {
    const v = String(s == null ? '' : s).trim().replace(/\s+/g, ' ');
    if (!v) continue;
    if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue; // case-insensitive dedup
    out.push(v);
    if (out.length >= MAX_STAEDTE) break;
  }
  return out;
}

function parseTage(raw) {
  const list = String(raw || '').split(',')
    .map((n) => parseInt(n, 10))
    .filter((n) => Number.isInteger(n) && n >= 1 && n <= 7);
  return [...new Set(list)].sort((a, b) => a - b);
}

// "HH:MM", clamped to a valid time; anything unparseable falls back to the default.
function parseZeit(raw) {
  const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw || '').trim());
  if (!m) return DEFAULTS.zeitplan_zeit;
  const h = Math.min(23, Math.max(0, parseInt(m[1], 10)));
  const min = Math.min(59, Math.max(0, parseInt(m[2], 10)));
  return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}

// A DB row (or nothing) -> the profile object the app and the runner work with.
// A user without a row is a valid state: they simply have no search configured.
function fromRow(row) {
  const r = row || {};
  const modus = MODI.includes(r.modus) ? r.modus : DEFAULTS.modus;
  return {
    aktiv: r.aktiv ? 1 : 0,
    modus,
    staedte: parseStaedte(r.staedte),
    berufsbezeichnungen: r.berufsbezeichnungen || '',
    zusatz_begriffe: r.zusatz_begriffe || '',
    ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse,
    zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage),
    zeitplan_zeit: parseZeit(r.zeitplan_zeit != null ? r.zeitplan_zeit : DEFAULTS.zeitplan_zeit),
  };
}

// Form body -> storable values. `sanitize` is the app's XSS sanitizer, injected so
// this module stays free of app dependencies.
function fromForm(body, sanitize = (v) => v) {
  const b = body || {};
  const modus = MODI.includes(b.modus) ? b.modus : DEFAULTS.modus;
  const tage = Array.isArray(b.tage) ? b.tage : (b.tage ? [b.tage] : []);
  return {
    aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0,
    modus,
    staedte: parseStaedte(b.staedte).map((s) => sanitize(s)),
    berufsbezeichnungen: sanitize(String(b.berufsbezeichnungen || '').trim()),
    zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()),
    ausschluesse: sanitize(String(b.ausschluesse || '').trim()),
    zeitplan_tage: parseTage(tage.join(',')),
    zeitplan_zeit: parseZeit(b.zeitplan_zeit),
  };
}

// Values as they go into the DB (arrays serialized).
function toRow(profil) {
  return {
    aktiv: profil.aktiv ? 1 : 0,
    modus: profil.modus,
    staedte: JSON.stringify(profil.staedte || []),
    berufsbezeichnungen: profil.berufsbezeichnungen || '',
    zusatz_begriffe: profil.zusatz_begriffe || '',
    ausschluesse: profil.ausschluesse || '',
    zeitplan_tage: (profil.zeitplan_tage || []).join(','),
    zeitplan_zeit: profil.zeitplan_zeit || DEFAULTS.zeitplan_zeit,
  };
}

// Is the profile usable for a run? A regional search without a single city would
// otherwise send the model off to search "everywhere", which is exactly the
// unbounded run we want to avoid.
function validate(profil) {
  const fehler = [];
  if (!MODI.includes(profil.modus)) fehler.push('Ungültiger Modus.');
  if (profil.modus !== 'remote' && !(profil.staedte || []).length) {
    fehler.push('Für die regionale Suche muss mindestens eine Stadt angegeben sein.');
  }
  if (profil.aktiv && !(profil.zeitplan_tage || []).length) {
    fehler.push('Für den Zeitplan muss mindestens ein Wochentag gewählt sein.');
  }
  return fehler;
}

// Is a scheduled run due? `letzterLauf` is the ISO timestamp of the last run that
// the schedule triggered (manual runs do not count, so a manual test does not
// swallow the day's scheduled run). Due means: today is one of the chosen days,
// the configured time has passed, and no scheduled run has happened since that
// time today.
function istFaellig(profil, jetzt, letzterLauf) {
  if (!profil.aktiv) return false;
  const tage = profil.zeitplan_tage || [];
  if (!tage.length) return false;

  const iso = jetzt.getDay() === 0 ? 7 : jetzt.getDay(); // JS: 0=So -> ISO 7
  if (!tage.includes(iso)) return false;

  const [h, m] = profil.zeitplan_zeit.split(':').map(Number);
  const faelligAb = new Date(jetzt);
  faelligAb.setHours(h, m, 0, 0);
  if (jetzt < faelligAb) return false;               // today's slot not reached yet

  if (!letzterLauf) return true;
  const letzte = new Date(letzterLauf);
  if (Number.isNaN(letzte.getTime())) return true;
  return letzte < faelligAb;                          // already ran for this slot?
}

// Build the instruction the headless search agent gets. Everything user-specific
// is interpolated here, so the skill itself no longer carries anyone's cities.
// `profilText` is the user's CV/profile text (from their Lebenslauf template) —
// the roles and technologies to search for are derived from it, not configured.
function buildPrompt(profil, profilText) {
  const teile = [];

  teile.push(
    'Führe eine IT-/Fach-Stellensuche vollständig autonom im Hintergrund aus, ohne jede Rückfrage. ' +
    'Nutze dafür den Stellensuche-Skill (Quellen-Politik, Dedup, Blacklist, Volltext-Beschreibung).'
  );

  if (profil.modus === 'regional') {
    teile.push(
      `Suche AUSSCHLIESSLICH Stellen mit Arbeitsort in diesen Städten: ${profil.staedte.join(', ')} ` +
      `(Priorität in dieser Reihenfolge, die erste ist der Wohnort). Stellen außerhalb dieser Städte ` +
      `verwerfen — auch das übrige Umland und reines Homeoffice/Remote ohne Sitz in einer dieser Städte. ` +
      `Sitzt der Arbeitgeber in einer dieser Städte und bietet zusätzlich Homeoffice, ist die Stelle zulässig. ` +
      `Setze IMMER labels=["Regional"].`
    );
  } else if (profil.modus === 'remote') {
    teile.push(
      'Suche AUSSCHLIESSLICH Stellen, die 100 % REMOTE / vollständig im Homeoffice und deutschlandweit ' +
      'ortsunabhängig ausübbar sind (Anstellung in Deutschland). Firmensitz egal. Verwirf Präsenz-, Hybrid- ' +
      'und ortsgebundene Stellen sowie vages „Homeoffice möglich" ohne klare 100-%-Remote-Zusage; bestätige ' +
      'die Remote-Regelung live im Anzeigentext per WebFetch. Setze IMMER labels=["Remote"].'
    );
  } else {
    teile.push(
      `Suche in ZWEI Durchgängen: (1) regional mit Arbeitsort ausschließlich in ${profil.staedte.join(', ')} ` +
      `(Priorität in dieser Reihenfolge) — diese Treffer mit labels=["Regional"]; (2) deutschlandweit Stellen, ` +
      `die 100 % REMOTE / vollständig im Homeoffice ausübbar sind (live per WebFetch bestätigen) — diese ` +
      `Treffer mit labels=["Remote"]. Alles, was in keinen der beiden Durchgänge fällt, verwerfen.`
    );
  }

  // Die Berufsbezeichnungen sind die eigentlichen Suchanfragen — sie entscheiden,
  // welche Anzeigen überhaupt gefunden werden. Sind sie gesetzt, sucht der Agent
  // danach, statt sich die Titel bei jedem Lauf neu aus dem Lebenslauf zu
  // improvisieren (was der Nutzer weder sieht noch steuern kann). Das Profil
  // liefert dann nur noch die Technologien zur Gewichtung.
  if (profil.berufsbezeichnungen) {
    teile.push(
      `Suche gezielt nach Stellen mit diesen Berufsbezeichnungen (das sind deine Suchanfragen, jede einzeln ` +
      `abfragen): ${profil.berufsbezeichnungen}. Nahe Schreibweisen und gängige Varianten desselben Titels ` +
      `(m/w/d, "IT-" davor, Singular/Plural, englische Entsprechung) darfst du ergänzen; auf fachfremde Rollen ` +
      `ausweichen darfst du NICHT.`
    );
  }

  if (profilText && profilText.trim()) {
    teile.push(
      (profil.berufsbezeichnungen
        ? 'Technologie-Buzzwords und die Gewichtung der Treffer leitest du AUS DEM FOLGENDEN PROFIL DES ' +
          'BEWERBERS ab (die Suchtitel stehen oben und gelten) — gewichte Treffer höher, je mehr aus dem ' +
          'Profil passt:'
        : 'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' +
          'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' +
          'gewichte Treffer höher, je mehr aus dem Profil passt:') +
      '\n\n--- PROFIL ---\n' +
      profilText.trim().slice(0, 6000) +
      '\n--- ENDE PROFIL ---'
    );
  } else if (profil.berufsbezeichnungen) {
    teile.push(
      'Es ist kein Lebenslauf/Profil hinterlegt — halte dich strikt an die oben genannten Berufsbezeichnungen.'
    );
  } else {
    teile.push(
      'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' +
      'erfassten Bewerbungen des Benutzers ab (GET /applications) und halte die Suche eng an deren Stellentiteln.'
    );
  }

  if (profil.zusatz_begriffe) {
    teile.push(`Zusätzlich ausdrücklich berücksichtigen: ${profil.zusatz_begriffe}.`);
  }
  if (profil.ausschluesse) {
    teile.push(`Ausschlusskriterien — solche Treffer verwerfen: ${profil.ausschluesse}.`);
  }

  teile.push(
    'Fokussiere auf Original-Stellenanzeigen echter Arbeitgeber, bevorzugt direkt auf deren Karriereseite ' +
    '(Firmen recherchieren, Karriereseite per WebFetch prüfen) — nicht nur breit ausgeschriebene Board-Anzeigen.'
  );

  teile.push(
    'Pflege JEDEN neuen, live per WebFetch verifizierten Treffer sofort selbst als Jobangebot ein ' +
    '(POST /joboffers, external_id DETERMINISTISCH aus firma+stelle mit Präfix "stellensuche-", NICHT aus einer ' +
    'flüchtigen Board-ID, damit der Server-Upsert statt einer Dublette greift; art=Firmenwebsite wo möglich; die ' +
    'VOLLSTÄNDIGE Stellenausschreibung als beschreibung; kontakt_email nur wenn belegt). Beachte die Dedup gegen ' +
    'bereits erfasste Bewerbungen/Jobangebote und die Blacklist. Sperre jeden echten Neuimport (action:created) ' +
    'danach sofort per POST /joboffers/blacklist (typ=firma_stelle, ort falls bekannt); bei 409 oder bloßem ' +
    'updated NICHT blacklisten und NICHT mit force erzwingen.'
  );

  teile.push(
    'Stelle am Ende KEINE Frage. Gib als LETZTE Zeile exakt diese Bilanz aus (nur Zahlen):\n' +
    'ERGEBNIS neu=<X> dubletten=<Y> verworfen=<Z>'
  );

  return teile.join('\n\n');
}

// The runner reports back "ERGEBNIS neu=1 dubletten=2 verworfen=3"; pull the
// numbers out of whatever the agent printed. Missing counts read as 0.
function parseErgebnis(text) {
  const m = /ERGEBNIS\s+neu=(\d+)\s+dubletten=(\d+)\s+verworfen=(\d+)/i.exec(String(text || ''));
  if (!m) return null;
  return { neu: Number(m[1]), dubletten: Number(m[2]), verworfen: Number(m[3]) };
}

module.exports = {
  MODI, WOCHENTAGE, DEFAULTS, MAX_STAEDTE,
  fromRow, fromForm, toRow, validate, istFaellig, buildPrompt, parseErgebnis,
  parseStaedte, parseTage, parseZeit,
};
