const state = {
  activeTool: 'ask',
  authenticated: Boolean(window.DBN_TOOLS_AUTHENTICATED),
};

const REDACT_I18N = {
  en: {
    redactEngine: 'Engine',
    redactEngineAzureMini: 'Azure gpt-4o-mini',
    redactEngineAzureFull: 'Azure gpt-4o',
    redactEngineGpu: 'GPU (cuttlefish)',
    redactEngineRegex: 'Regex only',
    redactEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy. Regex-only is instant and free but finds no names or organisations.',
    redactMode: 'Mode',
    redactModeStandard: 'Standard',
    redactModeStrict: 'Strict',
    redactModeHint: 'Standard: regex patterns + LLM scan for names/orgs/places. Strict: also replaces any capitalised two-word phrase as a potential name — more aggressive, may produce false positives.',
    redactRegion: 'Region',
    redactRegionNordic: 'Nordic',
    redactRegionEuropean: 'European',
    redactRegionEchr: 'ECHR',
    redactRegionGlobal: 'Global',
    redactRegionHint: 'Nordic: Norwegian fødselsnummer, phone, email, addresses. European: adds IBAN, SE personnummer, UK NI. ECHR: adds application numbers, DOB phrases. Global: adds US SSN, document numbers.',
    redactEntities: 'Redact',
    redactEntityNames: 'Names',
    redactEntityOrgs: 'Organisations',
    redactEntityPlaces: 'Places',
    redactEntityDob: 'Dates',
    redactOfficials: 'Officials',
    redactKeepOfficials: 'Keep official names (judges, experts)',
    redactOfficialsHint: 'When checked, judges, expert witnesses and caseworkers keep their names in a labelled tag: [JUDGE: Andersen]. Uncheck to replace all names with generic role tags.',
    redactOutput: 'Output',
    redactOutputContextual: 'Contextual tags',
    redactOutputGeneric: 'Generic tags',
    redactOutputPseudo: 'Pseudonyms',
    redactOutputHint: 'Contextual: each person gets a role tag so their identity is traceable within the document. Generic: all names become [PERSON]. Pseudonyms: replaced with plausible fake Norwegian values.',
    redactExempt: 'Exempt names',
    redactExemptAdd: 'Add',
    redactExemptHint: 'Names listed here will never be redacted, even if the AI would otherwise remove them — e.g. a judge or expert who must remain identifiable.',
    redactExemptPlaceholder: 'Name to keep (e.g. Judge Andersen)',
    redactAliases: 'Name aliases',
    redactAliasAdd: 'Add',
    redactAliasHint: 'Replace a specific name with a custom bracketed label, e.g. "David Jr" → [Junior].',
    redactUploadAria: 'File upload',
    redactUploadDrop: 'Drop up to 5 files here, or',
    redactUploadBrowse: 'browse',
    redactUploadHint: 'text extracted in memory, never stored',
    redactUploadClear: '× Clear',
    redactInputLabel: 'Pasted text',
    redactInputPlaceholder: 'Paste text containing names, phone numbers, emails, addresses, or national ID numbers.',
    redactRun: 'Run',
    redactRunning: 'Redacting…',
    redactReadyTitle: 'Ready',
    redactReadyDesc: 'Paste text or upload a file, configure redaction options, then run.',
    redactAdvancedToggle: 'Advanced settings',
    redactDownloadTxt: 'Download .txt',
    redactDownloadDocx: 'Download .docx',
    redactCopy: 'Copy',
    redactCopied: 'Copied!',
  },
  no: {
    redactEngine: 'Motor',
    redactEngineAzureMini: 'Azure gpt-4o-mini',
    redactEngineAzureFull: 'Azure gpt-4o',
    redactEngineGpu: 'GPU (cuttlefish)',
    redactEngineRegex: 'Kun regex',
    redactEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy. Kun regex er øyeblikkelig og gratis, men finner ingen navn eller organisasjoner.',
    redactMode: 'Modus',
    redactModeStandard: 'Standard',
    redactModeStrict: 'Strikt',
    redactModeHint: 'Standard: regex-mønstre + LLM-skanning for navn/org/steder. Strikt: erstatter også enhver stor-stav-kombinasjon som potensielt navn — mer aggressivt, kan gi falske positiver.',
    redactRegion: 'Region',
    redactRegionNordic: 'Nordisk',
    redactRegionEuropean: 'Europeisk',
    redactRegionEchr: 'EMD',
    redactRegionGlobal: 'Global',
    redactRegionHint: 'Nordisk: norsk fødselsnummer, telefon, e-post, adresser. Europeisk: legger til IBAN, SE personnummer, UK NI. EMD: legger til saksnummer, fødselsdatofraser. Global: legger til US SSN, dokumentnummer.',
    redactEntities: 'Rediger',
    redactEntityNames: 'Navn',
    redactEntityOrgs: 'Organisasjoner',
    redactEntityPlaces: 'Steder',
    redactEntityDob: 'Datoer',
    redactOfficials: 'Offisielle',
    redactKeepOfficials: 'Behold offisielle navn (dommere, sakkyndige)',
    redactOfficialsHint: 'Når avkrysset beholder dommere, sakkyndige og saksbehandlere sine navn i en merket tagg: [DOMMER: Andersen]. Fjern haken for å erstatte alle navn med generiske rolletaggar.',
    redactOutput: 'Utdata',
    redactOutputContextual: 'Kontekstuelle taggar',
    redactOutputGeneric: 'Generiske taggar',
    redactOutputPseudo: 'Pseudonymer',
    redactOutputHint: 'Kontekstuell: hver person får en rolletagg slik at identiteten kan spores i dokumentet. Generisk: alle navn blir [PERSON]. Pseudonymer: erstattes med troverdige falske norske verdier.',
    redactExempt: 'Unntak',
    redactExemptAdd: 'Legg til',
    redactExemptHint: 'Navn oppført her vil aldri bli redigert, selv om AI ellers ville fjernet dem — f.eks. en dommer eller sakkyndig som må forbli identifiserbar.',
    redactExemptPlaceholder: 'Navn som skal beholdes (f.eks. Dommer Andersen)',
    redactAliases: 'Navnealiaser',
    redactAliasAdd: 'Legg til',
    redactAliasHint: 'Erstatt et spesifikt navn med en egendefinert merkelapp, f.eks. «David Jr» → [Junior].',
    redactUploadAria: 'Filopplasting',
    redactUploadDrop: 'Slipp opptil 5 filer her, eller',
    redactUploadBrowse: 'bla',
    redactUploadHint: 'tekst hentes i minnet, lagres aldri',
    redactUploadClear: '× Tøm',
    redactInputLabel: 'Limt inn tekst',
    redactInputPlaceholder: 'Lim inn tekst med navn, telefonnummer, e-poster, adresser eller personnummer.',
    redactRun: 'Kjør',
    redactRunning: 'Redigerer…',
    redactReadyTitle: 'Klar',
    redactReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer redigeringsalternativene, og kjør.',
    redactAdvancedToggle: 'Avanserte innstillinger',
    redactDownloadTxt: 'Last ned .txt',
    redactDownloadDocx: 'Last ned .docx',
    redactCopy: 'Kopier',
    redactCopied: 'Kopiert!',
  },
  uk: {
    redactEngine: 'Рушій',
    redactEngineAzureMini: 'Azure gpt-4o-mini',
    redactEngineAzureFull: 'Azure gpt-4o',
    redactEngineGpu: 'GPU (cuttlefish)',
    redactEngineRegex: 'Лише регулярні вирази',
    redactEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM. Лише regex — миттєво і безкоштовно, але не знаходить імен або організацій.',
    redactMode: 'Режим',
    redactModeStandard: 'Стандартний',
    redactModeStrict: 'Суворий',
    redactModeHint: 'Стандарт: шаблони regex + LLM-сканування для імен/орг/місць. Суворий: також замінює будь-яку комбінацію слів з великої літери як потенційне ім\'я.',
    redactRegion: 'Регіон',
    redactRegionNordic: 'Nordisk',
    redactRegionEuropean: 'Європейський',
    redactRegionEchr: 'ЄСПЛ',
    redactRegionGlobal: 'Глобальний',
    redactRegionHint: 'Nordisk: норвезький фødselsnummer, телефон, email, адреси. Європейський: додає IBAN, SE personnummer, UK NI. ЄСПЛ: додає номери справ, фрази дати народження. Глобальний: додає US SSN.',
    redactEntities: 'Редагувати',
    redactEntityNames: 'Імена',
    redactEntityOrgs: 'Організації',
    redactEntityPlaces: 'Місця',
    redactEntityDob: 'Дати',
    redactOfficials: 'Офіційні особи',
    redactKeepOfficials: 'Зберігати офіційні імена (судді, експерти)',
    redactOfficialsHint: 'Якщо позначено, судді, експерти та соціальні працівники зберігають свої імена у позначеному тезі: [СУДДЯ: Andersen].',
    redactOutput: 'Вивід',
    redactOutputContextual: 'Контекстні теги',
    redactOutputGeneric: 'Загальні теги',
    redactOutputPseudo: 'Псевдоніми',
    redactOutputHint: 'Контекстний: кожна особа отримує тег ролі. Загальний: всі імена стають [PERSON]. Псевдоніми: замінюються правдоподібними норвезькими значеннями.',
    redactExempt: 'Виключені імена',
    redactExemptAdd: 'Додати',
    redactExemptHint: 'Імена, перелічені тут, ніколи не будуть відредаговані.',
    redactExemptPlaceholder: 'Ім\'я для збереження (напр. суддя Andersen)',
    redactAliases: 'Псевдоніми імен',
    redactAliasAdd: 'Додати',
    redactAliasHint: 'Замініть конкретне ім\'я на власну мітку, напр. «David Jr» → [Junior].',
    redactUploadAria: 'Завантаження файлів',
    redactUploadDrop: 'Перетягніть до 5 файлів сюди, або',
    redactUploadBrowse: 'огляд',
    redactUploadHint: 'текст обробляється в пам\'яті, ніколи не зберігається',
    redactUploadClear: '× Очистити',
    redactInputLabel: 'Вставлений текст',
    redactInputPlaceholder: 'Вставте текст з іменами, телефонами, адресами або ідентифікаційними номерами.',
    redactRun: 'Запустити',
    redactRunning: 'Редагування…',
    redactReadyTitle: 'Готово',
    redactReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
    redactAdvancedToggle: 'Розширені налаштування',
    redactDownloadTxt: 'Завантажити .txt',
    redactDownloadDocx: 'Завантажити .docx',
    redactCopy: 'Копіювати',
    redactCopied: 'Скопійовано!',
  },
  pl: {
    redactEngine: 'Silnik',
    redactEngineAzureMini: 'Azure gpt-4o-mini',
    redactEngineAzureFull: 'Azure gpt-4o',
    redactEngineGpu: 'GPU (cuttlefish)',
    redactEngineRegex: 'Tylko regex',
    redactEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM. Tylko regex jest natychmiastowy i bezpłatny, ale nie znajdzie imion ani organizacji.',
    redactMode: 'Tryb',
    redactModeStandard: 'Standardowy',
    redactModeStrict: 'Ścisły',
    redactModeHint: 'Standardowy: wzorce regex + skanowanie LLM dla imion/org/miejsc. Ścisły: zastępuje też każdą kombinację słów pisanych wielką literą jako potencjalne imię.',
    redactRegion: 'Region',
    redactRegionNordic: 'Nordycki',
    redactRegionEuropean: 'Europejski',
    redactRegionEchr: 'ETPC',
    redactRegionGlobal: 'Globalny',
    redactRegionHint: 'Nordycki: norweski fødselsnummer, telefon, email, adresy. Europejski: dodaje IBAN, SE personnummer, UK NI. ETPC: dodaje numery spraw, frazy daty urodzenia. Globalny: dodaje US SSN.',
    redactEntities: 'Redaguj',
    redactEntityNames: 'Imiona',
    redactEntityOrgs: 'Organizacje',
    redactEntityPlaces: 'Miejsca',
    redactEntityDob: 'Daty',
    redactOfficials: 'Urzędnicy',
    redactKeepOfficials: 'Zachowaj oficjalne nazwy (sędziowie, eksperci)',
    redactOfficialsHint: 'Gdy zaznaczone, sędziowie, biegli i pracownicy socjalni zachowują swoje nazwiska w oznaczonym tagu: [SĘDZIA: Andersen].',
    redactOutput: 'Wyjście',
    redactOutputContextual: 'Tagi kontekstowe',
    redactOutputGeneric: 'Tagi ogólne',
    redactOutputPseudo: 'Pseudonimy',
    redactOutputHint: 'Kontekstowe: każda osoba otrzymuje tag roli. Ogólne: wszystkie imiona stają się [PERSON]. Pseudonimy: zastąpione wiarygodnymi fałszywymi wartościami norweskimi.',
    redactExempt: 'Zwolnione nazwy',
    redactExemptAdd: 'Dodaj',
    redactExemptHint: 'Nazwy tu wpisane nigdy nie zostaną zredagowane.',
    redactExemptPlaceholder: 'Nazwa do zachowania (np. Sędzia Andersen)',
    redactAliases: 'Aliasy nazw',
    redactAliasAdd: 'Dodaj',
    redactAliasHint: 'Zastąp konkretną nazwę własną etykietą, np. «David Jr» → [Junior].',
    redactUploadAria: 'Przesyłanie pliku',
    redactUploadDrop: 'Upuść do 5 plików tutaj lub',
    redactUploadBrowse: 'przeglądaj',
    redactUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany',
    redactUploadClear: '× Wyczyść',
    redactInputLabel: 'Wklejony tekst',
    redactInputPlaceholder: 'Wklej tekst zawierający imiona, numery telefonów, adresy lub numery identyfikacyjne.',
    redactRun: 'Uruchom',
    redactRunning: 'Redagowanie…',
    redactReadyTitle: 'Gotowe',
    redactReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje redakcji, uruchom.',
    redactAdvancedToggle: 'Ustawienia zaawansowane',
    redactDownloadTxt: 'Pobierz .txt',
    redactDownloadDocx: 'Pobierz .docx',
    redactCopy: 'Kopiuj',
    redactCopied: 'Skopiowano!',
  },
};

