/**
 * Main-process gitea bridge. All gitea traffic runs here — the token never
 * reaches the renderer (which is CSP-locked to 'self' anyway). The renderer
 * calls these over IPC (see preload). Config for the dogfood slice comes from
 * the environment or the repo's .env.local; Settings/Onboarding wire it up
 * properly later.
 */

import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'

import {
  appendDirective,
  createGiteaClient,
  type DirectiveEntry,
  type GiteaClient,
  type GiteaConfig,
  type GiteaLabel,
  type IssueChange,
  type LifecycleEvent,
  makeDirectiveEntry,
  parseDirectiveLog,
  planIssueChange,
  type ProjectSnapshot,
  type DirectiveInput,
} from '@commitea/core'
import { ipcMain } from 'electron'

import { loadSnapshot, saveSnapshot } from './snapshot-store.js'

/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
function loadEnvLocalToken(): string | undefined {
  let dir = process.cwd()
  for (let i = 0; i < 6; i++) {
    try {
      const txt = readFileSync(join(dir, '.env.local'), 'utf8')
      const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(txt)
      if (m) return m[1].trim()
    } catch {
      // not in this dir — keep walking up
    }
    const parent = dirname(dir)
    if (parent === dir) break
    dir = parent
  }
  return undefined
}

function resolveConfig(): GiteaConfig | null {
  // E2E runs against fixtures — never hit the network from the test harness.
  if (process.env.COMMITEA_E2E === '1') return null
  const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken()
  if (!token) return null
  return {
    baseUrl: process.env.GITEA_BASE_URL ?? 'https://gitea.stephenmann.io',
    token,
    owner: process.env.GITEA_OWNER ?? 'christian',
    repo: process.env.GITEA_REPO ?? 'commitea',
  }
}

// Memoized client so both the gitea and model bridges share one instance.
let sharedClient: GiteaClient | null | undefined
export function getGiteaClient(): GiteaClient | null {
  if (sharedClient === undefined) {
    const config = resolveConfig()
    sharedClient = config ? createGiteaClient(config, fetch) : null
  }
  return sharedClient
}

// The pm-state repo holds machine-derived state (the directive ledger). Same
// token/host as the work repo, a different repo (the purity split, decisions D4).
let pmStateClient: GiteaClient | null | undefined
export function getPmStateClient(): GiteaClient | null {
  if (pmStateClient === undefined) {
    const config = resolveConfig()
    pmStateClient = config
      ? createGiteaClient({ ...config, repo: process.env.COMMITEA_PMSTATE_REPO ?? 'commitea-pm-state' }, fetch)
      : null
  }
  return pmStateClient
}

const DIRECTIVE_LOG_PATH = 'directives/log.jsonl'

async function readDirectiveLog(client: GiteaClient): Promise<{ text: string; sha: string | null }> {
  const file = await client.getFile(DIRECTIVE_LOG_PATH)
  if (!file) return { text: '', sha: null }
  return { text: Buffer.from(file.contentBase64, 'base64').toString('utf8'), sha: file.sha }
}

/** Record a directive: read the ledger, append, write it back (concatenation merge). */
export async function appendDirectiveEntry(client: GiteaClient, input: DirectiveInput): Promise<DirectiveEntry> {
  const entry = makeDirectiveEntry(input, randomUUID(), new Date().toISOString())
  const { text, sha } = await readDirectiveLog(client)
  const next = appendDirective(text, entry)
  await client.putFile(DIRECTIVE_LOG_PATH, {
    contentBase64: Buffer.from(next, 'utf8').toString('base64'),
    message: `directive: ${entry.kind}`,
    sha: sha ?? undefined,
  })
  return entry
}

export async function readDirectives(client: GiteaClient) {
  const { text } = await readDirectiveLog(client)
  return parseDirectiveLog(text)
}

/** Full reconcile: issues + milestones + native deps + lifecycle timelines. */
export async function reconcileSnapshot(
  client: GiteaClient,
): Promise<ProjectSnapshot & { milestones: Awaited<ReturnType<GiteaClient['listMilestones']>> }> {
  const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
  // dependency edges among the open scope (the scheduler only plans what's left)
  const open = issues.filter((i) => i.state === 'open')
  const perIssue = await Promise.all(
    open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
  )
  const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
  // lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
  const timelineEntries = await Promise.all(
    issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
  )
  const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
  return { issues, milestones, deps, timelines }
}

type Snapshot = Awaited<ReturnType<typeof reconcileSnapshot>>

/**
 * A single in-memory reconcile cache shared across the app. A full reconcile is
 * ~2N gitea calls (deps + timelines per issue); without this, every agent tool
 * call refetched the whole repo. Reads within `maxAgeMs` reuse the cache;
 * `getSnapshot({ maxAgeMs: 0 })` forces a fresh pull (the explicit UI reconcile),
 * and any write calls `invalidateSnapshot()` so the next read sees it. The cache
 * is rebuildable — the durable truth stays in gitea (the purity split, D4).
 */
