import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { mkdirSync } from 'fs'
import { mkdir, readdir, readlink, rm, stat } from 'fs/promises'
import { join, resolve } from 'path'
import type { Child } from 'hono/jsx'

const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR!
const TOES_URL = process.env.TOES_URL!

const MAX_VERSIONS = 5
const REPOS_DIR = resolve(DATA_DIR, 'repos')
const VALID_NAME = /^[a-zA-Z0-9_-]+$/

const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>()

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

const Badge = define('Badge', {
  fontSize: '12px',
  padding: '2px 8px',
  borderRadius: theme('radius-md'),
  backgroundColor: theme('colors-bgElement'),
  color: theme('colors-textMuted'),
})

const CodeBlock = define('CodeBlock', {
  base: 'pre',
  backgroundColor: theme('colors-bgElement'),
  border: `1px solid ${theme('colors-border')}`,
  borderRadius: theme('radius-md'),
  padding: theme('spacing-lg'),
  fontFamily: theme('fonts-mono'),
  fontSize: '13px',
  overflowX: 'auto',
  color: theme('colors-text'),
  lineHeight: '1.5',
})

const Container = define('Container', {
  fontFamily: theme('fonts-sans'),
  padding: '20px',
  paddingTop: 0,
  maxWidth: '800px',
  margin: '0 auto',
  color: theme('colors-text'),
})

const Heading = define('Heading', {
  base: 'h3',
  margin: '24px 0 8px',
  color: theme('colors-text'),
})

const HelpText = define('HelpText', {
  color: theme('colors-textMuted'),
  fontSize: '14px',
  lineHeight: '1.6',
  margin: '12px 0',
})

const RepoItem = define('RepoItem', {
  padding: '12px 15px',
  borderBottom: `1px solid ${theme('colors-border')}`,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  states: {
    ':last-child': { borderBottom: 'none' },
    ':hover': { backgroundColor: theme('colors-bgHover') },
  },
})

const RepoList = define('RepoList', {
  listStyle: 'none',
  padding: 0,
  margin: '20px 0',
  border: `1px solid ${theme('colors-border')}`,
  borderRadius: theme('radius-md'),
  overflow: 'hidden',
})

const RepoName = define('RepoName', {
  fontFamily: theme('fonts-mono'),
  fontSize: '15px',
  fontWeight: 'bold',
  color: theme('colors-text'),
})

// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------

interface AppRepoProps {
  appName: string
  baseUrl: string
  branch: string
  exists: boolean
  commits: boolean
}

interface LayoutProps {
  title: string
  children: Child
}

interface RepoListPageProps {
  baseUrl: string
  repos: Array<{ name: string; commits: boolean; branch: string }>
}

// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------

const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)

const timestamp = () => {
  const [date, time] = new Date().toISOString().slice(0, 19).split('T')
  return `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}`
}

// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
const validRepoName = (name: string) =>
  VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)

async function activateApp(name: string, version: string): Promise<string | null> {
  const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, {
    method: 'POST',
  })
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    const msg = (body as Record<string, string>).error ?? `activate returned ${res.status}`
    console.error(`Activate failed for ${name}@${version}:`, msg)
    return msg
  }
  return null
}

async function cleanOldVersions(appDir: string): Promise<void> {
  if (!(await dirExists(appDir))) return

  // Read the current symlink target so we never delete the active version
  let current: string | null = null
  try {
    const target = await readlink(join(appDir, 'current'))
    current = target.split('/').pop() ?? null
  } catch {}

  const entries = await readdir(appDir, { withFileTypes: true })
  const versions = entries
    .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
    .map(e => e.name)
    .sort()

  if (versions.length <= MAX_VERSIONS) return

  const toRemove = versions
    .slice(0, versions.length - MAX_VERSIONS)
    .filter(v => v !== current)
  for (const dir of toRemove) {
    await rm(join(appDir, dir), { recursive: true, force: true })
  }
}