const TIMELINE_I18N = {
  en: {
    timelineEngine: 'Engine',
    timelineEngineAzureMini: 'Azure gpt-4o-mini',
    timelineEngineAzureFull: 'Azure gpt-4o',
    timelineEngineGpu: 'GPU (cuttlefish)',
    timelineEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.',
    timelineAdvancedToggle: 'Advanced settings',
    timelineFocus: 'Focus',
    timelineFocusAll: 'All events',
    timelineFocusDeadlines: 'Legal deadlines',
    timelineFocusHearings: 'Court hearings',
    timelineFocusCps: 'CPS milestones',
    timelineFocusHint: 'All events: every temporal reference. Legal deadlines: filing dates, appeal windows, statutory limits. Court hearings: tribunal and mediation sessions. CPS milestones: Barnevernet interventions and Fylkesnemnda proceedings.',
    timelineConfidence: 'Confidence',
    timelineConfidenceAll: 'Show all events',
    timelineConfidenceHighMed: 'Hide low-confidence',
    timelineConfidenceHint: 'Show all: includes uncertain events (shown in grey). Hide low-confidence: only returns events the model is reasonably sure of.',
    timelineDates: 'Date types',
    timelineIncludeRelative: 'Include relative / recurring dates',
    timelineDatesHint: 'When checked, relative references ("three weeks later", "every Monday") are included. Uncheck to return only exact calendar dates.',
    timelineUploadAria: 'File upload',
    timelineUploadDrop: 'Drop up to 5 files here, or',
    timelineUploadBrowse: 'browse',
    timelineUploadHint: 'text extracted in memory, never stored',
    timelineUploadClear: '× Clear',
    timelineInputLabel: 'Pasted text',
    timelineInputPlaceholder: 'Paste case notes, court decisions, or correspondence containing dates and events.',
    timelineRun: 'Run',
    timelineRunning: 'Building timeline…',
    timelineReadyTitle: 'Ready',
    timelineReadyDesc: 'Paste text or upload a file, configure options, then run.',
    timelineBackground: 'Background events',
    timelineIncludeBackground: 'Include narrative / background dates',
    timelineBackgroundHint: 'When checked, historical context dates are included (e.g. "born 30.07.2015", "met around 2011/2012"). Uncheck to extract only operational events and deadlines.',
    sortDocOrder: 'Document order',
    sortChronological: 'Chronological',
    timelineExportCsv: 'Download CSV',
    timelineNotesLabel: 'Context notes',
    timelineNotesPlaceholder: 'Add any clarifications to guide the AI — e.g. "All dates are 2024", "Focus on the mother\'s actions", "D refers to the defendant throughout".',
    timelineNotesHint: 'These notes are included in the prompt to help the model interpret ambiguous dates, actors, or abbreviations. Not stored.',
  },
  no: {
    timelineEngine: 'Motor',
    timelineEngineAzureMini: 'Azure gpt-4o-mini',
    timelineEngineAzureFull: 'Azure gpt-4o',
    timelineEngineGpu: 'GPU (cuttlefish)',
    timelineEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy på cuttlefish.',
    timelineAdvancedToggle: 'Avanserte innstillinger',
    timelineFocus: 'Fokus',
    timelineFocusAll: 'Alle hendelser',
    timelineFocusDeadlines: 'Juridiske frister',
    timelineFocusHearings: 'Rettsmøter',
    timelineFocusCps: 'Barnevernet-milepæler',
    timelineFocusHint: 'Alle hendelser: alle temporale referanser. Juridiske frister: innleveringsfrister, ankefrister, lovpålagte frister. Rettsmøter: domstol- og meklingsmøter. Barnevernet: akuttvedtak og Fylkesnemnda-saker.',
    timelineConfidence: 'Sikkerhet',
    timelineConfidenceAll: 'Vis alle hendelser',
    timelineConfidenceHighMed: 'Skjul usikre hendelser',
    timelineConfidenceHint: 'Vis alle: inkluderer usikre hendelser (vist i grått). Skjul usikre: returnerer bare hendelser modellen er rimelig sikker på.',
    timelineDates: 'Datotyper',
    timelineIncludeRelative: 'Inkluder relative / gjentakende datoer',
    timelineDatesHint: 'Når avkrysset inkluderes relative referanser ("tre uker senere", "hver mandag"). Fjern haken for å returnere kun eksakte kalenderdatoer.',
    timelineUploadAria: 'Filopplasting',
    timelineUploadDrop: 'Slipp opptil 5 filer her, eller',
    timelineUploadBrowse: 'bla',
    timelineUploadHint: 'tekst hentes i minnet, lagres aldri',
    timelineUploadClear: '× Tøm',
    timelineInputLabel: 'Limt inn tekst',
    timelineInputPlaceholder: 'Lim inn saksdokumenter, rettsavgjørelser eller korrespondanse med datoer og hendelser.',
    timelineRun: 'Kjør',
    timelineRunning: 'Bygger tidslinje…',
    timelineReadyTitle: 'Klar',
    timelineReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer alternativene, og kjør.',
    timelineBackground: 'Bakgrunnshendelser',
    timelineIncludeBackground: 'Inkluder narrative / bakgrunnsdatoer',
    timelineBackgroundHint: 'Når avkrysset inkluderes historiske kontekstdatoer (f.eks. "født 30.07.2015", "møttes rundt 2011/2012"). Fjern haken for å hente kun operasjonelle hendelser og frister.',
    sortDocOrder: 'Dokumentrekkefølge',
    sortChronological: 'Kronologisk',
    timelineExportCsv: 'Last ned CSV',
    timelineNotesLabel: 'Kontekstnotes',
    timelineNotesPlaceholder: 'Legg til avklaringer for å veilede AI-en — f.eks. "Alle datoer er 2024", "Fokuser på morens handlinger", "D refererer til saksøkte gjennom hele dokumentet".',
    timelineNotesHint: 'Disse notatene inkluderes i ledeteksten for å hjelpe modellen med å tolke uklare datoer, aktører eller forkortelser. Lagres ikke.',
  },
  uk: {
    timelineEngine: 'Рушій',
    timelineEngineAzureMini: 'Azure gpt-4o-mini',
    timelineEngineAzureFull: 'Azure gpt-4o',
    timelineEngineGpu: 'GPU (cuttlefish)',
    timelineEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM на cuttlefish.',
    timelineAdvancedToggle: 'Розширені налаштування',
    timelineFocus: 'Фокус',
    timelineFocusAll: 'Всі події',
    timelineFocusDeadlines: 'Юридичні терміни',
    timelineFocusHearings: 'Судові засідання',
    timelineFocusCps: 'Вехи органів опіки',
    timelineFocusHint: 'Всі події: кожне часове посилання. Юридичні терміни: дати подання, вікна апеляції. Судові засідання: трибунали та медіації. Опіка: втручання Barnevernet та провадження Fylkesnemnda.',
    timelineConfidence: 'Достовірність',
    timelineConfidenceAll: 'Показати всі події',
    timelineConfidenceHighMed: 'Приховати малодостовірні',
    timelineConfidenceHint: 'Показати всі: включає непевні події (сірим). Приховати малодостовірні: повертає лише впевнено визначені події.',
    timelineDates: 'Типи дат',
    timelineIncludeRelative: 'Включити відносні / повторювані дати',
    timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.',
    timelineUploadAria: 'Завантаження файлів',
    timelineUploadDrop: 'Перетягніть до 5 файлів сюди, або',
    timelineUploadBrowse: 'огляд',
    timelineUploadHint: 'текст обробляється в памʼяті, ніколи не зберігається',
    timelineUploadClear: '× Очистити',
    timelineInputLabel: 'Вставлений текст',
    timelineInputPlaceholder: 'Вставте нотатки справи, судові рішення або кореспонденцію з датами та подіями.',
    timelineRun: 'Запустити',
    timelineRunning: 'Будую хронологію…',
    timelineReadyTitle: 'Готово',
    timelineReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
    timelineBackground: 'Фонові події',
    timelineIncludeBackground: 'Включити наративні / фонові дати',
    timelineBackgroundHint: 'Якщо позначено, включаються дати з контексту (напр. "народився 30.07.2015", "зустрілися близько 2011/2012"). Зніміть, щоб витягувати лише операційні події.',
    sortDocOrder: 'Порядок документа',
    sortChronological: 'Хронологічний',
    timelineExportCsv: 'Завантажити CSV',
    timelineNotesLabel: 'Контекстні нотатки',
    timelineNotesPlaceholder: 'Додайте пояснення для ШІ — напр. "Усі дати відносяться до 2024 року", "Зосередьтесь на діях матері", "D — відповідач по всьому документу".',
    timelineNotesHint: 'Ці нотатки включаються до запиту, щоб допомогти моделі інтерпретувати неоднозначні дати, учасників або скорочення. Не зберігаються.',
  },
  pl: {
    timelineEngine: 'Silnik',
    timelineEngineAzureMini: 'Azure gpt-4o-mini',
    timelineEngineAzureFull: 'Azure gpt-4o',
    timelineEngineGpu: 'GPU (cuttlefish)',
    timelineEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM na cuttlefish.',
    timelineAdvancedToggle: 'Ustawienia zaawansowane',
    timelineFocus: 'Fokus',
    timelineFocusAll: 'Wszystkie zdarzenia',
    timelineFocusDeadlines: 'Terminy prawne',
    timelineFocusHearings: 'Rozprawy sądowe',
    timelineFocusCps: 'Kamienie milowe OPS',
    timelineFocusHint: 'Wszystkie: każde odniesienie czasowe. Terminy: daty złożenia, okna apelacyjne. Rozprawy: trybunały i mediacje. OPS: interwencje Barnevernet i postępowania Fylkesnemnda.',
    timelineConfidence: 'Pewność',
    timelineConfidenceAll: 'Pokaż wszystkie zdarzenia',
    timelineConfidenceHighMed: 'Ukryj mało pewne',
    timelineConfidenceHint: 'Pokaż wszystkie: zawiera niepewne zdarzenia (szarym). Ukryj mało pewne: zwraca tylko zdarzenia o rozsądnej pewności.',
    timelineDates: 'Typy dat',
    timelineIncludeRelative: 'Uwzględnij daty względne / cykliczne',
    timelineDatesHint: 'Gdy zaznaczone, odniesienia względne ("trzy tygodnie później", "co poniedziałek") są uwzględniane. Odznacz, aby zwracać tylko dokładne daty kalendarzowe.',
    timelineUploadAria: 'Przesyłanie pliku',
    timelineUploadDrop: 'Upuść do 5 plików tutaj lub',
    timelineUploadBrowse: 'przeglądaj',
    timelineUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany',
    timelineUploadClear: '× Wyczyść',
    timelineInputLabel: 'Wklejony tekst',
    timelineInputPlaceholder: 'Wklej notatki sprawy, decyzje sądowe lub korespondencję z datami i zdarzeniami.',
    timelineRun: 'Uruchom',
    timelineRunning: 'Tworzę oś czasu…',
    timelineReadyTitle: 'Gotowe',
    timelineReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje, uruchom.',
    timelineBackground: 'Zdarzenia w tle',
    timelineIncludeBackground: 'Uwzględnij daty narracyjne / kontekstowe',
    timelineBackgroundHint: 'Gdy zaznaczone, daty z kontekstu są uwzględniane (np. "urodzony 30.07.2015", "poznali się około 2011/2012"). Odznacz, aby wyodrębniać tylko zdarzenia operacyjne.',
    sortDocOrder: 'Kolejność dokumentu',
    sortChronological: 'Chronologicznie',
    timelineExportCsv: 'Pobierz CSV',
    timelineNotesLabel: 'Notatki kontekstowe',
    timelineNotesPlaceholder: 'Dodaj wyjaśnienia dla AI — np. "Wszystkie daty dotyczą 2024", "Skup się na działaniach matki", "D odnosi się do pozwanego w całym dokumencie".',
    timelineNotesHint: 'Te notatki są dołączane do zapytania, aby pomóc modelowi interpretować niejednoznaczne daty, uczestników lub skróty. Nie są przechowywane.',
  },
};

