#import "FloatingClockPanel+Runtime.h"
#import "FloatingClockPanel+Layout.h"
#import "../data/ThemeCatalog.h"
#import "../data/MarketCatalog.h"
#import "../data/MarketSessionCalculator.h"
#import "../content/ActiveSegmentContentBuilder.h"
#import "../content/NextSegmentContentBuilder.h"
#import "../content/WeekProgressBar.h"      // iter-229: weekly progress on LOCAL
#import "../content/UrgencyColors.h"        // iter-233: FCProgressEmptyColor for week bar
#import "../data/MoonPhase.h"               // iter-243: offline moon phase
#import "../rendering/FontResolver.h"  // FCCurrentTimeFormat
#import "DateFormatPrefix.h"              // FCDateFormatPrefix
#import "SkyGlyph.h"                       // FCSkyGlyphForHour

// Nanoseconds until next second boundary. Used by setupTimer's first fire.
static uint64_t nsUntilNextSecond(void) {
    NSTimeInterval t = [[NSDate date] timeIntervalSince1970];
    double frac = t - floor(t);
    return (uint64_t)((1.0 - frac) * NSEC_PER_SEC);
}

// v4 iter-111 / iter-113: date-format preset dispatcher lives in
// Sources/core/DateFormatPrefix.{h,m} now — FCDateFormatPrefix(id)
// is the public entry point. Runtime.m's 2 call sites below route
// through it.

// v4 iter-98 / iter-107: TimeSeparator helpers live in FontResolver.{h,m}
// now — `FCCurrentTimeFormat(is12h, showSec)` is the public entry point.
// Kept the 3 call sites below intact; they just route through the
// public function instead of the old file-scoped static helpers.

@implementation FloatingClockPanel (Runtime)

- (void)setupTimer {
    dispatch_queue_t q = dispatch_get_main_queue();
    _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, q);
    dispatch_source_set_timer(_timer,
        dispatch_time(DISPATCH_TIME_NOW, nsUntilNextSecond()),
        NSEC_PER_SEC,
        (uint64_t)(NSEC_PER_SEC / 10));  // 100ms leeway for power savings
    dispatch_source_set_event_handler(_timer, ^{ [self tick]; });
    dispatch_resume(_timer);
    [self tick];  // immediate paint so window isn't blank
}

- (void)tick {
    NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
    NSString *mode = [d stringForKey:@"DisplayMode"];
    // v4 iter-235 bug fix: when SegmentsCollapsed=YES, applyLocalOnlyLayout
    // hides _localSeg/_activeSeg/_nextSeg and shows _label. tick() must
    // route to tickLegacy in that case so _label gets populated — otherwise
    // the canvas shows a blank gray box (the bug user reported).
    BOOL collapsed = [d boolForKey:@"SegmentsCollapsed"];
    if ([mode isEqualToString:@"three-segment"] && !collapsed) {
        [self tickThreeSegment];
    } else {
        [self tickLegacy];
    }
}