async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; version?: string }> {
  const bare = repoPath(repoName)

  if (!(await hasCommits(bare))) {
    return { ok: false, error: 'No commits in repository' }
  }

  const ts = timestamp()
  const appDir = join(APPS_DIR, repoName)
  const versionDir = join(appDir, ts)

  await mkdir(versionDir, { recursive: true })

  // Extract HEAD into the version directory — no shell, pipe git archive into tar
  const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
    stdout: 'pipe',
    stderr: 'pipe',
  })

  const tar = Bun.spawn(['tar', '-x', '-C', versionDir], {
    stdin: archive.stdout,
    stdout: 'ignore',
    stderr: 'pipe',
  })

  // Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
  const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
    archive.exited,
    tar.exited,
    new Response(archive.stderr).text(),
    new Response(tar.stderr).text(),
  ])

  if (archiveExit !== 0 || tarExit !== 0) {
    await rm(versionDir, { recursive: true, force: true })
    return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
  }

  // Verify package.json with scripts.toes exists
  const pkgPath = join(versionDir, 'package.json')
  if (!(await Bun.file(pkgPath).exists())) {
    await rm(versionDir, { recursive: true, force: true })
    return { ok: false, error: 'No package.json found in repository' }
  }

  try {
    const pkg = JSON.parse(await Bun.file(pkgPath).text())
    if (!pkg.scripts?.toes) {
      await rm(versionDir, { recursive: true, force: true })
      return { ok: false, error: 'package.json missing scripts.toes entry' }
    }
  } catch {
    await rm(versionDir, { recursive: true, force: true })
    return { ok: false, error: 'Invalid package.json' }
  }

  // Clean up old versions beyond MAX_VERSIONS
  await cleanOldVersions(appDir)

  return { ok: true, version: ts }
}

// Bun.file().exists() is for files only — it returns false for directories.
// Use stat() to check directory existence instead.
async function dirExists(path: string): Promise<boolean> {
  try {
    return (await stat(path)).isDirectory()
  } catch {
    return false
  }
}

async function ensureBareRepo(name: string): Promise<string> {
  const bare = repoPath(name)
  if (!(await dirExists(bare))) {
    await mkdir(bare, { recursive: true })
    const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
    await run(['git', 'init', '--bare'])
    await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
    await run(['git', 'config', 'http.receivepack', 'true'])
  }
  return bare
}

function findLastFlush(data: Uint8Array): number {
  for (let i = data.length - 4; i >= 0; i--) {
    if (data[i] === 0x30 && data[i + 1] === 0x30 &&
        data[i + 2] === 0x30 && data[i + 3] === 0x30) {
      return i
    }
  }
  return -1
}

async function getDefaultBranch(bare: string): Promise<string> {
  const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
    cwd: bare,
    stdout: 'pipe',
    // Ignore stderr to avoid filling the pipe buffer and blocking the process
    stderr: 'ignore',
  })
  if ((await proc.exited) === 0) {
    const ref = await new Response(proc.stdout).text()
    return ref.trim().replace('refs/heads/', '')
  }
  return 'main'
}

async function gitRpc(
  repo: string,
  service: string,
  body: ReadableStream<Uint8Array> | null,
): Promise<Response> {
  const bare = repoPath(repo)

  const proc = Bun.spawn([service, '--stateless-rpc', bare], {
    stdin: body ?? 'ignore',
    stdout: 'pipe',
    // Ignore stderr to avoid filling the pipe buffer and blocking the process
    stderr: 'ignore',
  })

  return new Response(proc.stdout, {
    headers: {
      'Content-Type': `application/x-${service}-result`,
      'Cache-Control': 'no-cache',
    },
  })
}

async function gitService(repo: string, service: string): Promise<Response | null> {
  const bare = repoPath(repo)
  if (!(await dirExists(bare))) return null

  const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
    stdout: 'pipe',
    // Ignore stderr to avoid filling the pipe buffer and blocking the process
    stderr: 'ignore',
  })
  const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
  await proc.exited

  const header = serviceHeader(service)
  const body = new Uint8Array(header.length + stdout.byteLength)
  body.set(header, 0)
  body.set(stdout, header.length)

  return new Response(body, {
    headers: {
      'Content-Type': `application/x-${service}-advertisement`,
      'Cache-Control': 'no-cache',
    },
  })
}

function gitSidebandMessage(text: string): Uint8Array {
  const encoder = new TextEncoder()
  const lines = text.split('\n').filter(Boolean)
  const parts: Uint8Array[] = []
  for (const line of lines) {
    const msg = `\x02remote: ${line}\n`
    const hex = (4 + msg.length).toString(16).padStart(4, '0')
    parts.push(encoder.encode(hex + msg))
  }
  const total = parts.reduce((sum, p) => sum + p.length, 0)
  const out = new Uint8Array(total)
  let offset = 0
  for (const part of parts) {
    out.set(part, offset)
    offset += part.length
  }
  return out
}