let lastTimelineEvents = [];
let lastTimelineEventsOriginal = [];
let activeActorFilters = new Set();
let timelineSearchTerm = '';
let showSources = true;
let timelineSortMode = 'doc';
let audioQueue = [];       // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null;
let lastRedactedText   = null;
let lastOriginalText   = '';
let lastRedactPayload  = null;
let lastRunEngine      = null;

const VOCAB_PRESETS = {
  barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
  mediation: 'rettsmekling, meklingsnemnd, familievernkontor, mekling, forlik, meklingsprotokoll, foreldreplan, avtale, prosessfullmektig, advokat, dommer, vitne, sakkyndig, statsforvalter, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, tilsvar, stevning, kjennelse, anke',
  generell:  'bokmål, nynorsk, statsforvalter, kommunen, forvaltning, klage, vedtak, rettigheter, plikter, protokoll, referat, rapport, dokumentasjon, velferd',
  custom:    '',
};

let uiLang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';

function syncOutputLanguage(lang) {
  const normalized = ['en', 'no', 'uk', 'pl'].includes(lang) ? lang : 'en';
  document.querySelectorAll('input[name="language"]').forEach((input) => {
    if (input.value === normalized) input.checked = true;
  });
}

const TRANSCRIBE_I18N = {
  en: {
    transcribeLang: 'Audio language',
    autoDetectHint: '(may confuse nb/da/sv)',
    speakers: 'Speakers',
    identifySpeakers: 'Identify speakers',
    speakersCount: 'Count',
    speakersPlaceholder: 'auto',
    speakersAriaLabel: 'Expected number of speakers',
    vocabulary: 'Vocabulary',
    vocabPresetChildWelfare: 'Child welfare / CPS',
    vocabPresetMediation: 'Mediation / legal meeting',
    vocabPresetGeneral: 'General Norwegian',
    vocabPresetCustom: 'Custom',
    vocabPlaceholder: 'Technical terms and names for Whisper to recognise, e.g. Barnevernet, mediation, family services…',
    vocabHint: 'Helps Whisper recognise technical terms. Not included in the transcript.',
    uploadAria: 'Audio upload',
    uploadDrop: 'Drop audio file(s) here, or',
    uploadBrowse: 'browse',
    uploadHint: 'max 200 MB per file',
    uploadAddFiles: '+ Add files',
    uploadClearQueue: '× Clear queue',
    run: 'Run',
    running: 'Transcribing…',
    runningOther: 'Running…',
    readyTitle: 'Ready',
    readyDesc: 'Select a tool, run a request, and the result appears here.',
    noFileSelected: 'Select at least one audio file before transcribing.',
    clipLabel: (i, total) => total > 1 ? `Clip ${i}/${total}` : 'Transcribing',
    transcribeFailed: (s) => `Transcription failed (HTTP ${s}).`,
    errorLabel: (clip) => `Error – ${clip}`,
    filesSkipped: (names) => `Skipped: ${names}`,
    fileSizeExceeded: (name, mb) => `${name} (${mb} MB — max 200 MB)`,
    filesInQueue: (n) => `${n} file${n !== 1 ? 's' : ''} in queue.`,
    done: (n, dur) => n > 1 ? `Done · ${n} clips · Total audio: ${dur}` : `Done · Audio: ${dur}`,
    traceUploadLabel: (clip) => `${clip} — uploading`,
    traceUploadDetail: () => 'Sending to transcription service…',
    traceProcessingLabel: (clip) => `${clip} — transcribing`,
    traceProcessingDetail: () => 'Processing audio. Large files may take 1–3 minutes.',
    traceStillLabel: (clip) => `${clip} — still processing…`,
    traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m}m ${s}s elapsed — working through the audio.` : `${e}s elapsed — processing.`; },
    advancedOptions: 'Advanced options',
    task: 'Task',
    taskTranscribe: 'Transcribe',
    taskTranslate: 'Translate to English',
    vadFilter: 'VAD filter',
    vadFilterLabel: 'Remove silence / noise',
    vadFilterHint: 'Improves accuracy on recordings with long pauses.',
    whisperModel: 'Whisper model',
    whisperModelHint: 'Used when Azure/GCP unavailable. large-v3 is the default.',
    postModel: 'AI cleanup',
    postModelNone: 'None',
    postModelMini: 'GPT-4o Mini',
    postModelFull: 'GPT-4o',
    postModelHint: 'Fixes errors, punctuation, and domain terms after transcription.',
  },
  no: {
    transcribeLang: 'Språk i lydfil',
    autoDetectHint: '(kan forveksle nb/da/sv)',
    speakers: 'Talere',
    identifySpeakers: 'Skill ut talere',
    speakersCount: 'Antall',
    speakersPlaceholder: 'auto',
    speakersAriaLabel: 'Forventet antall talere',
    vocabulary: 'Ordliste',
    vocabPresetChildWelfare: 'Barnerett / CPS',
    vocabPresetMediation: 'Mekling / møter',
    vocabPresetGeneral: 'Generell norsk',
    vocabPresetCustom: 'Egendefinert',
    vocabPlaceholder: 'Fagord og navn Whisper skal gjenkjenne, f.eks. Barnevernet, Fylkesnemnda, mekling…',
    vocabHint: 'Hjelper Whisper gjenkjenne fagtermer. Ikke inkludert i utskriften.',
    uploadAria: 'Lydopplasting',
    uploadDrop: 'Slipp lydfil(er) her, eller',
    uploadBrowse: 'bla',
    uploadHint: 'maks 200 MB per fil',
    uploadAddFiles: '+ Legg til filer',
    uploadClearQueue: '× Tøm kø',
    run: 'Kjør',
    running: 'Transkriberer…',
    runningOther: 'Kjører…',
    readyTitle: 'Klar',
    readyDesc: 'Velg et verktøy, kjør en forespørsel, og svaret vises her.',
    noFileSelected: 'Velg minst én lydfil før transkripsjon.',
    clipLabel: (i, total) => total > 1 ? `Klipp ${i}/${total}` : 'Transkriberer',
    transcribeFailed: (s) => `Transkripsjon feilet (HTTP ${s}).`,
    errorLabel: (clip) => `Feil – ${clip}`,
    filesSkipped: (names) => `Hoppet over: ${names}`,
    fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
    filesInQueue: (n) => `${n} fil${n !== 1 ? 'er' : ''} i køen.`,
    done: (n, dur) => n > 1 ? `Ferdig · ${n} klipp · Total lyd: ${dur}` : `Ferdig · Lyd: ${dur}`,
    traceUploadLabel: (clip) => `${clip} — laster opp`,
    traceUploadDetail: () => 'Sender til transkripsjonsleverandør…',
    traceProcessingLabel: (clip) => `${clip} — transkriberer`,
    traceProcessingDetail: () => 'Behandler lyden. Store filer tar 1–3 minutter.',    traceStillLabel: (clip) => `${clip} — behandler fortsatt…`,
    traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m} min ${s}s gått — jobber gjennom lyden.` : `${e}s gått — behandler.`; },
    advancedOptions: 'Avanserte valg',
    task: 'Oppgave',
    taskTranscribe: 'Transkriber',
    taskTranslate: 'Oversett til engelsk',
    vadFilter: 'VAD-filter',
    vadFilterLabel: 'Fjern stillhet / støy',
    vadFilterHint: 'Forbedrer nøyaktigheten ved opptak med lange pauser.',
    whisperModel: 'Whisper-modell',
    whisperModelHint: 'Brukes når Azure/GCP ikke er tilgjengelig. large-v3 er standard.',
    postModel: 'AI-opprydding',
    postModelNone: 'Ingen',
    postModelMini: 'GPT-4o Mini',
    postModelFull: 'GPT-4o',
    postModelHint: 'Retter feil, tegnsetting og fagtermer etter transkripsjon.',
  },
  uk: {
    transcribeLang: 'Мова аудіо',
    autoDetectHint: '(може плутати nb/da/sv)',
    speakers: 'Мовці',
    identifySpeakers: 'Визначити мовців',
    speakersCount: 'Кількість',
    speakersPlaceholder: 'auto',
    speakersAriaLabel: 'Очікувана кількість мовців',
    vocabulary: 'Словник',
    vocabPresetChildWelfare: 'Охорона дітей / CPS',
    vocabPresetMediation: 'Медіація / зустріч',
    vocabPresetGeneral: 'Загальна норвезька',
    vocabPresetCustom: 'Власний',
    vocabPlaceholder: 'Терміни та імена для Whisper, напр. Barnevernet, медіація…',
    vocabHint: 'Допомагає Whisper розпізнати терміни. Не включається до транскрипту.',
    uploadAria: 'Завантаження аудіо',
    uploadDrop: 'Перетягніть файл(u) сюди, або',
    uploadBrowse: 'огляд',
    uploadHint: 'макс 200 МБ на файл',
    uploadAddFiles: '+ Додати файли',
    uploadClearQueue: '× Очистити чергу',
    run: 'Запустити',
    running: 'Транскрибування…',
    runningOther: 'Виконання…',
    readyTitle: 'Готово',
    readyDesc: 'Виберіть інструмент, запустіть запит — результат з\'явиться тут.',
    noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.',
    clipLabel: (i, total) => total > 1 ? `Кліп ${i}/${total}` : 'Транскрибування',
    transcribeFailed: (s) => `Транскрибування не вдалося (HTTP ${s}).`,
    errorLabel: (clip) => `Помилка – ${clip}`,
    filesSkipped: (names) => `Пропущено: ${names}`,
    fileSizeExceeded: (name, mb) => `${name} (${mb} МБ — макс 200 МБ)`,
    filesInQueue: (n) => `${n} файл${n !== 1 ? 'ів' : ''} у черзі.`,
    done: (n, dur) => n > 1 ? `Готово · ${n} кліпи · Загальне аудіо: ${dur}` : `Готово · Аудіо: ${dur}`,
    traceUploadLabel: (clip) => `${clip} — завантаження`,
    traceUploadDetail: () => 'Відправка до сервісу транскрипції…',
    traceProcessingLabel: (clip) => `${clip} — транскрибування`,
    traceProcessingDetail: () => 'Обробка аудіо. Великі файли займають 1–3 хвилини.',    traceStillLabel: (clip) => `${clip} — ще обробляється…`,
    traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; },
    advancedOptions: 'Розширені параметри',
    task: 'Завдання',
    taskTranscribe: 'Транскрибувати',
    taskTranslate: 'Перекласти на англійську',
    vadFilter: 'VAD-фільтр',
    vadFilterLabel: 'Видалити тишу / шум',
    vadFilterHint: 'Покращує точність для записів з довгими паузами.',
    whisperModel: 'Модель Whisper',
    whisperModelHint: 'Використовується, якщо Azure/GCP недоступні. large-v3 за замовчуванням.',
    postModel: 'AI-очищення',
    postModelNone: 'Без',
    postModelMini: 'GPT-4o Mini',
    postModelFull: 'GPT-4o',
    postModelHint: 'Виправляє помилки, пунктуацію та терміни після транскрипції.',
  },
  pl: {
    transcribeLang: 'Język audio',
    autoDetectHint: '(może mylić nb/da/sv)',
    speakers: 'Mówcy',
    identifySpeakers: 'Rozróżnij mówców',
    speakersCount: 'Liczba',
    speakersPlaceholder: 'auto',
    speakersAriaLabel: 'Oczekiwana liczba mówców',
    vocabulary: 'Słownik',
    vocabPresetChildWelfare: 'Opieka nad dziećmi / CPS',
    vocabPresetMediation: 'Mediacja / spotkanie',
    vocabPresetGeneral: 'Ogólny norweski',
    vocabPresetCustom: 'Własny',
    vocabPlaceholder: 'Terminy i nazwy dla Whisper, np. Barnevernet, mediacja…',
    vocabHint: 'Pomaga Whisper rozpoznać terminy. Nie jest uwzględniony w transkrypcji.',
    uploadAria: 'Prześylanie audio',
    uploadDrop: 'Upuść plik(i) audio tutaj lub',
    uploadBrowse: 'przeglądaj',
    uploadHint: 'maks 200 MB na plik',
    uploadAddFiles: '+ Dodaj pliki',
    uploadClearQueue: '× Wyczyść kolejkę',
    run: 'Uruchom',
    running: 'Transkrybowanie…',
    runningOther: 'Uruchamianie…',
    readyTitle: 'Gotowe',
    readyDesc: 'Wybierz narzędzie, uruchom żądanie — wynik pojawi się tutaj.',
    noFileSelected: 'Wybierz co najmniej jeden plik audio przed transkrypcją.',
    clipLabel: (i, total) => total > 1 ? `Klip ${i}/${total}` : 'Transkrybowanie',
    transcribeFailed: (s) => `Transkrypcja nie powiodła się (HTTP ${s}).`,
    errorLabel: (clip) => `Błąd – ${clip}`,
    filesSkipped: (names) => `Pominięto: ${names}`,
    fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
    filesInQueue: (n) => `${n} plik${n !== 1 ? 'i' : ''} w kolejce.`,
    done: (n, dur) => n > 1 ? `Gotowe · ${n} klipy · Łączne audio: ${dur}` : `Gotowe · Audio: ${dur}`,
    traceUploadLabel: (clip) => `${clip} — przesyłanie`,
    traceUploadDetail: () => 'Wysyłanie do serwisu transkrypcji…',
    traceProcessingLabel: (clip) => `${clip} — transkrybowanie`,
    traceProcessingDetail: () => 'Przetwarzanie audio. Duże pliki zajmują 1–3 minuty.',    traceStillLabel: (clip) => `${clip} — nadal przetwarza…`,
    traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Minęło ${m} min ${s} s — przetwarzanie audio.` : `Minęło ${e} s — przetwarzanie.`; },
    advancedOptions: 'Opcje zaawansowane',
    task: 'Zadanie',
    taskTranscribe: 'Transkrypcja',
    taskTranslate: 'Przetłumacz na angielski',
    vadFilter: 'Filtr VAD',
    vadFilterLabel: 'Usuń ciszę / szum',
    vadFilterHint: 'Poprawia dokładność nagrań z długimi przerwami.',
    whisperModel: 'Model Whisper',
    whisperModelHint: 'Używany gdy Azure/GCP niedostępne. large-v3 jest domyślny.',
    postModel: 'Korekta AI',
    postModelNone: 'Brak',
    postModelMini: 'GPT-4o Mini',
    postModelFull: 'GPT-4o',
    postModelHint: 'Poprawia błędy, interpunkcję i terminy po transkrypcji.',
  },
};

function currentUiT(key, ...args) {
  const t = TRANSCRIBE_I18N[uiLang] || TRANSCRIBE_I18N.en;
  const val = (key in t) ? t[key] : TRANSCRIBE_I18N.en[key];
  if (typeof val === 'function') return val(...args);
  return val ?? key;
}

function applyTranscribeI18n(lang) {
  uiLang = lang;
  localStorage.setItem('dbn-ui-lang', lang);
  syncOutputLanguage(lang);
  document.querySelectorAll('[data-i18n]').forEach((el) => {
    const text = currentUiT(el.dataset.i18n);
    if (text != null) el.textContent = text;
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    const text = currentUiT(el.dataset.i18nPlaceholder);
    if (text != null) el.placeholder = text;
  });
  document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
    const text = currentUiT(el.dataset.i18nAria);
    if (text != null) el.setAttribute('aria-label', text);
  });
  document.querySelectorAll('.lang-btn').forEach((btn) => {
    btn.classList.toggle('is-active', btn.dataset.lang === lang);
  });
}

function currentRedactT(key) {
  const t = REDACT_I18N[uiLang] || REDACT_I18N.en;
  return (key in t) ? t[key] : (REDACT_I18N.en[key] ?? key);
}

function applyRedactI18n(lang) {
  uiLang = lang;
  localStorage.setItem('dbn-ui-lang', lang);
  syncOutputLanguage(lang);
  document.querySelectorAll('[data-i18n]').forEach((el) => {
    const text = currentRedactT(el.dataset.i18n);
    if (text != null) el.textContent = text;
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    const text = currentRedactT(el.dataset.i18nPlaceholder);
    if (text != null) el.placeholder = text;
  });
  document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
    const text = currentRedactT(el.dataset.i18nAria);
    if (text != null) el.setAttribute('aria-label', text);
  });
  document.querySelectorAll('#redactLangSwitcher .lang-btn').forEach((btn) => {
    btn.classList.toggle('is-active', btn.dataset.lang === lang);
  });
}

function currentRedactEngine() {
  return document.querySelector('input[name="redactEngine"]:checked')?.value || 'azure_mini';
}

function currentOutputFormat() {
  return document.querySelector('input[name="outputFormat"]:checked')?.value || 'contextual';
}

function currentKeepOfficials() {
  return document.getElementById('keepOfficialsCheck')?.checked ?? false;
}

function currentRedactTypes() {
  return {
    names:  document.getElementById('redactNames')?.checked  ?? true,
    orgs:   document.getElementById('redactOrgs')?.checked   ?? true,
    places: document.getElementById('redactPlaces')?.checked ?? true,
    dob:    document.getElementById('redactDob')?.checked     ?? true,
  };
}

function setupRedactControls() {
  const switcher = document.getElementById('redactLangSwitcher');
  if (!switcher) return;
  switcher.querySelectorAll('.lang-btn').forEach((btn) => {
    btn.addEventListener('click', () => applyRedactI18n(btn.dataset.lang));
  });
  applyRedactI18n(uiLang);
}

function currentTimelineT(key) {
  const t = TIMELINE_I18N[uiLang] || TIMELINE_I18N.en;
  return (key in t) ? t[key] : (TIMELINE_I18N.en[key] ?? key);
}

function applyTimelineI18n(lang) {
  uiLang = lang;
  localStorage.setItem('dbn-ui-lang', lang);
  syncOutputLanguage(lang);
  document.querySelectorAll('[data-i18n]').forEach((el) => {
    const text = currentTimelineT(el.dataset.i18n);
    if (text != null) el.textContent = text;
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    const text = currentTimelineT(el.dataset.i18nPlaceholder);
    if (text != null) el.placeholder = text;
  });
  document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
    const text = currentTimelineT(el.dataset.i18nAria);
    if (text != null) el.setAttribute('aria-label', text);
  });
  document.querySelectorAll('#timelineLangSwitcher .lang-btn').forEach((btn) => {
    btn.classList.toggle('is-active', btn.dataset.lang === lang);
  });
}

function currentTimelineEngine() {
  return document.querySelector('input[name="timelineEngine"]:checked')?.value || 'azure_mini';
}

function currentTimelineFocus() {
  return document.querySelector('input[name="timelineFocus"]:checked')?.value || 'all';
}

function currentConfidenceFilter() {
  return document.querySelector('input[name="confidenceFilter"]:checked')?.value || 'all';
}

function currentIncludeRelative() {
  return document.getElementById('includeRelativeCheck')?.checked ?? true;
}

function currentIncludeBackground() {
  return document.getElementById('includeBackgroundCheck')?.checked ?? true;
}

function sortChronological(events) {
  const isIso = (d) => /^\d{4}-\d{2}/.test(d);
  return [...events].sort((a, b) => {
    const da = a.date || '', db = b.date || '';
    if (isIso(da) && isIso(db)) return da.localeCompare(db);
    if (isIso(da)) return -1;
    if (isIso(db)) return 1;
    return 0;
  });
}

function setupTimelineControls() {
  const switcher = document.getElementById('timelineLangSwitcher');
  if (!switcher) return;
  switcher.querySelectorAll('.lang-btn').forEach((btn) => {
    btn.addEventListener('click', () => applyTimelineI18n(btn.dataset.lang));
  });
  applyTimelineI18n(uiLang);
}

function setupExemptNames() {
  const addBtn = document.getElementById('addExemptRow');
  const rows   = document.getElementById('exemptRows');
  if (!addBtn || !rows) return;

  addBtn.addEventListener('click', () => {
    const row = document.createElement('div');
    row.className = 'exempt-row';
    row.innerHTML = [
      `<input type="text" class="exempt-name-input" placeholder="${escapeHtml(currentRedactT('redactExemptPlaceholder'))}" maxlength="100">`,
      '<button type="button" class="alias-remove" aria-label="Remove exempt name">×</button>',
    ].join('');
    rows.appendChild(row);
    row.querySelector('.exempt-name-input').focus();
  });

  rows.addEventListener('click', (e) => {
    const btn = e.target.closest('.alias-remove');
    if (btn) btn.closest('.exempt-row').remove();
  });
}

function getExemptNames() {
  return Array.from(document.querySelectorAll('#exemptRows .exempt-name-input'))
    .map((el) => el.value.trim())
    .filter(Boolean);
}

const tools = {
  ask: {
    kind: 'Source-grounded Legal Ask',
    title: 'Ask a legal question',
    label: 'Question',
    endpoint: 'api/ask.php',
    payloadKey: 'question',
    placeholder: 'Example: What evidence is needed before asking for changes in custody arrangements?',
    usesLanguage: true,
    badge: 'family-legal',
  },
  search: {
    kind: 'Legal Source Search',
    title: 'Search legal sources',
    label: 'Search query',
    endpoint: 'api/search.php',
    payloadKey: 'query',
    placeholder: 'Example: barnets beste samvær foreldreansvar',
    usesLanguage: true,
    badge: 'family-legal',
  },
  summarize: {
    kind: 'Document Summarizer',
    title: 'Summarize pasted text',
    label: 'Pasted text',
    endpoint: 'api/summarize.php',
    payloadKey: 'text',
    placeholder: 'Paste a case note, letter, or excerpt.',
    usesLanguage: true,
    badge: 'process-and-forget',
  },
  timeline: {
    kind: 'Timeline Builder',
    title: 'Build a timeline',
    label: 'Pasted text',
    endpoint: 'api/timeline.php',
    payloadKey: 'text',
    placeholder: 'Paste case notes with dates, actors, and events.',
    usesLanguage: true,
    badge: 'process-and-forget',
  },
  redact: {
    kind: 'Redaction Assistant',
    title: 'Redact sensitive details',
    label: 'Pasted text',
    endpoint: 'api/redact.php',
    payloadKey: 'text',
    placeholder: 'Paste text containing names, phone numbers, emails, addresses, or fødselsnummer-like values.',
    usesLanguage: false,
    badge: 'deterministic first',
  },
  transcribe: {
    kind: 'Audio Transcription',
    title: 'Transcribe audio',
    label: 'Audio file',
    endpoint: 'api/transcribe.php',
    payloadKey: null,
    placeholder: '',
    usesLanguage: false,
    badge: 'Whisper / GPU',
  },
};

const els = {};

document.addEventListener('DOMContentLoaded', () => {
  Object.assign(els, {
    gate: document.querySelector('#publicLanding'),
    app: document.querySelector('#appShell'),
    passcodeForm: document.querySelector('#passcodeForm'),
    loginEmail: document.querySelector('#loginEmail'),
    loginPassword: document.querySelector('#loginPassword'),
    gateStatus: document.querySelector('#gateStatus'),
    tabs: Array.from(document.querySelectorAll('.tool-tab')),
    toolKind: document.querySelector('#toolKind'),
    toolTitle: document.querySelector('#toolTitle'),
    toolBadge: document.querySelector('#toolBadge'),
    form: document.querySelector('#toolForm'),
    inputLabel: document.querySelector('#inputLabel'),
    input: document.querySelector('#toolInput'),
    languageControl: document.querySelector('#languageControl'),
    redactionControl: document.querySelector('#redactionControl'),
    status: document.querySelector('#toolStatus'),
    results: document.querySelector('#results'),
    traceList: document.querySelector('#traceList'),
    healthButton: document.querySelector('#healthButton'),
    healthPill: document.querySelector('#healthPill'),
    uploadZone: document.querySelector('#uploadZone'),
    uploadInput: document.querySelector('#uploadInput'),
    uploadPrompt: document.querySelector('#uploadPrompt'),
    uploadFileInfo: document.querySelector('#uploadFileInfo'),
    uploadFileList: document.querySelector('#uploadFileList'),
    uploadClear: document.querySelector('#uploadClear'),
    aliasSection: document.querySelector('#aliasSection'),
    addAliasRow: document.querySelector('#addAliasRow'),
    aliasRows: document.querySelector('#aliasRows'),
    audioZone: document.querySelector('#audioZone'),
    audioInput: document.querySelector('#audioInput'),
    audioPrompt: document.querySelector('#audioPrompt'),
    audioFileInfo: document.querySelector('#audioFileInfo'),
    audioQueueList: document.querySelector('#audioQueueList'),
    audioClear: document.querySelector('#audioClear'),
    diarizeControl: document.querySelector('#diarizeControl'),
    diarizeCheck: document.querySelector('#diarizeCheck'),
    numSpeakersInput: document.querySelector('#numSpeakersInput'),
    transcribeLangControl: document.querySelector('#transcribeLangControl'),
    initPromptInput: document.querySelector('#initPromptInput'),
    vocabPresets: document.querySelector('#vocabPresets'),
  });

  els.tabs.forEach((tab) => {
    if (tab.tagName !== 'A') {
      tab.addEventListener('click', () => setTool(tab.dataset.tool));
    }
  });
  els.form?.addEventListener('submit', runTool);
  els.passcodeForm?.addEventListener('submit', submitPasscode);
  els.healthButton?.addEventListener('click', checkHealth);
  setupUpload();
  setupAliases();
  setupAudio();
  setupTranscribeControls();
  setupVocabPresets();
  setupRedactControls();
  setupExemptNames();
  setupTimelineControls();
  // Wire transcribe lang switcher (only present on transcribe page)
  document.querySelectorAll('#uiLangSwitcher .lang-btn').forEach((btn) => {
    btn.addEventListener('click', () => applyTranscribeI18n(btn.dataset.lang));
  });
  if (document.getElementById('uiLangSwitcher')) {
    applyTranscribeI18n(uiLang);
  }
  els.results?.addEventListener('click', (e) => {
    if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents);
    if (e.target.closest('#txCopy'))  copyTranscriptText();
    if (e.target.closest('#dlTxt'))   downloadTranscriptTxt();
    if (e.target.closest('#dlSrt'))   downloadTranscriptSrt();
    if (e.target.closest('#dlVtt'))   downloadTranscriptVtt();
    if (e.target.closest('#rdlCopy')) copyRedactedText();
    if (e.target.closest('#rdlTxt'))  downloadRedactedTxt();
    if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
    const copyBtn = e.target.closest('.timeline-copy-btn');
    if (copyBtn) {
      navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
        const orig = copyBtn.innerHTML;
        copyBtn.innerHTML = '&#10003;';
        setTimeout(() => { copyBtn.innerHTML = orig; }, 1200);
      }).catch(() => {});
    }
    const chip = e.target.closest('.timeline-actor-chip');
    if (chip) {
      const actor = chip.dataset.actor;
      if (activeActorFilters.has(actor)) {
        activeActorFilters.delete(actor);
        chip.classList.remove('is-active');
      } else {
        activeActorFilters.add(actor);
        chip.classList.add('is-active');
      }
      applyTimelineFilters();
    }
  });
  const activeTool = document.body.dataset.activeTool || state.activeTool;
  if (els.form && tools[activeTool]) {
    setTool(activeTool);
  }

  if (state.authenticated) {
    checkHealth();
  } else {
    els.loginEmail?.focus();
  }
});

function setTool(toolName) {
  state.activeTool = toolName;
  const tool = tools[toolName];
  if (!tool || !els.toolKind || !els.input) return;
  const serverRenderedShell = els.tabs.some((tab) => tab.tagName === 'A');
  els.tabs.forEach((button) => {
    const active = button.dataset.tool === toolName;
    button.classList.toggle('is-active', active);
    button.setAttribute('aria-pressed', String(active));
  });

  if (!serverRenderedShell) {
    els.toolKind.textContent = tool.kind;
    els.toolTitle.textContent = tool.title;
    els.toolBadge.textContent = tool.badge;
    els.inputLabel.textContent = tool.label;
  }
  els.input.value = '';
  if (!serverRenderedShell) {
    els.input.placeholder = tool.placeholder;
  }
  els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
  els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact');
  els.uploadZone.classList.toggle('is-hidden', toolName !== 'redact' && toolName !== 'timeline');
  els.aliasSection.classList.toggle('is-hidden', toolName !== 'redact');
  els.audioZone.classList.toggle('is-hidden', toolName !== 'transcribe');
  els.diarizeControl.classList.toggle('is-hidden', toolName !== 'transcribe');
  els.transcribeLangControl.classList.toggle('is-hidden', toolName !== 'transcribe');
  els.input.classList.toggle('is-hidden', toolName === 'transcribe');
  els.inputLabel.classList.toggle('is-hidden', toolName === 'transcribe');
  els.input.required = toolName !== 'transcribe';
  resetUpload();
  resetAliases();
  resetAudio();
  els.status.textContent = '';
  renderTrace([]);
}

async function submitPasscode(event) {
  event.preventDefault();
  els.gateStatus.textContent = 'Signing in…';
  try {
    const data = await postJson('api/session.php', {
      email: els.loginEmail.value.trim(),
      password: els.loginPassword.value,
    });
    if (!data.ok) {
      throw new Error(data.error?.message || 'Credentials were not accepted.');
    }
    state.authenticated = true;
    if (!els.app) {
      const params = new URLSearchParams(window.location.search);
      const dest = params.get('return') || '/';
      window.location.href = dest.startsWith('/') && !dest.startsWith('//') ? dest : '/';
      return;
    }
    els.gate.classList.add('is-hidden');
    els.app.classList.remove('is-hidden');
    els.loginPassword.value = '';
    els.healthPill.textContent = 'Session active';
    checkHealth();
    els.input.focus();
  } catch (error) {
    els.gateStatus.textContent = error.message;
  }
}

async function runTool(event) {
  event.preventDefault();

  if (state.activeTool === 'transcribe') {
    await runTranscribe();
    return;
  }

  const tool = tools[state.activeTool];
  const text = els.input.value.trim();
  if (!text) {
    els.status.textContent = 'Add text before running the tool.';
    els.input.focus();
    return;
  }

  const payload = { [tool.payloadKey]: text };
  if (tool.usesLanguage) {
    payload.language = currentLanguage();
  }
  if (state.activeTool === 'search') {
    payload.limit = 7;
  }
  if (state.activeTool === 'redact') {
    lastOriginalText = text;
    payload.mode           = currentRedactionMode();
    payload.region         = currentRedactionRegion();
    payload.aliases        = getAliases();
    payload.engine         = currentRedactEngine();
    payload.output_format  = currentOutputFormat();
    payload.keep_officials = currentKeepOfficials();
    payload.exempt_names   = getExemptNames();
    payload.redact_types   = currentRedactTypes();
    lastRedactPayload = { ...payload };
  }
  if (state.activeTool === 'timeline') {
    payload.engine             = currentTimelineEngine();
    payload.focus              = currentTimelineFocus();
    payload.confidence_filter  = currentConfidenceFilter();
    payload.include_relative   = currentIncludeRelative();
    payload.include_background = currentIncludeBackground();
    payload.user_notes         = (document.getElementById('timelineNotes')?.value || '').trim();
  }

  setBusy(true);
  renderTrace([
    { label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
  ]);

  try {
    const data = await postJson(tool.endpoint, payload);
    if (!data.ok) {
      throw new Error(data.error?.message || 'Tool request failed.');
    }
    renderResults(data);
    renderTrace(data.trace || []);
    els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
  } catch (error) {
    els.status.textContent = error.message;
    renderTrace([
      { label: 'Tool error', detail: error.message, status: 'warning' },
    ]);
  } finally {
    setBusy(false);
  }
}

function resetUpload() {
  if (!els.uploadInput) return;
  els.uploadInput.value = '';
  els.uploadPrompt.classList.remove('is-hidden');
  els.uploadFileInfo.classList.add('is-hidden');
  els.uploadFileList.innerHTML = '';
  els.uploadZone.classList.remove('is-drag-over');
}

function setupUpload() {
  if (!els.uploadZone || !els.uploadInput) return;
  els.uploadZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    els.uploadZone.classList.add('is-drag-over');
  });

  els.uploadZone.addEventListener('dragleave', (e) => {
    if (!els.uploadZone.contains(e.relatedTarget)) {
      els.uploadZone.classList.remove('is-drag-over');
    }
  });

  els.uploadZone.addEventListener('drop', (e) => {
    e.preventDefault();
    els.uploadZone.classList.remove('is-drag-over');
    if (e.dataTransfer?.files?.length) handleFiles(e.dataTransfer.files);
  });

  els.uploadZone.addEventListener('click', (e) => {
    if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return;
    if (e.target.tagName === 'LABEL') return;
    els.uploadInput.click();
  });

  els.uploadInput.addEventListener('change', () => {
    if (els.uploadInput.files?.length) handleFiles(els.uploadInput.files);
  });

  els.uploadClear.addEventListener('click', () => {
    resetUpload();
    els.input.value = '';
    els.status.textContent = '';
  });
}

async function handleFiles(fileList) {
  const allowed = ['pdf', 'docx', 'txt'];
  const files = Array.from(fileList).slice(0, 5);

  for (const file of files) {
    const ext = file.name.split('.').pop().toLowerCase();
    if (!allowed.includes(ext)) {
      els.status.textContent = `Skipped ${file.name}: unsupported type. Use .pdf, .docx, or .txt.`;
      return;
    }
  }

  els.status.textContent = files.length === 1 ? `Extracting ${files[0].name}…` : `Extracting ${files.length} files…`;
  setBusy(true);

  const parts = [];
  let totalChars = 0;
  let anyTruncated = false;

  try {
    for (const file of files) {
      const formData = new FormData();
      formData.append('file', file);

      const resp = await fetch('api/extract.php', {
        method: 'POST',
        credentials: 'same-origin',
        body: formData,
      });
      const data = await resp.json().catch(() => ({}));

      if (!resp.ok || !data.ok) {
        throw new Error(data.error?.message || `Extraction failed for ${file.name} (HTTP ${resp.status}).`);
      }

      parts.push({ filename: file.name, chars: data.chars, truncated: data.truncated, text: data.text });
      totalChars += data.chars;
      if (data.truncated) anyTruncated = true;
    }

    const combined = parts.length === 1
      ? parts[0].text
      : parts.map((p) => `--- Document: ${p.filename} ---\n\n${p.text}`).join('\n\n');

    const MAX_COMBINED = 128000;
    const combinedTruncated = combined.length > MAX_COMBINED;
    els.input.value = combinedTruncated ? combined.slice(0, MAX_COMBINED) : combined;

    els.uploadFileList.innerHTML = parts
      .map((p) => `<li><span class="upload-filename">${escapeHtml(p.filename)}</span><span class="upload-chars">${p.chars.toLocaleString()} chars${p.truncated ? ' • per-file limit reached' : ''}</span></li>`)
      .join('');
    els.uploadPrompt.classList.add('is-hidden');
    els.uploadFileInfo.classList.remove('is-hidden');

    const truncNote = (anyTruncated || combinedTruncated) ? ' — truncated to 128 000 char limit' : '';
    els.status.textContent = parts.length === 1
      ? `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${truncNote}.`
      : `Extracted ${totalChars.toLocaleString()} chars total from ${parts.length} files${truncNote}.`;
  } catch (err) {
    els.status.textContent = err.message;
    resetUpload();
  } finally {
    setBusy(false);
  }
}

function setupAliases() {
  if (!els.addAliasRow || !els.aliasRows) return;
  els.addAliasRow.addEventListener('click', () => {
    const row = document.createElement('div');
    row.className = 'alias-row';
    row.innerHTML = [
      '<input type="text" class="alias-original" placeholder="Real name" maxlength="100">',
      '<span class="alias-arrow" aria-hidden="true">→</span>',
      '<input type="text" class="alias-label" placeholder="Alias (without brackets)" maxlength="100">',
      '<button type="button" class="alias-remove" aria-label="Remove alias">×</button>',
    ].join('');
    els.aliasRows.appendChild(row);
    row.querySelector('.alias-original').focus();
  });

  els.aliasRows.addEventListener('click', (e) => {
    const btn = e.target.closest('.alias-remove');
    if (btn) btn.closest('.alias-row').remove();
  });
}

function getAliases() {
  return Array.from(els.aliasRows.querySelectorAll('.alias-row')).flatMap((row) => {
    const original = row.querySelector('.alias-original')?.value.trim() ?? '';
    const alias = row.querySelector('.alias-label')?.value.trim() ?? '';
    return original && alias ? [{ original, alias }] : [];
  });
}

function resetAliases() {
  if (els.aliasRows) els.aliasRows.innerHTML = '';
}

async function checkHealth() {
  els.healthPill.textContent = 'Checking...';
  try {
    const response = await fetch('api/health.php', {
      method: 'GET',
      headers: { Accept: 'application/json' },
      credentials: 'same-origin',
    });
    const data = await response.json();
    els.healthPill.textContent = data.ok ? 'Healthy' : 'Needs config';
    els.healthPill.classList.toggle('is-warning', !data.ok);
    if (!data.ok && data.checks) {
      renderHealth(data);
    }
  } catch (error) {
    els.healthPill.textContent = 'Health failed';
    els.healthPill.classList.add('is-warning');
  }
}

async function postJson(url, payload) {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    credentials: 'same-origin',
    body: JSON.stringify(payload),
  });
  const data = await response.json().catch(() => ({}));
  if (response.status === 402 || response.status === 429) {
    dbnFreeTierError(response.status, data);
    throw new Error(data.error?.message || (response.status === 429 ? 'rate_limit' : 'no_credits'));
  }
  const remaining = response.headers.get('X-Credits-Remaining');
  if (remaining !== null) { dbnUpdateCredits(parseInt(remaining, 10)); }
  if (!response.ok) {
    throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
  }
  return data;
}

function setBusy(isBusy) {
  const button = document.querySelector('#runButton');
  if (!button) return;
  button.disabled = isBusy;
  if (state.activeTool === 'transcribe') {
    button.textContent = isBusy ? currentUiT('running') : currentUiT('run');
  } else if (state.activeTool === 'redact') {
    button.textContent = isBusy ? currentRedactT('redactRunning') : currentRedactT('redactRun');
  } else {
    button.textContent = isBusy ? currentUiT('runningOther') : currentUiT('run');
  }
}

function currentLanguage() {
  return document.querySelector('input[name="language"]:checked')?.value || 'en';
}

function currentRedactionMode() {
  return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard';
}

function currentRedactionRegion() {
  return document.querySelector('input[name="redactionRegion"]:checked')?.value || 'nordic';
}

function renderResults(data) {
  lastRunEngine = data.trace_metadata?.deployment || null;

  const sections = [];
  sections.push(sectionHtml('What We Found', renderMainFinding(data)));
  sections.push(sectionHtml('Evidence Trail', renderEvidence(data)));
  sections.push(sectionHtml('What Remains Uncertain', renderListish(data.what_remains_uncertain)));
  sections.push(sectionHtml('Next Practical Step', `<p>${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}</p>`));

  if (data.disclaimer) {
    sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`);
  }

  sections.push(renderFeedbackWidget());
  els.results.innerHTML = sections.join('');
  setupFeedbackWidget(data.tool || state.activeTool);

  if (data.tool === 'redact') setupRedactViewToggle();

  const sortDoc = document.getElementById('sortDocOrder');
  const sortChr = document.getElementById('sortChronological');
  if (sortDoc && sortChr) {
    sortDoc.addEventListener('click', () => {
      timelineSortMode = 'doc';
      sortDoc.classList.add('is-active');
      sortChr.classList.remove('is-active');
      applyTimelineFilters();
    });
    sortChr.addEventListener('click', () => {
      timelineSortMode = 'chrono';
      sortChr.classList.add('is-active');
      sortDoc.classList.remove('is-active');
      applyTimelineFilters();
    });
  }
  const searchInput = document.getElementById('timelineSearch');
  if (searchInput) {
    searchInput.addEventListener('input', () => {
      timelineSearchTerm = searchInput.value.trim().toLowerCase();
      applyTimelineFilters();
    });
  }
  const sourceToggle = document.getElementById('sourceToggle');
  if (sourceToggle) {
    sourceToggle.addEventListener('click', () => {
      showSources = !showSources;
      sourceToggle.textContent = showSources ? 'Hide sources' : 'Show sources';
      document.querySelectorAll('.timeline-excerpt').forEach((el) => {
        el.classList.toggle('is-hidden', !showSources);
      });
    });
  }
}