- (void)tickThreeSegment {
    NSMutableString *fmt = [NSMutableString string];
    NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
    BOOL showDate = [d boolForKey:@"ShowDate"];
    NSString *tf = [d stringForKey:@"TimeFormat"];

    // v4 iter-46: honor ShowSeconds pref. Prior comment said "always
    // shown" but the menu toggle still existed and users could check/
    // uncheck it with no effect. Now it really strips the ":ss" portion
    // — less jitter, less clutter for minimalist setups.
    BOOL showSec = [d boolForKey:@"ShowSeconds"];
    if (showDate) [fmt appendString:FCDateFormatPrefix([d stringForKey:@"DateFormat"])];
    // v4 iter-98: separator chosen via FCCurrentTimeFormat (was hardcoded `:`).
    [fmt appendString:FCCurrentTimeFormat([tf isEqualToString:@"12h"], showSec)];

    if (!_dateFormatter) _dateFormatter = [[NSDateFormatter alloc] init];
    _dateFormatter.dateFormat = fmt;
    NSTimeZone *localTz = [NSTimeZone localTimeZone];
    _dateFormatter.timeZone = localTz;

    NSDate *nowLocal = [NSDate date];
    NSString *localLabel = fullTzLabelForZone(localTz, nowLocal);

    // Inline UTC reference honors user directive 2026-04-24: "the original
    // time must also be considered". UTC is the canonical astronomical
    // origin; showing it alongside local time disambiguates any DST edge
    // case and gives a fixed reference for cross-timezone coordination.
    // Canonical rendering: always 24h (scientific convention — UTC is
    // written HH:mm:ss in every ISO-8601 context, regardless of the user's
    // local 12h preference). Hidden when ShowUTCReference pref is NO.
    BOOL showUTC = ![d objectForKey:@"ShowUTCReference"] || [d boolForKey:@"ShowUTCReference"];

    // v4 iter-42 + iter-112: sun/moon glyph — subtle day/night cue.
    // No astronomical calculation (needs lat/lon + solar position);
    // simple hour buckets match civil-twilight expectations closely.
    // iter-112 upgrades binary (☀/🌙) to 5 phases for more nuance:
    //   [5, 7)   🌅 dawn / sunrise
    //   [7, 12)  ☀️ morning
    //   [12, 17) ☀️ afternoon (same glyph, phase name differs)
    //   [17, 19) 🌇 dusk / sunset
    //   [19, 5)  🌙 night
    // Hidden when ShowSkyState pref is explicitly NO.
    // v4 iter-249: skyGlyph anchored at FRONT of LOCAL row per user
    // directive — sun-of-day at start, moon phase at end. Trailing
    // space (was leading) so it sits flush against the date prefix.
    // v4 iter-250: solar-event aware glyph dispatcher. When CoreLocation
    // has yielded coordinates, FCSkyGlyphForDate returns a glyph based
    // on actual sunrise/sunset/civil-twilight at the user's lat/lon
    // (NOAA solar formula, ±1min accuracy). Falls back to iter-114's
    // hour-bucket dispatcher when location is unavailable (denied
    // permission, never granted, or polar latitudes).
    // v4 iter-252f: emojis live in DEDICATED sub-views (skyGlyphLabel,
    // moonGlyphLabel) so the timeLabel can be PURE mono — eliminating
    // the mixed-font baseline mismatch that broke vertical centering.
    BOOL showSky = ![d objectForKey:@"ShowSkyState"] || [d boolForKey:@"ShowSkyState"];
    NSString *skyGlyph = @"";
    if (showSky) {
        BOOL haveLoc = [d objectForKey:@"LocationFetchedAt"] != nil
                     && ([d doubleForKey:@"Latitude"] != 0.0 || [d doubleForKey:@"Longitude"] != 0.0);
        if (haveLoc) {
            skyGlyph = FCSkyGlyphForDate(nowLocal,
                                          [d doubleForKey:@"Latitude"],
                                          [d doubleForKey:@"Longitude"]);
        } else {
            NSCalendar *cal = [NSCalendar currentCalendar];
            cal.timeZone = localTz;
            NSInteger hour = [cal component:NSCalendarUnitHour fromDate:nowLocal];
            skyGlyph = FCSkyGlyphForHour(hour);
        }
    }
    _localSeg.skyGlyphLabel.stringValue = skyGlyph;

    NSString *moonGlyph = @"";
    BOOL showMoon = ![d objectForKey:@"ShowMoonPhase"] || [d boolForKey:@"ShowMoonPhase"];
    if (showMoon) {
        moonGlyph = FCMoonPhaseGlyph(nowLocal);
    }
    _localSeg.moonGlyphLabel.stringValue = moonGlyph;

    // v4 iter-229: optional week-progress bar appended inline. Shows
    // "▕<bar>▏" where <bar> is a fractional fill across 14 cells (2
    // per day × 7 days). Default ON so users see the new feature
    // after rebuild; toggle via Show Week Progress menu item.
    // iter-257: build LOCAL row as attributed string so the time-of-day
    // portion can be painted in distinct cyan per user directive ("the
    // top section local time hh:mm:ss needs that same unique coloring").
    // Three runs: datePrefix (theme color) → "HH:mm:ss TZ" (cyan) → " ·
    // HH:mm:ss UTC" (theme color, only when ShowUTCReference).
    NSString *datePrefix = @"";
    if (showDate) {
        NSDateFormatter *df = [[NSDateFormatter alloc] init];
        df.timeZone = localTz;
        // Strip trailing whitespace from the prefix pattern — we add a
        // single space between prefix and time below.
        NSString *prefixPattern = FCDateFormatPrefix([d stringForKey:@"DateFormat"]);
        df.dateFormat = [prefixPattern stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
        datePrefix = [df stringFromDate:nowLocal];
    }
    NSDateFormatter *tf2 = [[NSDateFormatter alloc] init];
    tf2.timeZone = localTz;
    tf2.dateFormat = FCCurrentTimeFormat([tf isEqualToString:@"12h"], showSec);
    NSString *timeOfDay = [tf2 stringFromDate:nowLocal];

    NSColor *themeColor = _localSeg.timeLabel.textColor ?: [NSColor labelColor];
    NSFont  *timeFont   = _localSeg.timeLabel.font ?: [NSFont monospacedSystemFontOfSize:24 weight:NSFontWeightMedium];
    NSMutableAttributedString *line = [[NSMutableAttributedString alloc] init];
    if (datePrefix.length > 0) {
        [line appendAttributedString:[[NSAttributedString alloc]
            initWithString:[datePrefix stringByAppendingString:@"  "]
            attributes:@{NSFontAttributeName: timeFont, NSForegroundColorAttributeName: themeColor}]];
    }
    NSString *timeRun = [NSString stringWithFormat:@"%@ %@", timeOfDay, localLabel];
    [line appendAttributedString:[[NSAttributedString alloc]
        initWithString:timeRun
        attributes:@{NSFontAttributeName: timeFont, NSForegroundColorAttributeName: [NSColor systemCyanColor]}]];
    if (showUTC) {
        if (!_utcFormatter) {
            _utcFormatter = [[NSDateFormatter alloc] init];
            _utcFormatter.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
        }
        // UTC always 24h canonical; separator follows user's TimeSeparator pref
        // for visual consistency with the local time beside it.
        _utcFormatter.dateFormat = FCCurrentTimeFormat(NO, showSec);
        NSString *utcStr = [_utcFormatter stringFromDate:nowLocal];
        NSString *utcRun = [NSString stringWithFormat:@" · %@ UTC", utcStr];
        [line appendAttributedString:[[NSAttributedString alloc]
            initWithString:utcRun
            attributes:@{NSFontAttributeName: timeFont, NSForegroundColorAttributeName: themeColor}]];
    }
    // iter-258: paragraph-style center alignment. attributedStringValue
    // ignores the cell's alignment (set in Layout.m line 155) unless the
    // attributed string carries its own paragraph style — same quirk
    // that bit W18/bar/days in iter-254. Without this the whole LOCAL
    // row left-aligns inside the wider segment frame.
    NSMutableParagraphStyle *centerPS = [[NSMutableParagraphStyle alloc] init];
    centerPS.alignment = NSTextAlignmentCenter;
    [line addAttribute:NSParagraphStyleAttributeName value:centerPS range:NSMakeRange(0, line.length)];
    _localSeg.timeLabel.attributedStringValue = line;

    // v4 iter-231 / iter-232: week-progress bar in its own NSTextField
    // (weekBarLabel). cellsPerDay computed dynamically from the LOCAL
    // segment width (per user directive iter-232 — "take full advantage
    // of horizontality"). User can still pin a value via
    // WeekProgressCellsPerDay > 0; default 0 = auto-fit width.
    if ([d boolForKey:@"ShowWeekProgress"]) {
        NSInteger cellsPerDay = [d integerForKey:@"WeekProgressCellsPerDay"];
        if (cellsPerDay <= 0) {
            // Auto: estimate mono char width from the bar's font (set
            // in Layout.m to activeFont). Available width = segment
            // width minus side padding (8pt each side) and ▕▏ brackets
            // (~2 chars). Reserve 6 day-separators. Then divide by 7.
            CGFloat segW = _weekSeg.bounds.size.width;
            NSFont *barFont = _weekSeg.weekBarLabel.font ?: [NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular];
            CGFloat charW = [@"M" sizeWithAttributes:@{NSFontAttributeName: barFont}].width;
            if (charW < 4) charW = 7.0;  // safety floor
            // iter-234: drop margin from 16pt → 4pt (2pt each side)
            // per user directive — make the most of horizontal space.
            NSInteger maxChars = (NSInteger)((segW - 4.0) / charW);
            NSInteger usableForCells = maxChars - 2 - 6;  // brackets + separators
            cellsPerDay = usableForCells / 7;
            if (cellsPerDay < 1) cellsPerDay = 1;
            if (cellsPerDay > 32) cellsPerDay = 32;  // sanity cap
            // v4 iter-252: force ODD so the day-letter sits at the exact
            // pixel center of its 7-cell slot. Even widths force the
            // single 1-char letter into one of two off-center positions
            // (no half-character offset possible in monospace), making
            // M/T/W/T/F/S/S visibly drift relative to the bar's
            // day-group centers below. Odd N → leftPad == rightPad → true center.
            if (cellsPerDay % 2 == 0) cellsPerDay--;
            if (cellsPerDay < 1) cellsPerDay = 1;
        }
        // v4 iter-233: render with weekend-dimming via attributed string.
        // LocalTheme drives the filled cell color so the bar harmonizes
        // with the LOCAL segment's foreground; FCProgressEmptyColor stays
        // for empties + separators (matches ACTIVE bar conventions).
        const ClockTheme *localTheme = themeForId([d stringForKey:@"LocalTheme"]);
        NSColor *barFilled = [NSColor colorWithRed:localTheme->fg_r green:localTheme->fg_g blue:localTheme->fg_b alpha:1.0];
        NSColor *barEmpty  = FCProgressEmptyColor();
        NSFont *barFont = _weekSeg.weekBarLabel.font ?: [NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular];
        NSAttributedString *body = FCBuildWeekProgressBarAttributed(nowLocal, (int)cellsPerDay,
                                                                     barFilled, barEmpty, barFont);
        // Wrap with `▕…▏` brackets in the empty-color (subtle frame).
        NSMutableAttributedString *full = [[NSMutableAttributedString alloc] init];
        NSDictionary *frameAttrs = @{NSFontAttributeName: barFont,
                                     NSForegroundColorAttributeName: barEmpty};
        [full appendAttributedString:[[NSAttributedString alloc] initWithString:@"▕" attributes:frameAttrs]];
        [full appendAttributedString:body];
        [full appendAttributedString:[[NSAttributedString alloc] initWithString:@"▏" attributes:frameAttrs]];
        // iter-254: apply paragraph-style center alignment to the entire bar.
        // attributedStringValue ignores cell.alignment unless the attributed
        // string carries its own paragraph style. Without this the bar
        // renders left-aligned even though _weekBarLabel has center
        // alignment set on both cell and field.
        NSMutableParagraphStyle *barCenterPS = [[NSMutableParagraphStyle alloc] init];
        barCenterPS.alignment = NSTextAlignmentCenter;
        [full addAttribute:NSParagraphStyleAttributeName value:barCenterPS range:NSMakeRange(0, full.length)];
        _weekSeg.weekBarLabel.attributedStringValue = full;

        // v4 iter-252: day-letter row uses SAME ▕/▏ brackets as the bar
        // (rendered transparent) so character-cell counts match exactly.
        // Prior version padded with regular spaces — but ▕▏ are half-
        // width unicode and space is full-width, so the labels row drifted
        // by half a char on each side and the M/T/W/T/F/S/S letters
        // didn't sit directly above their day-group columns. Centering
        // both rows via the SAME wrapping char eliminates the drift.
        NSString *plainLabels = FCBuildWeekDayLabels((int)cellsPerDay);
        NSDictionary *labelAttrs = @{NSFontAttributeName: barFont,
                                     NSForegroundColorAttributeName: barFilled};
        NSDictionary *invisibleFrameAttrs = @{NSFontAttributeName: barFont,
                                              NSForegroundColorAttributeName: [NSColor clearColor]};
        NSMutableAttributedString *labelsRow = [[NSMutableAttributedString alloc] init];
        [labelsRow appendAttributedString:[[NSAttributedString alloc] initWithString:@"▕" attributes:invisibleFrameAttrs]];
        [labelsRow appendAttributedString:[[NSAttributedString alloc] initWithString:plainLabels attributes:labelAttrs]];
        [labelsRow appendAttributedString:[[NSAttributedString alloc] initWithString:@"▏" attributes:invisibleFrameAttrs]];
        // iter-254: paragraph-style center alignment (same reason as the bar above).
        NSMutableParagraphStyle *daysCenterPS = [[NSMutableParagraphStyle alloc] init];
        daysCenterPS.alignment = NSTextAlignmentCenter;
        [labelsRow addAttribute:NSParagraphStyleAttributeName value:daysCenterPS range:NSMakeRange(0, labelsRow.length)];
        _weekSeg.weekDayLabelsLabel.attributedStringValue = labelsRow;

        // v4 iter-234: ISO 8601 week-of-year.
        // iter-254: render as ATTRIBUTED string with paragraph-style center
        // alignment. NSTextFieldCell.alignment + NSTextField.alignment did
        // not take effect for short plain strings in this setup;
        // paragraph style is the ground-truth that every code path
        // respects (same approach as how the day-letters and bar already
        // render via attributedStringValue above).
        NSInteger isoWeek = FCISOWeekOfYear(nowLocal);
        NSString *weekText = [NSString stringWithFormat:@"W%02ld", (long)isoWeek];
        NSMutableParagraphStyle *centerPS = [[NSMutableParagraphStyle alloc] init];
        centerPS.alignment = NSTextAlignmentCenter;
        NSDictionary *weekNumAttrs = @{
            NSFontAttributeName: _weekSeg.weekNumberLabel.font ?: [NSFont monospacedSystemFontOfSize:9.5 weight:NSFontWeightMedium],
            NSForegroundColorAttributeName: _weekSeg.weekNumberLabel.textColor ?: [NSColor colorWithCalibratedWhite:1.0 alpha:0.55],
            NSParagraphStyleAttributeName: centerPS
        };
        _weekSeg.weekNumberLabel.attributedStringValue =
            [[NSAttributedString alloc] initWithString:weekText attributes:weekNumAttrs];
    } else {
        _weekSeg.weekBarLabel.stringValue = @"";
        _weekSeg.weekDayLabelsLabel.stringValue = @"";
        _weekSeg.weekNumberLabel.stringValue = @"";
    }
    [_localSeg setNeedsLayout:YES];
    [_weekSeg setNeedsLayout:YES];
    _activeSeg.contentLabel.attributedStringValue = FCBuildActiveSegmentContent();
    _nextSeg.contentLabel.attributedStringValue = FCBuildNextSegmentContent();

    // Per-tick resize: markets open/close, content height changes. Cheap —
    // setFrame no-ops when values match.
    [self relayoutThreeSegmentIfNeeded];
}

- (void)tickLegacy {
    NSMutableString *fmt = [NSMutableString string];
    NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
    BOOL showDate = [d boolForKey:@"ShowDate"];
    NSString *timeFormat = [d stringForKey:@"TimeFormat"];

    BOOL showSec2 = [d boolForKey:@"ShowSeconds"];
    if (showDate) [fmt appendString:FCDateFormatPrefix([d stringForKey:@"DateFormat"])];
    // v4 iter-98: separator chosen via FCCurrentTimeFormat.
    [fmt appendString:FCCurrentTimeFormat([timeFormat isEqualToString:@"12h"], showSec2)];

    if (!_dateFormatter) _dateFormatter = [[NSDateFormatter alloc] init];
    _dateFormatter.dateFormat = fmt;

    NSString *marketId = [d stringForKey:@"SelectedMarket"];
    const ClockMarket *mkt = marketForId(marketId);
    NSDate *now = [NSDate date];
    NSTimeZone *effectiveTz = [NSTimeZone localTimeZone];
    if (strlen(mkt->iana) > 0) {
        NSTimeZone *tz = [NSTimeZone timeZoneWithName:[NSString stringWithUTF8String:mkt->iana]];
        if (tz) effectiveTz = tz;
    }
    _dateFormatter.timeZone = effectiveTz;

    NSString *legacyLabel = (strlen(mkt->iana) > 0)
        ? fullTzLabelForIana(mkt->iana, now)
        : fullTzLabelForZone(effectiveTz, now);
    _label.stringValue = [NSString stringWithFormat:@"%@ %@",
        [_dateFormatter stringFromDate:now], legacyLabel];

    if (strlen(mkt->iana) == 0) return;  // local mode — no session label

    SessionState state;
    double progress01;
    long secsToNext;
    computeSessionState(mkt, now, &state, &progress01, &secsToNext);

    NSString *glyph = glyphForState(state);
    NSString *code = [NSString stringWithUTF8String:mkt->code];
    NSMutableAttributedString *attr = [[NSMutableAttributedString alloc] init];

    [attr appendAttributedString:[[NSAttributedString alloc]
        initWithString:glyph attributes:@{
            NSFontAttributeName: _sessionLabel.font,
            NSForegroundColorAttributeName: colorForState(state, NULL)}]];

    NSString *themeId = [d stringForKey:@"ColorTheme"];
    const ClockTheme *theme = themeForId(themeId);
    NSColor *themeFg = [NSColor colorWithRed:theme->fg_r green:theme->fg_g blue:theme->fg_b alpha:1.0];
    NSColor *dim = [NSColor colorWithWhite:0.5 alpha:1.0];

    [attr appendAttributedString:[[NSAttributedString alloc]
        initWithString:[NSString stringWithFormat:@" %@ ", code]
        attributes:@{NSFontAttributeName: _sessionLabel.font, NSForegroundColorAttributeName: themeFg}]];

    if (FCStateIsTrading(state)) {  // iter-168
        NSString *bar = buildProgressBar(progress01, 12);
        NSInteger splitIdx = fcProgressBarFullCells(progress01, 12);
        if (splitIdx > (NSInteger)bar.length) splitIdx = bar.length;
        NSMutableAttributedString *barAttr = [[NSMutableAttributedString alloc]
            initWithString:bar attributes:@{NSFontAttributeName: _sessionLabel.font}];
        [barAttr addAttribute:NSForegroundColorAttributeName value:themeFg range:NSMakeRange(0, splitIdx)];
        [barAttr addAttribute:NSForegroundColorAttributeName value:dim range:NSMakeRange(splitIdx, bar.length - splitIdx)];
        [attr appendAttributedString:barAttr];

        NSString *cd = (state == kSessionLunch)
            ? [NSString stringWithFormat:@" LUNCH %@", formatCountdown(secsToNext)]
            : [NSString stringWithFormat:@" %@", formatCountdown(secsToNext)];
        [attr appendAttributedString:[[NSAttributedString alloc]
            initWithString:cd attributes:@{NSFontAttributeName: _sessionLabel.font, NSForegroundColorAttributeName: themeFg}]];
    } else {
        // v4 iter-134: state-accurate label. iter-123/125 introduced
        // PRE-MARKET and AFTER-HOURS as distinct states, but this legacy
        // single-market path kept the literal "CLOSED" prefix — the glyph
        // color said one thing, the text said another. Map the state to
        // its word-form now so the two agree.
        // v4 iter-135: state→word mapping lifted to `labelForState` in
        // MarketSessionCalculator (testable, reusable). PRE-MARKET /
        // AFTER-HOURS use their state color; plain CLOSED stays dim.
        NSString *stateWord = labelForState(state);
        NSColor *textColor = (state == kSessionPreMarket || state == kSessionAfterHours)
                              ? colorForState(state, NULL) : dim;
        NSString *countdownText;
        if (secsToNext > kFCMaxBoundedCountdownSecs) {
            NSDate *opensAt = [NSDate dateWithTimeIntervalSinceNow:secsToNext];
            NSDateFormatter *openFmt = [[NSDateFormatter alloc] init];
            openFmt.dateFormat = @"EEE HH:mm";
            NSTimeZone *mktTz = [NSTimeZone timeZoneWithName:[NSString stringWithUTF8String:mkt->iana]];
            if (mktTz) openFmt.timeZone = mktTz;
            NSString *label = fullTzLabelForIana(mkt->iana, opensAt);
            countdownText = [NSString stringWithFormat:@" %@ · opens %@ %@",
                stateWord, [openFmt stringFromDate:opensAt], label];
        } else {
            countdownText = [NSString stringWithFormat:@" %@ · opens in %@",
                stateWord, formatCountdown(secsToNext)];
        }
        [attr appendAttributedString:[[NSAttributedString alloc]
            initWithString:countdownText attributes:@{NSFontAttributeName: _sessionLabel.font, NSForegroundColorAttributeName: textColor}]];
    }

    _sessionLabel.attributedStringValue = attr;
}

#pragma mark - Positioning

// Primary display = screens[0] (with menu bar at origin). mainScreen is
// indeterminate for LSUIElement apps before a window is key.
- (NSScreen *)primaryScreen {
    NSArray<NSScreen *> *all = [NSScreen screens];
    if (all.count > 0) return all.firstObject;
    return [NSScreen mainScreen];
}

- (NSRect)defaultFrame {
    NSScreen *s = [self primaryScreen];
    NSRect vf = s.visibleFrame;
    NSRect f = self.frame;
    CGFloat x = vf.origin.x + (vf.size.width - f.size.width) / 2.0;
    CGFloat y = vf.origin.y + 24;
    return NSMakeRect(x, y, f.size.width, f.size.height);
}

- (NSRect)clampFrameToVisibleScreen:(NSRect)proposed {
    NSScreen *s = self.screen ?: [self primaryScreen];
    NSRect vf = s.visibleFrame;
    NSRect r = proposed;
    if (r.size.width > vf.size.width)  r.size.width  = vf.size.width;
    if (r.size.height > vf.size.height) r.size.height = vf.size.height;
    if (NSMaxX(r) > NSMaxX(vf)) r.origin.x = NSMaxX(vf) - r.size.width;
    if (NSMaxY(r) > NSMaxY(vf)) r.origin.y = NSMaxY(vf) - r.size.height;
    if (r.origin.x < vf.origin.x) r.origin.x = vf.origin.x;
    if (r.origin.y < vf.origin.y) r.origin.y = vf.origin.y;
    return r;
}

- (void)windowDidMove:(NSNotification *)n {
    NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
    [d setObject:NSStringFromRect(self.frame) forKey:@"FloatingClockWindowFrame"];
    NSNumber *sn = self.screen.deviceDescription[@"NSScreenNumber"];
    if ([sn isKindOfClass:[NSNumber class]]) {
        [d setObject:sn forKey:@"FloatingClockScreenNumber"];
    }
}

- (void)restorePosition {
    NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
    NSString *frameStr = [d stringForKey:@"FloatingClockWindowFrame"];
    NSNumber *savedScreenNum = [d objectForKey:@"FloatingClockScreenNumber"];

    if ([frameStr isKindOfClass:[NSString class]] && frameStr.length > 0 &&
        [savedScreenNum isKindOfClass:[NSNumber class]]) {
        NSRect r = NSRectFromString(frameStr);
        if (r.size.width > 20 && r.size.height > 20) {
            for (NSScreen *s in [NSScreen screens]) {
                NSNumber *n = s.deviceDescription[@"NSScreenNumber"];
                if ([n isKindOfClass:[NSNumber class]] && [n isEqualToNumber:savedScreenNum]) {
                    if (NSIntersectsRect(r, s.frame)) {
                        [self setFrame:r display:NO];
                        return;
                    }
                    NSRect vf = s.visibleFrame;
                    NSRect clamped = r;
                    clamped.origin.x = MAX(vf.origin.x, MIN(r.origin.x, NSMaxX(vf) - r.size.width));
                    clamped.origin.y = MAX(vf.origin.y, MIN(r.origin.y, NSMaxY(vf) - r.size.height));
                    [self setFrame:clamped display:NO];
                    return;
                }
            }
        }
    }
    [self setFrame:[self defaultFrame] display:NO];
}

- (void)screensChanged:(NSNotification *)n {
    BOOL onLiveScreen = NO;
    for (NSScreen *s in [NSScreen screens]) {
        if (NSIntersectsRect(self.frame, s.frame)) { onLiveScreen = YES; break; }
    }
    if (!onLiveScreen) {
        [self setFrame:[self defaultFrame] display:YES animate:YES];
        NSNumber *sn = [self primaryScreen].deviceDescription[@"NSScreenNumber"];
        if ([sn isKindOfClass:[NSNumber class]]) {
            [[NSUserDefaults standardUserDefaults] setObject:sn forKey:@"FloatingClockScreenNumber"];
        }
        [[NSUserDefaults standardUserDefaults] setObject:NSStringFromRect(self.frame)
                                                  forKey:@"FloatingClockWindowFrame"];
    }
}

@end