async function hasCommits(bare: string): Promise<boolean> {
  const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
    cwd: bare,
    // Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
    stdout: 'ignore',
    stderr: 'ignore',
  })
  return (await proc.exited) === 0
}

function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
  const pos = findLastFlush(gitBody)
  if (pos === -1) {
    const out = new Uint8Array(gitBody.length + msg.length)
    out.set(gitBody, 0)
    out.set(msg, gitBody.length)
    return out
  }
  const out = new Uint8Array(gitBody.length + msg.length)
  out.set(gitBody.subarray(0, pos), 0)
  out.set(msg, pos)
  out.set(gitBody.subarray(pos), pos + msg.length)
  return out
}

function Layout({ title, children }: LayoutProps) {
  return (
    <html>
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <ToolScript />
        <Container>{children}</Container>
      </body>
    </html>
  )
}

async function listRepos(): Promise<string[]> {
  if (!(await dirExists(REPOS_DIR))) return []
  const entries = await readdir(REPOS_DIR, { withFileTypes: true })
  return entries
    .filter(e => e.isDirectory() && e.name.endsWith('.git'))
    .map(e => e.name.replace(/\.git$/, ''))
    .sort()
}

function serviceHeader(service: string): Uint8Array {
  const line = `# service=${service}\n`
  const hex = (4 + line.length).toString(16).padStart(4, '0')
  const header = `${hex}${line}0000`
  return new TextEncoder().encode(header)
}

async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
  const prev = deployLocks.get(repo) ?? Promise.resolve()
  const { promise: lock, resolve: release } = Promise.withResolvers<void>()
  deployLocks.set(repo, lock)
  await prev
  try {
    return await fn()
  } finally {
    release()
    if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
  }
}

function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
  return (
    <Layout title={`Git - ${appName}`}>
      {exists && commits ? (
        <>
          <Heading>Repository</Heading>
          <RepoList>
            <RepoItem>
              <div>
                <RepoName>{appName}</RepoName>
                <HelpText style="margin: 4px 0 0; font-size: 12px">
                  git clone {baseUrl}/{appName}.git
                </HelpText>
              </div>
              <div style="display: flex; gap: 8px; align-items: center">
                <Badge>{branch}</Badge>
                <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
              </div>
            </RepoItem>
          </RepoList>

          <Heading>Push Changes</Heading>
          <CodeBlock>{[
            `git push toes ${branch}`,
            '',
            '# Or if remote not yet added:',
            `git remote add toes ${baseUrl}/${appName}.git`,
            `git push toes ${branch}`,
          ].join('\n')}</CodeBlock>
        </>
      ) : exists ? (
        <>
          <Heading>Repository</Heading>
          <RepoList>
            <RepoItem>
              <div>
                <RepoName>{appName}</RepoName>
                <HelpText style="margin: 4px 0 0; font-size: 12px">
                  git clone {baseUrl}/{appName}.git
                </HelpText>
              </div>
              <Badge>empty</Badge>
            </RepoItem>
          </RepoList>

          <Heading>Push to Deploy</Heading>
          <CodeBlock>{[
            `git remote add toes ${baseUrl}/${appName}.git`,
            'git push toes main',
          ].join('\n')}</CodeBlock>
        </>
      ) : (
        <>
          <Heading>Push to Deploy</Heading>
          <HelpText>
            No git repository for <strong>{appName}</strong> yet.
            Push to create one and deploy.
          </HelpText>
          <CodeBlock>{[
            `git remote add toes ${baseUrl}/${appName}.git`,
            'git push toes main',
          ].join('\n')}</CodeBlock>
        </>
      )}
    </Layout>
  )
}