// ── Redact: tag colour helpers ─────────────────────────────────────────────

function tagColorClass(root) {
  if (/^(FATHER|MOTHER|CHILD|GRANDPARENT|SIBLING|ATTORNEY|JUDGE|CASEWORKER|EXPERT_WITNESS|PERSON)/.test(root)) return 'person';
  if (root === 'ORG')   return 'org';
  if (root === 'PLACE') return 'place';
  if (/^(DATE|DOB)/.test(root)) return 'date';
  return 'id';
}

function highlightRedactedText(text) {
  const escaped = escapeHtml(text);
  return escaped.replace(/\[([A-Z][A-Z0-9_-]*)(?::\s*([^\]]+))?\]/g, (_m, root, suffix) => {
    const cls     = tagColorClass(root);
    const display = suffix ? `[${root}: ${suffix}]` : `[${root}]`;
    return `<span class="redact-tag redact-tag--${cls}">${display}</span>`;
  });
}

function renderRedactionInventory(redactionMap, entityCounts) {
  const map    = redactionMap || {};
  const counts = entityCounts || {};
  const entries = [];

  for (const [tag, info] of Object.entries(map)) {
    const root = tag.replace(/^\[|\]$/g, '').split(':')[0].trim();
    entries.push({ tag, originals: info.originals || [], occurrences: info.occurrences || 0, cls: tagColorClass(root) });
  }

  const mappedTypes = new Set(Object.values(map).map(e => e.type));
  for (const [type, count] of Object.entries(counts)) {
    if (Number(count) > 0 && !mappedTypes.has(type)) {
      entries.push({ tag: type, originals: [], occurrences: Number(count), cls: tagColorClass(type.toUpperCase()) });
    }
  }

  if (!entries.length) return '';

  const rows = entries.map(e => {
    const tagSpan      = `<span class="redact-tag redact-tag--${e.cls}">${escapeHtml(e.tag)}</span>`;
    const originalsHtml = e.originals.length
      ? `<span class="inv-originals">→ ${e.originals.map(o => `<em>${escapeHtml(o)}</em>`).join(', ')}</span> `
      : '';
    const countHtml = e.occurrences > 0 ? `<span class="inv-count">${e.occurrences}×</span>` : '';
    return `<li>${tagSpan} ${originalsHtml}${countHtml}</li>`;
  }).join('');

  return `<details class="redact-inventory" open>
    <summary>Redaction inventory <span class="inv-badge">${entries.length}</span></summary>
    <ul class="inv-list">${rows}</ul>
  </details>`;
}