let snapshotCache: { snap: Snapshot; at: number } | null = null

export async function getSnapshot(client: GiteaClient, opts?: { maxAgeMs?: number }): Promise<Snapshot> {
  const maxAgeMs = opts?.maxAgeMs ?? 0
  if (snapshotCache && maxAgeMs > 0 && Date.now() - snapshotCache.at <= maxAgeMs) {
    return snapshotCache.snap
  }
  const snap = await reconcileSnapshot(client)
  snapshotCache = { snap, at: Date.now() }
  saveSnapshot(snap, new Date().toISOString()) // persist for instant boot + offline
  return snap
}

/** Drop the cache so the next read reflects a just-made write. */
export function invalidateSnapshot(): void {
  snapshotCache = null
}

/**
 * The last persisted snapshot (from a previous session), for instant boot. The
 * renderer shows it immediately, then a real reconcile supersedes it
 * (stale-while-revalidate). Returns null when there's nothing on disk; its
 * `savedAt` marks staleness. It does NOT seed the cache — agent tool calls
 * always reconcile fresh so they never reason over stale data.
 */
export function bootSnapshot(): (Snapshot & { savedAt: string }) | null {
  const persisted = loadSnapshot()
  if (!persisted) return null
  return {
    issues: persisted.issues,
    milestones: persisted.milestones,
    deps: persisted.deps,
    timelines: persisted.timelines,
    savedAt: persisted.savedAt,
  } as unknown as Snapshot & { savedAt: string }
}

/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
export const AGENT_SNAPSHOT_TTL_MS = 30_000

export function registerGiteaIpc(): void {
  const client = getGiteaClient()
  const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null

  ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))

  // Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
  ipcMain.handle('gitea:boot', () => {
    if (!client) return { configured: false }
    const persisted = bootSnapshot()
    return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
  })

  ipcMain.handle('gitea:reconcile', async () => {
    if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
    try {
      // explicit UI sync — force fresh, and warm the cache for agent tool calls
      const snap = await getSnapshot(client, { maxAgeMs: 0 })
      return { configured: true, stale: false, ...snap }
    } catch (e) {
      // offline / gitea down — serve the last persisted snapshot rather than error out
      const persisted = bootSnapshot()
      if (persisted) return { configured: true, stale: true, ...persisted }
      throw e
    }
  })

  ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
    if (!client) return null
    return client.getIssue(index)
  })

  // Cached label list for name→id resolution; refreshed on demand if a name misses.
  let labelCache: GiteaLabel[] | null = null
  async function resolveLabelIds(names: string[]): Promise<number[]> {
    if (!client) return []
    const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
    if (!labelCache) labelCache = await client.listLabels()
    let byName = lookup()
    if (names.some((n) => !byName.has(n))) {
      labelCache = await client.listLabels() // a name we don't know — refetch once
      byName = lookup()
    }
    return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
  }

  // The write path (apply_changes). Additive label swaps, applied only after the
  // renderer's propose-approve. Returns the plan + the freshly-read issue.
  ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
    if (!client) return { ok: false as const, reason: 'unconfigured' as const }
    const current = await client.getIssue(change.issue)
    const plan = planIssueChange(current.labels, change)
    if (plan.noop) return { ok: true as const, plan, issue: current }
    const ids = await resolveLabelIds(plan.labels)
    await client.setIssueLabels(change.issue, ids)
    const issue = await client.getIssue(change.issue)
    invalidateSnapshot() // the board + forecast must reflect the label change
    return { ok: true as const, plan, issue }
  })

  // capture_work filing: open each approved issue with its est/* + p/* labels.
  // Only touches the CommiTea label namespaces — no invented labels (zero-pollution).
  ipcMain.handle(
    'gitea:createIssues',
    async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => {
      if (!client) return { ok: false as const, reason: 'unconfigured' as const }
      const created: { number: number; title: string }[] = []
      for (const it of issues) {
        const names = [it.estimate, it.priority].filter((n): n is string => !!n)
        const labelIds = await resolveLabelIds(names)
        const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
        created.push({ number: issue.number, title: issue.title })
      }
      if (created.length) invalidateSnapshot() // new issues enter the board/scope
      return { ok: true as const, created }
    },
  )

  // Read the directive ledger from the pm-state repo (for the Directives screen).
  ipcMain.handle('pmstate:directives', async () => {
    const pm = getPmStateClient()
    if (!pm) return { ok: false as const, reason: 'unconfigured' as const }
    try {
      return { ok: true as const, directives: await readDirectives(pm) }
    } catch (e) {
      return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
    }
  })
}
