---
import {
  differenceInCalendarDays,
  eachDayOfInterval,
  formatISO,
  getDay,
  nextDay,
  parseISO,
  subWeeks,
  getYear,
  getMonth,
  isValid,
} from 'date-fns'
import type {
  WeekdayIndex,
  CodebergActivityApiResponse,
  CodebergActivityDay,
  CodebergActivityWeek,
  CodebergActivityMonthLabel,
} from '~/types'

interface Props {
  username: string
  year?: number
}

function range(n: number) {
  return [...Array(n).keys()]
}

async function fetchData(
  username: string,
  year: number | 'last',
): Promise<CodebergActivityApiResponse> {
  function validateActivities(activities: Array<CodebergActivityDay>) {
    if (activities.length === 0) {
      throw new Error('Activity data must not be empty.')
    }
    for (const { date, count } of activities) {
      if (!isValid(parseISO(date))) {
        throw new Error(`Activity date '${date}' is not a valid ISO 8601 date string.`)
      }
      if (count < 0) {
        throw new RangeError(`Activity count must not be negative, found ${count}.`)
      }
    }
  }
  const apiUrl = 'https://github-contributions-api.jogruber.de/v4/'
  const response = await fetch(`${apiUrl}${username}?y=${String(year)}`)
  const data = (await response.json()) as GitHubActivityApiResponse
  if (!response.ok) {
    const message = data.error || 'Unknown error'
    throw Error(`Fetching GitHub contribution data for "${username}" failed: ${message}`)
  }

  validateActivities(data.contributions)

  return data
}

function calcColorScale([start, end]: [string, string], steps: number): Array<string> {
  return range(steps).map((i) => {
    switch (i) {
      case 0:
        return start
      case steps - 1:
        return end
      default: {
        const pos = (i / (steps - 1)) * 100
        return `color-mix(in oklab, ${end} ${parseFloat(pos.toFixed(2))}%, ${start})`
      }
    }
  })
}

function fillHoles(activities: Array<GitHubActivityDay>): Array<GitHubActivityDay> {
  const calendar = new Map<string, GitHubActivityDay>(activities.map((a) => [a.date, a]))
  const firstActivity = activities[0] as GitHubActivityDay
  const lastActivity = activities[activities.length - 1] as GitHubActivityDay

  return eachDayOfInterval({
    start: parseISO(firstActivity.date),
    end: parseISO(lastActivity.date),
  }).map((day) => {
    const date = formatISO(day, { representation: 'date' })
    if (calendar.has(date)) {
      return calendar.get(date) as GitHubActivityDay
    }
    return {
      date,
      count: 0,
      level: 0,
    }
  })
}

function groupByWeeks(
  activities: Array<GitHubActivityDay>,
  weekStart: WeekdayIndex = 0, // 0 = Sunday
): Array<GitHubActivityWeek> {
  const normalizedActivities = fillHoles(activities)
  // Determine the first date of the calendar. If the first date is not the
  // passed weekday, the respective weekday one week earlier is used.
  const firstActivity = normalizedActivities[0] as GitHubActivityDay
  const firstDate = parseISO(firstActivity.date)
  const firstCalendarDate =
    getDay(firstDate) === weekStart
      ? firstDate
      : subWeeks(nextDay(firstDate, weekStart), 1)
  // To correctly group activities by week, it is necessary to left-pad the list
  // because the first date might not be set start weekday.
  const paddedActivities = [
    ...(Array(differenceInCalendarDays(firstDate, firstCalendarDat        e)).fill(
      undefined,
    ) as Array<GitHubActivityDay>),
    ...normalizedActivities,
  ]
  const numberOfWeeks = Math.ceil(paddedActivities.length / 7)

  // Finally, group activities by week
  return [...Array(numberOfWeeks).keys()].map((weekIndex) =>
    paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7),
  )
}