async function rerunWithBetterEngine() {
  if (!lastRedactPayload) return;
  const btn = document.getElementById('rerunBetterBtn');
  if (btn) { btn.disabled = true; btn.textContent = 'Running…'; }
  const payload = { ...lastRedactPayload, engine: 'azure_full' };
  lastRedactPayload = payload;
  setBusy(true);
  renderTrace([{ label: 'Re-run with gpt-4o', detail: 'Submitting same text with Azure gpt-4o engine.', status: 'running' }]);
  try {
    const data = await postJson('api/redact.php', payload);
    if (!data.ok) throw new Error(data.error?.message || 'Re-run failed.');
    renderResults(data);
    renderTrace(data.trace || []);
    els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
  } catch (error) {
    els.status.textContent = error.message;
    renderTrace([{ label: 'Tool error', detail: error.message, status: 'warning' }]);
  } finally {
    setBusy(false);
  }
}

function setupRedactViewToggle() {
  const pre         = document.getElementById('redactOutputPre');
  const btnRedacted = document.getElementById('viewRedacted');
  const btnOriginal = document.getElementById('viewOriginal');
  const rerunBtn    = document.getElementById('rerunBetterBtn');
  if (btnRedacted && pre) {
    btnRedacted.addEventListener('click', () => {
      pre.innerHTML = highlightRedactedText(lastRedactedText || '');
      btnRedacted.classList.add('is-active');
      if (btnOriginal) btnOriginal.classList.remove('is-active');
    });
  }
  if (btnOriginal && pre) {
    btnOriginal.addEventListener('click', () => {
      pre.textContent = lastOriginalText || '';
      btnOriginal.classList.add('is-active');
      if (btnRedacted) btnRedacted.classList.remove('is-active');
    });
  }
  if (rerunBtn) rerunBtn.addEventListener('click', rerunWithBetterEngine);
}