function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
  return (
    <Layout title="Git">
      <Heading>Push to Deploy</Heading>
      <HelpText>
        Push a git repository to deploy it as a toes app.
        The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
      </HelpText>

      <CodeBlock>{[
        '# Add this server as a remote and push',
        `git remote add toes ${baseUrl}/<app-name>.git`,
        'git push toes main',
        '',
        '# Or push an existing repo',
        `git push ${baseUrl}/<app-name>.git main`,
      ].join('\n')}</CodeBlock>

      {repos.length > 0 && (
        <>
          <Heading>Repositories</Heading>
          <RepoList>
            {repos.map(({ name, commits, branch }) => (
              <RepoItem>
                <div>
                  <RepoName>{name}</RepoName>
                  <HelpText style="margin: 4px 0 0; font-size: 12px">
                    git clone {baseUrl}/{name}.git
                  </HelpText>
                </div>
                <div style="display: flex; gap: 8px; align-items: center">
                  <Badge>{branch}</Badge>
                  {commits
                    ? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
                    : <Badge>empty</Badge>}
                </div>
              </RepoItem>
            ))}
          </RepoList>
        </>
      )}

      {repos.length === 0 && (
        <HelpText>No repositories yet. Push one to get started.</HelpText>
      )}
    </Layout>
  )
}

// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------

mkdirSync(REPOS_DIR, { recursive: true })

app.get('/ok', c => c.text('ok'))

app.get('/styles.css', c =>
  c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
)

// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
  const repoParam = c.req.param('repo').replace(/\.git$/, '')
  const service = c.req.query('service')

  if (!validRepoName(repoParam)) {
    return c.text('Invalid repository name', 400)
  }

  if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
    return c.text('Invalid service', 400)
  }

  if (service === 'git-receive-pack') {
    await ensureBareRepo(repoParam)
  }

  const bare = repoPath(repoParam)
  if (!(await dirExists(bare))) {
    return c.text('Repository not found', 404)
  }

  const res = await gitService(repoParam, service)
  return res ?? c.text('Repository not found', 404)
})

// POST /:repo[.git]/git-upload-pack
app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
  const repoParam = c.req.param('repo').replace(/\.git$/, '')

  if (!validRepoName(repoParam)) {
    return c.text('Invalid repository name', 400)
  }

  const bare = repoPath(repoParam)
  if (!(await dirExists(bare))) {
    return c.text('Repository not found', 404)
  }

  return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
})

// POST /:repo[.git]/git-receive-pack
app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
  const repoParam = c.req.param('repo').replace(/\.git$/, '')

  if (!validRepoName(repoParam)) {
    return c.text('Invalid repository name', 400)
  }

  await ensureBareRepo(repoParam)

  const response = await gitRpc(repoParam, 'git-receive-pack', c.req.raw.body)
  // Buffer the full response so we can inject sideband error messages before the
  // final flush-pkt on deploy failure. The receive-pack response is just ref status
  // lines (not pack data), so the buffer is small regardless of push size.
  const gitBody = new Uint8Array(await response.arrayBuffer())

  const deployError = await withDeployLock(repoParam, async () => {
    try {
      const result = await deploy(repoParam)
      if (result.ok && result.version) {
        const err = await activateApp(repoParam, result.version)
        if (err) {
          console.error(`Activate failed for ${repoParam}: ${err}`)
          return `Deploy succeeded but activation failed: ${err}`
        }
        console.log(`Deployed ${repoParam}@${result.version}`)
        return null
      }
      console.error(`Deploy failed for ${repoParam}: ${result.error}`)
      return `Deploy failed: ${result.error}`
    } catch (e) {
      console.error(`Deploy error for ${repoParam}:`, e)
      return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
    }
  })

  const headers = {
    'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
    'Cache-Control': 'no-cache',
  }

  if (deployError) {
    return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
  }

  return new Response(gitBody, { headers })
})

app.get('/', async c => {
  const appName = c.req.query('app')
  const host = c.req.header('host') ?? 'git.toes.local'
  const baseUrl = `http://${host}`

  // When viewing a specific app, only show that app's repo
  if (appName) {
    const bare = repoPath(appName)
    const exists = await dirExists(bare)
    const [commits, branch] = exists
      ? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
      : [false, 'main']

    return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
  }

  // No app selected — show all repos
  const repos = await listRepos()
  const repoData = await Promise.all(repos.map(async name => {
    const bare = repoPath(name)
    const [commits, branch] = await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
    return { name, commits, branch }
  }))

  return c.html(<RepoListPage baseUrl={baseUrl} repos={repoData} />)
})

export default app.defaults