function getMonthLabels(
  weeks: Array<GitHubActivityWeek>,
  monthNames: Array<string>,
): Array<GitHubActivityMonthLabel> {
  return weeks
    .reduce<Array<GitHubActivityMonthLabel>>((labels, week, weekIndex) => {
      const firstActivity = week.find((activity) => activity !== undefined)
      if (!firstActivity) {
        throw new Error(`Unexpected error: Week ${weekIndex + 1} is empty.`)
      }
      const month = monthNames[getMonth(parseISO(firstActivity.date))]
      if (!month) {
        const monthName = new Date(firstActivity.date).toLocaleString('en-US', {
          month: 'short',
        })
        throw new Error(`Unexpected error: undefined month label for ${monthName}.`)
      }
      const prevLabel = labels[labels.length - 1]
      if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {
        return [...labels, { weekIndex, label: month }]
      }
      return labels
    }, [])
    .filter(({ weekIndex }, index, labels) => {
      const minWeeks = 3
      // Skip the first month label if there is not enough space to the next one.
      if (index === 0) {
        return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks
      }
      // Skip the last month label if there is not enough data in that month
      if (index === labels.length - 1) {
        return weeks.slice(weekIndex).length >= minWeeks
      }
      return true
    })
}

const { username, year = 'last' } = Astro.props

const data = await fetchData(username, year)

const themeFromColorscheme: [string, string] = [
  'var(--theme-background)',
  'var(--theme-accent)',
]

const totalCount = year === 'last' ? data.total.lastYear : data.total[year]
const maxLevel = 4
const blockMargin = 4
const labelMargin = 8
const blockRadius = 2
const blockSize = 12
const fontSize = 14
const hideColorLegend = false
const hideMonthLabels = false
const hideTotalCount = false
const weekStart = 0 // 0 = Sunday, 1 = Monday, etc.

const colorScale = calcColorScale(themeFromColorscheme, maxLevel + 1)
const activities = data.contributions

const firstActivity = activities[0]
const activityYear = getYear(parseISO(firstActivity.date))
const weeks = groupByWeeks(activities, weekStart)
const labels = {
  months: [
    'Jan',
    'Feb',
    'Mar',
    'Apr',
    'May',
    'Jun',
    'Jul',
    'Aug',
    'Sep',
    'Oct',
    'Nov',
    'Dec',
  ],
  totalCount: `{{count}} contributions in ${year === 'last' ? 'the last year' : '{{year}}'}`,
  legend: {
    less: 'Less',
    more: 'More',
  },
}
const labelHeight = hideMonthLabels ? 0 : fontSize + labelMargin
const width = weeks.length * (blockSize + blockMargin) - blockMargin
const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin
---

<article
  id="github-activity-calendar"
  class="w-max max-w-full flex flex-col gap-2 text-sm"
>
  <div
    class="max-w-full overflow-x-auto pt-0.5"
    style={{
      // Don't cover the calendar with the scrollbar.
      scrollbarGutter: 'stable',
    }}
  >
    <svg
      class="block visible"
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
    >
      {
        !hideMonthLabels && (
          <g>
            {getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => (
              <text
                x={(blockSize + blockMargin) * weekIndex}
                y={0}
                dominant-baseline="hanging"
                fill="currentColor"
              >
                {label}
              </text>
            ))}
          </g>
        )
      }
      {
        weeks.map((week, weekIndex) => (
          <g transform={`translate(${(blockSize + blockMargin) * weekIndex}, 0)`}>
            {week.map((activity, dayIndex) => {
              if (!activity) return null
              return (
                <rect
                  class="stroke-foreground/10"
                  x={0}
                  y={labelHeight + (blockSize + blockMargin) * dayIndex}
                  width={blockSize}
                  height={blockSize}
                  rx={blockRadius}
                  ry={blockRadius}
                  fill={colorScale[activity.level]}
                  data-date={activity.date}
                  data-level={activity.level}
                />
              )
            })}
          </g>
        ))
      }
    </svg>
  </div>
  {
    !(hideTotalCount && hideColorLegend) && (
      <footer class="flex flex-col sm:flex-row sm:justify-between gap-x-1 gap-y-2">
        {!hideTotalCount && (
          <div>
            {labels.totalCount
              ? labels.totalCount
                  .replace('{{count}}', String(totalCount))
                  .replace('{{year}}', String(activityYear))
              : `${totalCount} activities in ${activityYear}`}
          </div>
        )}
        {!hideColorLegend && (
          <div class="flex items-center gap-[3px]">
            <span class="mr-1.5">{labels.legend.less}</span>
            {range(maxLevel + 1).map((level) => (
              <svg width={blockSize} height={blockSize}>
                <rect
                  class="stroke-foreground/10"
                  width={blockSize}
                  height={blockSize}
                  fill={colorScale[level]}
                  rx={blockRadius}
                  ry={blockRadius}
                />
              </svg>
            ))}
            <span class="ml-1.5">{labels.legend.more}</span>
          </div>
        )}
      </footer>
    )
  }
</article>