// ── Main finding renderer ───────────────────────────────────────────────────

function renderMainFinding(data) {
  if (data.tool === 'ask') {
    return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
  }
  if (data.tool === 'redact') {
    lastRedactedText = data.redacted_text || '';
    const t = (k) => currentRedactT(k) || k;

    const viewToggle = `<div class="redact-view-toggle">
      <button type="button" class="view-btn is-active" id="viewRedacted">Redacted</button>
      <button type="button" class="view-btn" id="viewOriginal">Original</button>
    </div>`;

    const inventoryHtml = renderRedactionInventory(data.redaction_map, data.entity_counts);

    const isNotBestEngine = data.engine_used && data.engine_used !== 'Azure gpt-4o' && data.engine_used !== 'gpt-4o';
    const upgradeBtn = isNotBestEngine
      ? `<button type="button" class="upgrade-engine-btn" id="rerunBetterBtn">Re-run with gpt-4o for higher accuracy →</button>`
      : '';

    const dlRow = `<div class="redact-downloads">
      <button type="button" class="redact-dl-btn" id="rdlCopy">${t('redactCopy')}</button>
      <button type="button" class="redact-dl-btn" id="rdlTxt">${t('redactDownloadTxt')}</button>
      <button type="button" class="redact-dl-btn" id="rdlDocx">${t('redactDownloadDocx')}</button>
    </div>`;

    return `${viewToggle}<pre class="redacted-output" id="redactOutputPre">${highlightRedactedText(lastRedactedText)}</pre>${inventoryHtml}${upgradeBtn}${dlRow}`;
  }
  if (data.tool === 'timeline') {
    lastTimelineEventsOriginal = data.events || [];
    lastTimelineEvents = [...lastTimelineEventsOriginal];
    activeActorFilters = new Set();
    timelineSearchTerm = '';
    showSources = true;
    timelineSortMode = 'doc';
    const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
    const csvBtn = lastTimelineEventsOriginal.length
      ? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
      : '';
    const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
    const actorChips = buildActorChips(lastTimelineEventsOriginal);
    const toolbar = buildTimelineToolbar();
    const sortBar = lastTimelineEventsOriginal.length > 1 ? `
      <div class="timeline-sort-bar">
        <span class="sort-label">Sort:</span>
        <button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
        <button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
      </div>` : '';
    return `<p>${escapeHtml(data.what_we_found || '')}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${csvBtn}`;
  }
  if (data.tool === 'summarize') {
    return [
      `<p>${escapeHtml(data.what_we_found || '')}</p>`,
      detailList('Key Facts', data.key_facts),
      detailList('Dates', data.dates),
      detailList('Parties', data.parties),
      detailList('Legal References Detected', data.legal_references_detected),
    ].join('');
  }
  if (data.tool === 'search') {
    return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
  }
  return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
}

function currentTranscribeLang() {
  return document.querySelector('input[name="transcribeLang"]:checked')?.value || 'auto';
}

function renderEvidence(data) {
  const items = data.evidence_trail || data.sources || data.hits || [];
  if (!items.length) {
    return '<p>No evidence trail was available for this request.</p>';
  }
  return `<div class="source-list">${items.map(renderEvidenceItem).join('')}</div>`;
}

function renderEvidenceItem(item) {
  const title = item.title || item.citation || 'Source';
  const body = item.excerpt || item.why_it_matters || item.citation || '';
  const chunkText = item.chunk_text || '';
  const meta = [
    item.package_or_corpus,
    item.section,
    item.score !== undefined && item.score !== null ? `score ${item.score}` : '',
  ].filter(Boolean).join(' · ');

  const chunkToggle = (chunkText && chunkText !== body) ? `
    <details class="chunk-details">
      <summary class="chunk-toggle">View chunk</summary>
      <pre class="chunk-text">${escapeHtml(chunkText)}</pre>
    </details>
  ` : '';

  return `
    <article class="source-card">
      <h4>${escapeHtml(title)}</h4>
      ${meta ? `<p class="source-meta">${escapeHtml(meta)}</p>` : ''}
      <p>${escapeHtml(body)}</p>
      ${chunkToggle}
    </article>
  `;
}

function buildTimelineCountBadge(events) {
  if (!events.length) return '';
  const actors = new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'));
  const isoDates = events.map((e) => e.date || '').filter((d) => /^\d{4}/.test(d)).sort();
  let rangeStr = '';
  if (isoDates.length) {
    const y0 = isoDates[0].slice(0, 4);
    const y1 = isoDates[isoDates.length - 1].slice(0, 4);
    rangeStr = y0 === y1 ? ` · ${y0}` : ` · ${y0}–${y1}`;
  }
  const ac = actors.size;
  return `<p class="timeline-count-badge">${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}</p>`;
}

function buildActorChips(events) {
  const actors = [...new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'))].sort();
  if (actors.length < 2) return '';
  const chips = actors.map((a) =>
    `<button type="button" class="timeline-actor-chip" data-actor="${escapeHtml(a)}">${escapeHtml(a)}</button>`
  ).join('');
  return `<div class="timeline-actor-chips" id="actorChips">${chips}</div>`;
}

function buildTimelineToolbar() {
  return `<div class="timeline-toolbar">
    <input type="search" id="timelineSearch" class="timeline-search" placeholder="Search events…" aria-label="Filter events by keyword">
    <button type="button" id="sourceToggle" class="source-toggle-btn">Hide sources</button>
  </div>`;
}

function applyTimelineFilters() {
  let events = timelineSortMode === 'chrono'
    ? sortChronological([...lastTimelineEventsOriginal])
    : [...lastTimelineEventsOriginal];
  if (activeActorFilters.size > 0) {
    events = events.filter((e) => activeActorFilters.has(e.actor));
  }
  if (timelineSearchTerm) {
    const q = timelineSearchTerm;
    events = events.filter((e) =>
      (e.event || '').toLowerCase().includes(q) ||
      (e.actor || '').toLowerCase().includes(q) ||
      (e.source_excerpt || '').toLowerCase().includes(q) ||
      (e.date || '').toLowerCase().includes(q)
    );
  }
  lastTimelineEvents = events;
  const container = document.getElementById('timelineListContainer');
  if (container) container.innerHTML = renderTimeline(events, timelineSortMode === 'chrono');
}

function renderTimeline(events, grouped = false) {
  if (!events.length) {
    return '<p class="timeline-empty">No matching events.</p>';
  }
  const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  let lastGroupKey = null;
  const items = events.map((ev) => {
    const conf = ev.confidence || 'medium';
    let groupHeader = '';
    if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) {
      const year = ev.date.slice(0, 4);
      const mon  = ev.date.slice(5, 7);
      const key  = `${year}-${mon}`;
      if (key !== lastGroupKey) {
        const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
        const label = (year !== prevYear)
          ? year
          : `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`;
        groupHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
        lastGroupKey = key;
      }
    }
    const excerptHtml = ev.source_excerpt
      ? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
      : '';
    const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
    return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}">
      <div class="timeline-header">
        <strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
        ${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
        <span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
        <button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">&#128203;</button>
      </div>
      <span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span>
      <p class="timeline-event">${escapeHtml(ev.event || '')}</p>
      ${excerptHtml}
    </li>`;
  }).join('');
  return `<ol class="timeline-list">${items}</ol>`;
}

function renderFeedbackWidget() {
  return `
    <div class="feedback-widget" id="feedbackWidget">
      <p class="feedback-label">Was this result useful?</p>
      <div class="feedback-btns" id="feedbackBtns">
        <button type="button" class="feedback-thumb" data-rating="positive" aria-label="Helpful">&#128077;</button>
        <button type="button" class="feedback-thumb" data-rating="negative" aria-label="Not helpful">&#128078;</button>
      </div>
      <div class="feedback-detail is-hidden" id="feedbackDetail">
        <label class="feedback-detail-label" for="feedbackMissed">What was missed or wrong? <span class="feedback-optional">(optional)</span></label>
        <textarea id="feedbackMissed" class="feedback-textarea" rows="3" placeholder="e.g. missed dates: 18.09.25, 6.1. — or names that should have been redacted"></textarea>
        <div class="feedback-detail-footer">
          <button type="button" id="feedbackSubmit" class="feedback-submit-btn">Submit feedback</button>
          <p id="feedbackStatus" class="feedback-status" role="status" aria-live="polite"></p>
        </div>
      </div>
    </div>
  `;
}

function setupFeedbackWidget(tool) {
  const widget   = document.getElementById('feedbackWidget');
  const detail   = document.getElementById('feedbackDetail');
  const submit   = document.getElementById('feedbackSubmit');
  const status   = document.getElementById('feedbackStatus');
  const missed   = document.getElementById('feedbackMissed');
  if (!widget) return;

  let chosenRating = null;

  widget.querySelectorAll('.feedback-thumb').forEach((btn) => {
    btn.addEventListener('click', () => {
      chosenRating = btn.dataset.rating;
      widget.querySelectorAll('.feedback-thumb').forEach((b) => b.classList.remove('is-active'));
      btn.classList.add('is-active');
      detail.classList.remove('is-hidden');
      missed.focus();
    });
  });

  submit.addEventListener('click', async () => {
    if (!chosenRating) return;
    submit.disabled = true;
    status.textContent = 'Saving…';
    try {
      const resp = await fetch('api/feedback.php', {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          tool,
          rating: chosenRating,
          missed_or_wrong: missed.value.trim(),
          engine: lastRunEngine || '',
        }),
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed');
      widget.innerHTML = '<p class="feedback-thanks">Thanks for your feedback!</p>';
    } catch (err) {
      status.textContent = err.message;
      submit.disabled = false;
    }
  });
}

function exportTimelineCSV(events) {
  const header = ['Date', 'End Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
  const rows = events.map((ev) => [
    ev.date || '', ev.end_date || '', ev.time || '', ev.date_type || '', ev.actor || '',
    ev.event || '', ev.source_excerpt || '', ev.confidence || '',
  ]);
  const csv = [header, ...rows]
    .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
    .join('\n');
  const blob = new Blob([csv], { type: 'text/csv' });
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement('a'), { href: url, download: 'timeline.csv' });
  a.click();
  URL.revokeObjectURL(url);
}

function currentTask() {
  const el = document.querySelector('input[name="task"]:checked');
  return el ? el.value : 'transcribe';
}

function buildTranscribeError(err, file) {
  const name   = file?.name ?? 'file';
  const sizeMB = file?.size ? (file.size / 1024 / 1024).toFixed(1) : null;
  let reason   = err?.message || 'Unknown error';
  if (/too large|size/i.test(reason) || reason.includes('413'))
    reason = `File too large${sizeMB ? ` (${sizeMB} MB)` : ''}`;
  else if (/format|unsupported|415/i.test(reason))
    reason = 'Unsupported audio format';
  else if (/timeout|timed out|504/i.test(reason))
    reason = 'Request timed out — try a shorter clip';
  else if (/50[0-9]/.test(reason))
    reason = 'Server error';
  return `${name}: ${reason}`;
}

function showTranscribeProgress(clip, total) {
  if (!els.results) return;
  const pct = total > 1 ? Math.round(((clip - 1) / total) * 100) : null;
  const label = total > 1 ? `Clip ${clip} / ${total}` : 'Transcribing…';
  const barClass = pct !== null ? '' : ' is-indeterminate';
  const barStyle = pct !== null ? ` style="width:${pct}%"` : '';
  els.results.innerHTML = `
    <div class="transcribe-progress-wrap">
      <div class="transcribe-progress-track">
        <div class="transcribe-progress-fill${barClass}"${barStyle}></div>
      </div>
      <p class="transcribe-progress-label">${escapeHtml(label)}</p>
    </div>`;
}

async function runTranscribe() {
  if (!audioQueue.length) {
    els.status.textContent = currentUiT('noFileSelected');
    return;
  }

  setBusy(true);

  const initPrompt    = els.initPromptInput?.value?.trim() || '';
  const diarize       = els.diarizeCheck?.checked ?? false;
  const numSpeakers   = parseInt(els.numSpeakersInput?.value || '', 10);
  const vadFilter     = document.getElementById('vadFilterCheck')?.checked ?? false;
  const total         = audioQueue.length;

  // Reset all items to pending before starting
  audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; item.errorMsg = null; });
  renderAudioQueue();

  let cumulativeOffset = 0;
  let allTranscripts   = [];
  let allSegments      = [];
  let firstSpeakerRoles = null;
  let lastResult       = null;

  for (let i = 0; i < audioQueue.length; i++) {
    const item = audioQueue[i];
    item.status = 'processing';
    renderAudioQueue();

    // Show progress bar
    showTranscribeProgress(i + 1, total);

    const startTime = Date.now();
    let elapsed = 0;
    const clipLabel = currentUiT('clipLabel', i + 1, total);
    els.status.textContent = `${clipLabel}…`;

    const timer = setInterval(() => {
      elapsed = Math.floor((Date.now() - startTime) / 1000);
      const m = Math.floor(elapsed / 60);
      const s = elapsed % 60;
      const t = m > 0 ? `${m}:${pad2(s)}` : `${s}s`;
      els.status.textContent = `${clipLabel}… ${t}`;
      updateTranscribeTrace(elapsed, clipLabel);
    }, 1000);

    try {
      const formData = new FormData();
      formData.append('audio', item.file);
      formData.append('language', currentTranscribeLang());
      formData.append('task', currentTask());
      formData.append('time_offset', String(cumulativeOffset));
      if (vadFilter) formData.append('vad_filter', '1');
      if (initPrompt) formData.append('initial_prompt', initPrompt);
      const whisperModel = document.getElementById('whisperModelSelect')?.value;
      if (whisperModel) formData.append('model', whisperModel);
      const postModel = document.querySelector('input[name="post_model"]:checked')?.value;
      if (postModel) formData.append('post_model', postModel);
      if (diarize) {
        formData.append('diarize', '1');
        if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers));
      }

      const resp = await fetch('api/transcribe.php', {
        method: 'POST',
        credentials: 'same-origin',
        body: formData,
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok || !data.ok) {
        throw new Error(data.error?.message || currentUiT('transcribeFailed', resp.status));
      }

      clearInterval(timer);
      item.status = 'done';
      item.result = data;
      lastResult  = data;

      allTranscripts.push(data.transcript || '');
      allSegments.push(...(data.segments || []));
      if (!firstSpeakerRoles && data.speaker_roles && Object.keys(data.speaker_roles).length) {
        firstSpeakerRoles = data.speaker_roles;
      }

      // Advance offset by this clip's duration (fall back to file-size estimate at 128 kbps)
      cumulativeOffset += data.duration_sec > 0
        ? data.duration_sec
        : item.file.size / (128 * 1024 / 8);

    } catch (err) {
      clearInterval(timer);
      item.status = 'error';
      item.errorMsg = buildTranscribeError(err, item.file);
      renderAudioQueue();
      // Continue processing remaining clips rather than halting
    }

    renderAudioQueue();
  }

  // Merge results from successful clips
  const errorItems = audioQueue.filter((it) => it.status === 'error');
  if (errorItems.length && !lastResult) {
    // All clips failed
    const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
    els.status.textContent = `Failed: ${errList}`;
    renderTrace([{ label: 'Transcription failed', detail: errList, status: 'warning' }]);
    setBusy(false);
    return;
  }

  const merged = {
    ...lastResult,
    transcript:    allTranscripts.join('\n\n'),
    segments:      allSegments,
    speaker_roles: firstSpeakerRoles,
    num_speakers:  lastResult?.num_speakers ?? 0,
    duration_sec:  cumulativeOffset,
  };

  lastTranscriptData = merged;
  renderTranscriptResults(merged);

  const totalSec  = Math.round(cumulativeOffset);
  const totalMin  = Math.floor(totalSec / 60);
  const remSec    = totalSec % 60;
  const durLabel  = totalMin > 0 ? `${totalMin}m ${remSec}s` : `${totalSec}s`;
  if (errorItems.length) {
    const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
    els.status.textContent = `Done (${errorItems.length} failed: ${errList})`;
  } else {
    els.status.textContent = currentUiT('done', total, durLabel);
  }
  setBusy(false);
}

function updateTranscribeTrace(elapsed, clipLabel) {
  if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1);
  let label, detail;
  if (elapsed < 10) {
    label  = currentUiT('traceUploadLabel', clipLabel);
    detail = currentUiT('traceUploadDetail');
  } else if (elapsed < 60) {
    label  = currentUiT('traceProcessingLabel', clipLabel);
    detail = currentUiT('traceProcessingDetail');
  } else {
    label  = currentUiT('traceStillLabel', clipLabel);
    detail = currentUiT('traceStillDetail', elapsed);
  }
  renderTrace([{ label, detail, status: 'running' }]);
}
function renderTranscriptResults(data) {
  const speakerRoles = data.speaker_roles || {};
  const segments = data.segments || [];
  const hasSpeakers = segments.some((s) => s.speaker);

  const speakerOrder = [...new Set(segments.filter((s) => s.speaker).map((s) => s.speaker))];

  // Speaker role tags (shown above transcript)
  const rolesHtml = speakerOrder.length
    ? `<p class="transcript-roles">${speakerOrder.map((id, i) => {
        const role = speakerRoles[id] || id;
        return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
      }).join('')}</p>`
    : '';

  // Segments panel — includes inline speaker legend at the top
  const legendHtml = speakerOrder.length
    ? `<div class="segment-legend">${speakerOrder.map((id, i) => {
        const role = speakerRoles[id] || id;
        return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
      }).join('')}</div>`
    : '';

  const segmentsHtml = hasSpeakers
    ? `<details class="segment-details"><summary class="segment-summary">Segments (${segments.length})</summary>
        ${legendHtml}
        <div class="segment-list">${segments.map((seg) => {
          const idx = speakerOrder.indexOf(seg.speaker);
          const roleLabel = seg.speaker && speakerRoles[seg.speaker]
            ? `${speakerRoles[seg.speaker]} (${seg.speaker})`
            : (seg.speaker || '');
          return `<div class="segment-row">
            <span class="segment-time">${fmtTime(seg.start)}–${fmtTime(seg.end)}</span>
            ${seg.speaker ? `<span class="speaker-tag speaker-tag--${idx >= 0 ? idx % 6 : 0}">${escapeHtml(roleLabel)}</span>` : ''}
            <span class="segment-text">${escapeHtml(seg.text)}</span>
          </div>`;
        }).join('')}</div></details>`
    : '';

  // SRT/VTT downloads (only if segments available)
  const dlSrtVtt = segments.length
    ? `<div class="transcript-downloads">
         <button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
         <button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>
       </div>`
    : '';

  // Meta stats row
  const durStr  = data.duration_sec  ? `⏱ ${fmtDuration(data.duration_sec)}`   : null;
  const langStr = data.language      ? `🌐 ${data.language.toUpperCase()}`       : null;
  const spkStr  = data.num_speakers > 1 ? `🗣 ${data.num_speakers} speakers`    : null;
  const metaParts = [durStr, langStr, spkStr].filter(Boolean);
  const metaRow = metaParts.length
    ? `<div class="transcript-meta-row">${metaParts.map((p) => `<span>${p}</span>`).join('')}</div>`
    : '';

  // AI cleanup badge inline with engine label
  const cleanupBadge = data.cleaned_by
    ? ` <span class="cleanup-badge">✓ Cleaned by ${escapeHtml(data.cleaned_by)}</span>`
    : '';
  const engineLine = data.model
    ? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong>${cleanupBadge}</p>`
    : '';

  // Action row above transcript (copy + TXT download)
  const actionRow = `
    <div class="transcript-actions">
      <button type="button" class="export-csv-btn" id="txCopy">Copy</button>
      <button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
    </div>`;

  lastRunEngine = data.engine || null;
  els.results.innerHTML = `
    <section class="result-section">
      <h3>Transcript</h3>
      ${engineLine}
      ${metaRow}
      ${rolesHtml}
      ${actionRow}
      <div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
      ${segmentsHtml}
      ${dlSrtVtt}
    </section>
    ${renderFeedbackWidget()}`;
  setupFeedbackWidget('transcribe');

  const traceMeta = [];
  if (data.duration_sec) traceMeta.push({ label: `Duration: ${fmtDuration(data.duration_sec)}`, detail: '', status: 'complete' });
  if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
  if (data.num_speakers > 1) traceMeta.push({ label: `Speakers: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
  if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
  if (data.cleaned_by) traceMeta.push({ label: `Cleaned by ${data.cleaned_by}`, detail: '', status: 'complete' });
  renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
}

function fmtDuration(secs) {
  const m = Math.floor(secs / 60);
  const s = Math.round(secs % 60);
  return m > 0 ? `${m}m ${s}s` : `${s}s`;
}

async function copyTranscriptText() {
  if (!lastTranscriptData?.transcript) return;
  const btn = document.getElementById('txCopy');
  try {
    await navigator.clipboard.writeText(lastTranscriptData.transcript);
    if (btn) {
      const orig = btn.textContent;
      btn.textContent = 'Copied!';
      setTimeout(() => { btn.textContent = orig; }, 1800);
    }
  } catch {
    // clipboard unavailable — silently ignore
  }
}

function fmtTime(secs) {
  const h = Math.floor(secs / 3600);
  const m = Math.floor((secs % 3600) / 60);
  const s = Math.floor(secs % 60);
  const parts = h > 0 ? [pad2(h), pad2(m), pad2(s)] : [pad2(m), pad2(s)];
  return parts.join(':');
}

function pad2(n) { return String(n).padStart(2, '0'); }

function toSrtTime(secs) {
  const h = Math.floor(secs / 3600);
  const m = Math.floor((secs % 3600) / 60);
  const s = Math.floor(secs % 60);
  const ms = Math.round((secs % 1) * 1000);
  return `${pad2(h)}:${pad2(m)}:${pad2(s)},${String(ms).padStart(3, '0')}`;
}

function toVttTime(secs) {
  return toSrtTime(secs).replace(',', '.');
}

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement('a'), { href: url, download: filename });
  a.click();
  URL.revokeObjectURL(url);
}

function downloadTranscriptTxt() {
  if (!lastTranscriptData) return;
  downloadBlob(new Blob([lastTranscriptData.transcript], { type: 'text/plain' }), 'transcript.txt');
}

function downloadTranscriptSrt() {
  if (!lastTranscriptData?.segments?.length) return;
  const { segments, speaker_roles: roles = {} } = lastTranscriptData;
  const lines = segments.map((seg, i) => {
    const spk = seg.speaker ? `[${roles[seg.speaker] || seg.speaker}] ` : '';
    return `${i + 1}\n${toSrtTime(seg.start)} --> ${toSrtTime(seg.end)}\n${spk}${seg.text}\n`;
  });
  downloadBlob(new Blob([lines.join('\n')], { type: 'text/srt' }), 'transcript.srt');
}

function downloadTranscriptVtt() {
  if (!lastTranscriptData?.segments?.length) return;
  const { segments, speaker_roles: roles = {} } = lastTranscriptData;
  const lines = ['WEBVTT\n'];
  segments.forEach((seg) => {
    const spk = seg.speaker ? `<v ${roles[seg.speaker] || seg.speaker}>` : '';
    lines.push(`${toVttTime(seg.start)} --> ${toVttTime(seg.end)}\n${spk}${seg.text}\n`);
  });
  downloadBlob(new Blob([lines.join('\n')], { type: 'text/vtt' }), 'transcript.vtt');
}

async function copyRedactedText() {
  if (!lastRedactedText) return;
  const btn = document.getElementById('rdlCopy');
  await navigator.clipboard.writeText(lastRedactedText);
  if (btn) {
    const orig = btn.textContent;
    btn.textContent = currentRedactT('redactCopied') || 'Copied!';
    setTimeout(() => { btn.textContent = orig; }, 1800);
  }
}

function downloadRedactedTxt() {
  if (!lastRedactedText) return;
  downloadBlob(new Blob([lastRedactedText], { type: 'text/plain' }), 'redacted.txt');
}

async function downloadRedactedDocx() {
  if (!lastRedactedText) return;
  const btn = document.getElementById('rdlDocx');
  if (btn) btn.disabled = true;
  try {
    const resp = await fetch('/api/redact-download.php', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: lastRedactedText, format: 'docx' }),
    });
    if (!resp.ok) throw new Error('Download failed');
    const blob = await resp.blob();
    downloadBlob(blob, 'redacted.docx');
  } catch (e) {
    alert(e.message);
  } finally {
    if (btn) btn.disabled = false;
  }
}

function resetAudio() {
  audioQueue = [];
  if (!els.audioInput) return;
  els.audioInput.value = '';
  if (els.audioPrompt) els.audioPrompt.classList.remove('is-hidden');
  if (els.audioFileInfo) els.audioFileInfo.classList.add('is-hidden');
  if (els.audioQueueList) els.audioQueueList.innerHTML = '';
}

function setupAudio() {
  if (!els.audioZone) return;

  els.audioZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    els.audioZone.classList.add('is-drag-over');
  });

  els.audioZone.addEventListener('dragleave', (e) => {
    if (!els.audioZone.contains(e.relatedTarget)) {
      els.audioZone.classList.remove('is-drag-over');
    }
  });

  els.audioZone.addEventListener('drop', (e) => {
    e.preventDefault();
    els.audioZone.classList.remove('is-drag-over');
    if (e.dataTransfer?.files?.length) handleAudioFiles(e.dataTransfer.files);
  });

  els.audioZone.addEventListener('click', (e) => {
    if (e.target === els.audioClear || els.audioClear?.contains(e.target)) return;
    if (e.target === els.audioInput) return;
    if (e.target.tagName === 'LABEL') return;
    if (e.target.closest('#audioFileInfo') && e.target.tagName !== 'LABEL') return;
    els.audioInput.click();
  });

  els.audioInput.addEventListener('change', () => {
    if (els.audioInput.files?.length) handleAudioFiles(els.audioInput.files);
    els.audioInput.value = '';
  });

  els.audioClear.addEventListener('click', () => {
    resetAudio();
    els.status.textContent = '';
  });
}

function setupTranscribeControls() {
  // engine auto-selected server-side
}

function setupVocabPresets() {
  if (!els.vocabPresets) return;

  const vocabCountEl = document.getElementById('vocabCharCount');
  function updateVocabCount() {
    if (!vocabCountEl || !els.initPromptInput) return;
    const n = els.initPromptInput.value.length;
    vocabCountEl.textContent = n;
    vocabCountEl.closest('small')?.classList.toggle('vocab-char-count--warn', n > 450);
  }
  els.initPromptInput?.addEventListener('input', updateVocabCount);

  els.vocabPresets.addEventListener('click', (e) => {
    const btn = e.target.closest('.vocab-btn');
    if (!btn) return;
    const preset = btn.dataset.preset;
    if (preset && els.initPromptInput) {
      els.initPromptInput.value = VOCAB_PRESETS[preset] ?? '';
      els.vocabPresets.querySelectorAll('.vocab-btn').forEach((b) => b.classList.remove('is-active'));
      btn.classList.add('is-active');
      if (preset !== 'custom') els.initPromptInput.focus();
      updateVocabCount();
    }
  });
}

function handleAudioFiles(fileList) {
  const allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac'];
  let added = 0;
  let skipped = [];

  Array.from(fileList).forEach((file) => {
    const ext = file.name.split('.').pop().toLowerCase();
    if (!allowedExts.includes(ext)) {
      skipped.push(file.name);
      return;
    }
    const sizeMB = file.size / 1024 / 1024;
    if (sizeMB > 200) {
      skipped.push(currentUiT('fileSizeExceeded', file.name, sizeMB.toFixed(1)));
      return;
    }
    audioQueue.push({ file, status: 'pending', result: null });
    added++;
  });

  if (skipped.length) {
    els.status.textContent = currentUiT('filesSkipped', skipped.join(', '));
  } else if (added > 0) {
    els.status.textContent = currentUiT('filesInQueue', audioQueue.length);
  }

  renderAudioQueue();
}

function renderAudioQueue() {
  if (!els.audioQueueList) return;

  if (!audioQueue.length) {
    els.audioPrompt.classList.remove('is-hidden');
    els.audioFileInfo.classList.add('is-hidden');
    return;
  }

  els.audioPrompt.classList.add('is-hidden');
  els.audioFileInfo.classList.remove('is-hidden');

  els.audioQueueList.innerHTML = audioQueue.map((item, i) => {
    const sizeMB = (item.file.size / 1024 / 1024).toFixed(1);
    const statusIcon = item.status === 'processing' ? '⏳'
      : item.status === 'done'       ? '✓'
      : item.status === 'error'      ? '✗'
      : `${i + 1}.`;
    const statusClass = `queue-item queue-item--${item.status}`;
    return `<li class="${statusClass}">
      <span class="queue-num">${statusIcon}</span>
      <span class="queue-name">${escapeHtml(item.file.name)}</span>
      <span class="queue-size">${sizeMB} MB</span>
    </li>`;
  }).join('');
}

function renderEntityCounts(counts = {}) {
  const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0);
  if (!entries.length) {
    return '<p class="muted">No deterministic sensitive categories detected.</p>';
  }
  return `<ul class="pill-list">${entries.map(([name, count]) => `<li>${escapeHtml(name)} <strong>${Number(count)}</strong></li>`).join('')}</ul>`;
}

function detailList(title, values = []) {
  if (!Array.isArray(values) || !values.length) {
    return '';
  }
  return `<div class="detail-block"><h4>${escapeHtml(title)}</h4><ul>${values.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul></div>`;
}

function renderListish(value) {
  if (Array.isArray(value)) {
    if (!value.length) {
      return '<p>No uncertainty listed.</p>';
    }
    return `<ul>${value.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
  }
  return `<p>${escapeHtml(value || 'No uncertainty listed.')}</p>`;
}

function sectionHtml(title, content) {
  return `<section class="result-section"><h3>${escapeHtml(title)}</h3>${content}</section>`;
}

function renderTrace(trace) {
  if (!trace.length) {
    els.traceList.innerHTML = `
      <li>
        <span class="trace-status waiting"></span>
        <div><strong>Waiting</strong><p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p></div>
      </li>
    `;
    return;
  }

  els.traceList.innerHTML = trace.map((item) => `
    <li>
      <span class="trace-status ${escapeHtml(item.status || 'complete')}"></span>
      <div>
        <strong>${escapeHtml(item.label || 'Step')}</strong>
        <p>${escapeHtml(item.detail || '')}</p>
      </div>
    </li>
  `).join('');
}

function renderHealth(data) {
  const checks = Object.entries(data.checks || {}).map(([name, check]) => ({
    label: name.replaceAll('_', ' '),
    detail: check.detail || '',
    status: check.ok ? 'complete' : 'warning',
  }));
  renderTrace(checks);
}

function escapeHtml(value) {
  return String(value ?? '')
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#039;');
}

// ── Free-tier credit badge ────────────────────────────────────────────────

let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;

function dbnUpdateCredits(balance) {
  if (typeof balance !== 'number' || balance < 0) return;
  _freeTierBalance = balance;
  const badge = document.getElementById('creditBadge');
  if (!badge) return;
  badge.textContent = '🪙 ' + balance + ' credit' + (balance !== 1 ? 's' : '');
  badge.classList.toggle('is-low', balance > 0 && balance <= 2);
  badge.classList.toggle('is-empty', balance === 0);
}

// Exposed so barnevernet.js / deep-research.js can call it
window.dbnUpdateCredits = dbnUpdateCredits;

function dbnFreeTierError(status, data) {
  if (status === 429) {
    const toast = document.createElement('div');
    toast.className = 'credit-toast';
    toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.';
    document.body.appendChild(toast);
    setTimeout(() => toast.remove(), 5000);
    return;
  }
  // 402 — no credits
  const overlay = document.createElement('div');
  overlay.className = 'credit-modal-overlay';
  overlay.innerHTML = `
    <div class="credit-modal" role="dialog" aria-modal="true" aria-labelledby="cmTitle">
      <div class="credit-modal__icon">🪙</div>
      <h3 id="cmTitle">Free credits used up</h3>
      <p>Your 10 free credits for this month have been used.<br>
         Credits reset on the 1st of next month — or upgrade to CaveauAI for unlimited access.</p>
      <div class="credit-modal__actions">
        <a href="https://dobetternorge.no/tools/" class="credit-modal__cta">Learn about CaveauAI</a>
        <button class="credit-modal__dismiss" onclick="this.closest('.credit-modal-overlay').remove()">Close</button>
      </div>
    </div>`;
  document.body.appendChild(overlay);
  overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}

// Exposed so barnevernet.js / deep-research.js can call it
window.dbnFreeTierError = dbnFreeTierError;

// Inject credit badge into topbar on page load
document.addEventListener('DOMContentLoaded', () => {
  if (_freeTierBalance < 0) return;
  const actions = document.querySelector('.topbar-actions');
  if (!actions) return;
  const badge = document.createElement('span');
  badge.id = 'creditBadge';
  badge.className = 'credit-badge';
  badge.title = 'Free monthly credits remaining';
  dbnUpdateCredits(_freeTierBalance); // set initial state before inserting
  badge.textContent = '🪙 ' + _freeTierBalance + ' credit' + (_freeTierBalance !== 1 ? 's' : '');
  badge.classList.toggle('is-low', _freeTierBalance > 0 && _freeTierBalance <= 2);
  badge.classList.toggle('is-empty', _freeTierBalance === 0);
  actions.insertBefore(badge, actions.firstChild);
});
